[
  {
    "path": ".dockerignore",
    "content": ".github\n.venv\n.vscode\n.data\n.temp\nweb/.next\nweb/node_modules\nweb/.env\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.yml",
    "content": "name: 漏洞反馈\ndescription: 【供中文用户】报错或漏洞请使用这个模板创建，不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决，请勿提 issue。容器间网络连接问题，参考文档 https://docs.langbot.app/zh/workshop/network-details.html  \ntitle: \"[Bug]: \"\nlabels: [\"bug?\"]\nbody:\n  - type: input\n    attributes:\n      label: 运行环境\n      description: LangBot 版本、操作系统、系统架构、**Python版本**、**主机地理位置**\n      placeholder: 例如：v3.3.0、CentOS x64 Python 3.10.3、Docker\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: 异常情况\n      description: 完整描述异常情况，什么时候发生的、发生了什么。**请附带日志信息。** \n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: 复现步骤\n      description: 提供越多信息，我们会越快解决问题，建议多提供配置截图；**如果涉及 Dify、n8n、Langflow 等外部平台，请提供应用的导出文件（如 Dify 应用的 DSL），我们将更快回复您。**\n    validations:\n      required: false\n  - type: textarea\n    attributes:\n      label: 启用的插件\n      description: 有些情况可能和插件功能有关，建议提供插件启用情况。\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report_en.yml",
    "content": "name: Bug report\ndescription: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://docs.langbot.app/en/workshop/network-details.html\ntitle: \"[Bug]: \"\nlabels: [\"bug?\"]\nbody:\n  - type: input\n    attributes:\n      label: Runtime environment\n      description: LangBot version, operating system, system architecture, **Python version**, **host location**\n      placeholder: \"For example: v3.3.0, CentOS x64 Python 3.10.3, Docker\"\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Exception\n      description: Describe the exception in detail, what happened and when it happened. **Please include log information.** \n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Reproduction steps\n      description: How to reproduce this problem, the more detailed the better; the more information you provide, the faster we will solve the problem.\n    validations:\n      required: false\n  - type: textarea\n    attributes:\n      label: Enabled plugins\n      description: Some cases may be related to plugin functionality, so please provide the plugin enablement status.\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request.yml",
    "content": "name: 需求建议\ntitle: \"[Feature]: \"\nlabels: []\ndescription: \"【供中文用户】新功能或现有功能优化请使用这个模板；不符合类别的issue将被直接关闭\"\nbody:\n  - type: dropdown\n    attributes:\n      label: 这是一个？\n      description: 新功能建议还是现有功能优化\n      options:\n        - 新功能\n        - 现有功能优化\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: 详细描述\n      description: 详细描述，越详细越好\n    validations:\n      required: true\n      \n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request_en.yml",
    "content": "name: Feature request\ntitle: \"[Feature]: \"\nlabels: []\ndescription: \"New features or existing feature improvements should use this template; issues that do not match will be closed directly\"\nbody:\n  - type: dropdown\n    attributes:\n      label: This is a?\n      description: New feature request or existing feature improvement\n      options:\n        - New feature\n        - Existing feature improvement\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Detailed description\n      description: Detailed description, the more detailed the better\n    validations:\n      required: true\n      \n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/submit-plugin.yml",
    "content": "name: 提交新插件\ntitle: \"[Plugin]: 请求登记新插件\"\nlabels: [\"独立插件\"]\ndescription: \"【供中文用户】本模板供且仅供提交新插件使用\"\nbody:\n  - type: input\n    attributes:\n      label: 插件名称\n      description: 填写插件的名称\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: 插件代码库地址\n      description: 仅支持 Github\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: 插件简介\n      description: 插件的简介\n    validations:\n      required: true\n      \n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/submit-plugin_en.yml",
    "content": "name: Submit a new plugin\ntitle: \"[Plugin]: Request to register a new plugin\"\nlabels: [\"Independent Plugin\"]\ndescription: \"This template is only for submitting new plugins\"\nbody:\n  - type: input\n    attributes:\n      label: Plugin name\n      description: Fill in the name of the plugin\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Plugin code repository address\n      description: Only support Github\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Plugin description\n      description: The description of the plugin\n    validations:\n      required: true\n      \n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"pip\" # See documentation for possible values\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"weekly\"\n    allow:\n      - dependency-name: \"openai\"\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## 概述 / Overview\n\n> 请在此部分填写你实现/解决/优化的内容:  \n> Summary of what you implemented/solved/optimized:\n> \n\n### 更改前后对比截图 / Screenshots\n\n> 请在此部分粘贴更改前后对比截图（可以是界面截图、控制台输出、对话截图等）:  \n> Please paste the screenshots of changes before and after here (can be interface screenshots, console output, conversation screenshots, etc.):\n> \n> 修改前 / Before:\n> \n> 修改后 / After:\n> \n\n## 检查清单 / Checklist\n\n### PR 作者完成 / For PR author\n\n*请在方括号间写`x`以打勾 / Please tick the box with `x`*\n\n- [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗？ / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)?\n- [ ] 与项目所有者沟通过了吗？ / Have you communicated with the project maintainer?\n- [ ] 我确定已自行测试所作的更改，确保功能符合预期。 / I have tested the changes and ensured they work as expected.\n\n### 项目维护者完成 / For project maintainer\n\n- [ ] 相关 issues 链接了吗？ / Have you linked the related issues?\n- [ ] 配置项写好了吗？迁移写好了吗？生效了吗？ / Have you written the configuration items? Have you written the migration? Has it taken effect?\n- [ ] 依赖加到 pyproject.toml 和 core/bootutils/deps.py 了吗 / Have you added the dependencies to pyproject.toml and core/bootutils/deps.py?\n- [ ] 文档编写了吗？ / Have you written the documentation?"
  },
  {
    "path": ".github/workflows/build-dev-image.yaml",
    "content": "name: Build Dev Image\n\non:\n  push:\n  workflow_dispatch:\n\njobs:\n  build-dev-image:\n    runs-on: ubuntu-latest\n    # 如果是tag则跳过\n    if: ${{ !startsWith(github.ref, 'refs/tags/') }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v2\n        with:\n          persist-credentials: false\n\n      - name: Generate Tag\n        id: generate_tag\n        run: |\n          # 获取分支名称，把/替换为-\n          echo ${{ github.ref }} | sed 's/refs\\/heads\\///g' | sed 's/\\//-/g'\n          echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\\/heads\\///g' | sed 's/\\//-/g')\n      - name: Login to Registry\n        run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}\n      - name: Build Docker Image\n        run: |\n          docker buildx create --name mybuilder --use\n          docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push\n"
  },
  {
    "path": ".github/workflows/build-docker-image.yml",
    "content": "name: Build Docker Image\non:\n  ## 发布release的时候会自动构建\n  release:\n    types: [published]\njobs:\n  publish-docker-image:\n    runs-on: ubuntu-latest\n    name: Build image\n    \n    steps:\n      - name: Checkout\n        uses: actions/checkout@v2\n        with:\n          persist-credentials: false\n\n      - name: judge has env GITHUB_REF  # 如果没有GITHUB_REF环境变量，则把github.ref变量赋值给GITHUB_REF\n        run: |\n          if [ -z \"$GITHUB_REF\" ]; then\n            export GITHUB_REF=${{ github.ref }}\n            echo $GITHUB_REF\n          fi\n      - name: Check version\n        id: check_version\n        run: |\n          echo $GITHUB_REF\n          # 如果是tag，则去掉refs/tags/前缀\n          if [[ $GITHUB_REF == refs/tags/* ]]; then\n            echo \"It's a tag\"\n            echo $GITHUB_REF\n            echo $GITHUB_REF | awk -F '/' '{print $3}'\n            echo ::set-output name=version::$(echo $GITHUB_REF | awk -F '/' '{print $3}')\n          else\n            echo \"It's not a tag\"\n            echo $GITHUB_REF\n            echo ::set-output name=version::${GITHUB_REF}\n          fi\n      - name: Login to Registry\n        run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}\n      - name: Create Buildx\n        run: docker buildx create --name mybuilder --use\n      - name: Build for Release # only relase, exlude pre-release\n        if: ${{ github.event.release.prerelease == false }}\n        run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push\n      - name: Build for Pre-release # no update for latest tag\n        if: ${{ github.event.release.prerelease == true }}\n        run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push"
  },
  {
    "path": ".github/workflows/build-release-artifacts.yaml",
    "content": "name: Build Release Artifacts\n\non:\n  workflow_dispatch:\n  ## 发布release的时候会自动构建\n  release:\n    types: [published]\n\njobs:\n  build-artifacts:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v2\n        with:\n          persist-credentials: false\n\n      - name: Check version\n        id: check_version\n        run: |\n          echo $GITHUB_REF\n          # 如果是tag，则去掉refs/tags/前缀\n          if [[ $GITHUB_REF == refs/tags/* ]]; then\n            echo \"It's a tag\"\n            echo $GITHUB_REF\n            echo $GITHUB_REF | awk -F '/' '{print $3}'\n            echo ::set-output name=version::$(echo $GITHUB_REF | awk -F '/' '{print $3}')\n          else\n            echo \"It's not a tag\"\n            echo $GITHUB_REF\n            echo ::set-output name=version::${GITHUB_REF}\n          fi\n\n      - name: Make Temp Directory\n        run: |\n          mkdir -p /tmp/langbot_build_web\n          cp -r . /tmp/langbot_build_web\n      - name: Setup Node\n        uses: actions/setup-node@v2\n        with:\n          node-version: '22'\n      - name: Build Web\n        run: |\n          cd /tmp/langbot_build_web/web\n          npm install\n          npm run build\n      - name: Package Output\n        run: |\n          cp -r /tmp/langbot_build_web/web/out ./web\n      - name: Upload Artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: langbot-${{ steps.check_version.outputs.version }}-all\n          path: .\n\n      - name: Upload To Release\n        env:\n          GH_TOKEN: ${{ secrets.RELEASE_UPLOAD_GITHUB_TOKEN }}\n        run: |\n          # 本目录下所有文件打包成zip\n          zip -r langbot-${{ steps.check_version.outputs.version }}-all.zip .\n          gh release upload ${{ github.event.release.tag_name }} langbot-${{ steps.check_version.outputs.version }}-all.zip\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Lint\n\non:\n  push:\n    branches:\n      - main\n      - master\n      - dev\n  pull_request:\n    types: [opened, synchronize, reopened, ready_for_review]\n\njobs:\n  ruff:\n    name: Ruff Lint & Format\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v4\n\n      - name: Install dependencies\n        run: uv sync --dev\n\n      - name: Run ruff check\n        run: uv run ruff check src\n\n      - name: Run ruff format\n        run: uv run ruff format src --check\n\n  frontend:\n    name: Frontend Lint\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '25'\n\n      - name: Install pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 9\n\n      - name: Install dependencies\n        working-directory: web\n        run: pnpm install\n\n      - name: Run lint\n        working-directory: web\n        run: pnpm lint\n"
  },
  {
    "path": ".github/workflows/publish-to-pypi.yml",
    "content": "name: Build and Publish to PyPI\n\non:\n  workflow_dispatch:\n  release:\n    types: [published]\n\njobs:\n  build-and-publish:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write  # Required for trusted publishing to PyPI\n    \n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          persist-credentials: false\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '22'\n\n      - name: Build frontend\n        run: |\n          cd web\n          npm install -g pnpm\n          pnpm install\n          pnpm build\n          mkdir -p ../src/langbot/web/out\n          cp -r out ../src/langbot/web/\n\n      - name: Install the latest version of uv\n        uses: astral-sh/setup-uv@v6\n        with:\n          version: \"latest\"\n\n      - name: Build package\n        run: |\n          uv build\n\n      - name: Publish to PyPI\n        run: |\n          uv publish --token ${{ secrets.PYPI_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/run-tests.yml",
    "content": "name: Unit Tests\n\non:\n  pull_request:\n    types: [opened, ready_for_review, synchronize]\n    paths:\n      - 'pkg/**'\n      - 'tests/**'\n      - '.github/workflows/run-tests.yml'\n      - 'pyproject.toml'\n      - 'run_tests.sh'\n  push:\n    branches:\n      - master\n      - develop\n    paths:\n      - 'pkg/**'\n      - 'tests/**'\n      - '.github/workflows/run-tests.yml'\n      - 'pyproject.toml'\n      - 'run_tests.sh'\n\njobs:\n  test:\n    name: Run Unit Tests\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: ['3.11', '3.12', '3.13']\n      fail-fast: false\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ matrix.python-version }}\n\n      - name: Install uv\n        run: |\n          curl -LsSf https://astral.sh/uv/install.sh | sh\n          echo \"$HOME/.cargo/bin\" >> $GITHUB_PATH\n\n      - name: Install dependencies\n        run: |\n          uv sync --dev\n\n      - name: Run unit tests\n        run: |\n          bash run_tests.sh\n\n      - name: Upload coverage to Codecov\n        if: matrix.python-version == '3.12'\n        uses: codecov/codecov-action@v5\n        with:\n          files: ./coverage.xml\n          flags: unit-tests\n          name: unit-tests-coverage\n          fail_ci_if_error: false\n        env:\n          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}\n\n      - name: Test Summary\n        if: always()\n        run: |\n          echo \"## Unit Tests Results\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"Python Version: ${{ matrix.python-version }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"Test Status: ${{ job.status }}\" >> $GITHUB_STEP_SUMMARY\n"
  },
  {
    "path": ".github/workflows/test-dev-image.yaml",
    "content": "name: Test Dev Image\n\non:\n  workflow_run:\n    workflows: [\"Build Dev Image\"]\n    types:\n      - completed\n    branches:\n      - master\n\njobs:\n  test-dev-image:\n    runs-on: ubuntu-latest\n    # Only run if the build workflow succeeded\n    if: ${{ github.event.workflow_run.conclusion == 'success' }}\n    \n    permissions:\n      contents: read\n    \n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Update Docker Compose to use master tag\n        working-directory: ./docker\n        run: |\n          # Replace 'latest' with 'master' tag for testing the dev image\n          sed -i 's/rockchin\\/langbot:latest/rockchin\\/langbot:master/g' docker-compose.yaml\n          echo \"Updated docker-compose.yaml to use master tag:\"\n          cat docker-compose.yaml\n\n      - name: Start Docker Compose\n        working-directory: ./docker\n        run: docker compose up -d\n\n      - name: Wait and Test API\n        run: |\n          # Function to test API endpoint\n          test_api() {\n            echo \"Testing API endpoint...\"\n            response=$(curl -s --connect-timeout 10 --max-time 30 -w \"\\n%{http_code}\" http://localhost:5300/api/v1/system/info 2>&1)\n            curl_exit_code=$?\n            \n            if [ $curl_exit_code -ne 0 ]; then\n              echo \"Curl failed with exit code: $curl_exit_code\"\n              echo \"Error: $response\"\n              return 1\n            fi\n            \n            http_code=$(echo \"$response\" | tail -n 1)\n            response_body=$(echo \"$response\" | head -n -1)\n            \n            if [ \"$http_code\" = \"200\" ]; then\n              echo \"API is healthy! Response code: $http_code\"\n              echo \"Response: $response_body\"\n              return 0\n            else\n              echo \"API returned non-200 response: $http_code\"\n              echo \"Response body: $response_body\"\n              return 1\n            fi\n          }\n\n          # Wait 30 seconds before first attempt\n          echo \"Waiting 30 seconds for services to start...\"\n          sleep 30\n\n          # Try up to 3 times with 30-second intervals\n          max_attempts=3\n          attempt=1\n\n          while [ $attempt -le $max_attempts ]; do\n            echo \"Attempt $attempt of $max_attempts\"\n            \n            if test_api; then\n              echo \"Success! API is responding correctly.\"\n              exit 0\n            fi\n            \n            if [ $attempt -lt $max_attempts ]; then\n              echo \"Retrying in 30 seconds...\"\n              sleep 30\n            fi\n            \n            attempt=$((attempt + 1))\n          done\n\n          # All attempts failed\n          echo \"Failed to get healthy response after $max_attempts attempts\"\n          exit 1\n\n      - name: Show Container Logs on Failure\n        if: failure()\n        working-directory: ./docker\n        run: |\n          echo \"=== Docker Compose Status ===\"\n          docker compose ps\n          echo \"\"\n          echo \"=== LangBot Logs ===\"\n          docker compose logs langbot\n          echo \"\"\n          echo \"=== Plugin Runtime Logs ===\"\n          docker compose logs langbot_plugin_runtime\n\n      - name: Cleanup\n        if: always()\n        working-directory: ./docker\n        run: docker compose down\n"
  },
  {
    "path": ".gitignore",
    "content": "/config.py\n.idea/\n__pycache__/\ndatabase.db\nlangbot.log\n/banlist.py\n/plugins/\n!/plugins/__init__.py\n/revcfg.py\nprompts/\nlogs/\nsensitive.json\ntemp/\ncurrent_tag\nscenario/\n!scenario/default-template.json\noverride.json\ncookies.json\ndata/labels/announcement_saved.json\ncmdpriv.json\ntips.py\nvenv*\nbin/\n.vscode\n/test_*\nvenv/\nhugchat.json\nqcapi\nclaude.json\nbard.json\n/*yaml\n!.pre-commit-config.yaml\n!components.yaml\n!/docker-compose.yaml\ndata/labels/instance_id.json\n.DS_Store\n/data\nbotpy.log*\n/poc\n/libs/wecom_api/test.py\n/venv\ntest.py\n/web_ui\n.venv/\n/test\nplugins.bak\ncoverage.xml\n.coverage\nsrc/langbot/web/\n\n# Build artifacts\n/dist\n/build\n*.egg-info\n"
  },
  {
    "path": ".mcp.json",
    "content": "{\n  \"mcpServers\": {\n    \"shadcn\": {\n      \"command\": \"npx\",\n      \"args\": [\n        \"shadcn@latest\",\n        \"mcp\"\n      ]\n    },\n    \"sequential-thinking\": {\n      \"type\": \"stdio\",\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"],\n      \"env\": {}\n    },\n    \"github\": {\n      \"type\": \"stdio\",\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@modelcontextprotocol/server-github\"],\n      \"env\": {\n        \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"${GITHUB_PERSONAL_ACCESS_TOKEN}\"\n      }\n    },\n    \"fetch\": {\n      \"type\": \"stdio\",\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"],\n      \"env\": {}\n    },\n    \"playwright\": {\n      \"type\": \"stdio\",\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@playwright/mcp@latest\"],\n      \"env\": {}\n    }\n  }\n}\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    # Ruff version.\n    rev: v0.11.7\n    hooks:\n      # Run the linter of backend.\n      - id: ruff\n        args: [--fix]\n      # Run the formatter of backend.\n      - id: ruff-format\n\n  - repo: https://github.com/pre-commit/mirrors-prettier\n    rev: v3.1.0\n    hooks:\n      - id: prettier\n        types_or: [javascript, jsx, ts, tsx, css, scss]\n        additional_dependencies:\n          - prettier@3.1.0\n\n  - repo: local\n    hooks:\n      - id: lint-staged\n        name: lint-staged\n        entry: cd web && pnpm lint-staged\n        language: system\n        types: [javascript, jsx, ts, tsx]\n        pass_filenames: false\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\nThis file is for guiding code agents (like Claude Code, GitHub Copilot, OpenAI Codex, etc.) to work in LangBot project.\n\n## Project Overview\n\nLangBot is a open-source LLM native instant messaging bot development platform, aiming to provide an out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, supporting global instant messaging platforms, and providing rich API interfaces, supporting custom development.\n\nLangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts:\n\n- `./src/langbot`: The main python package of the project, below are the main modules in this package:\n    - `./pkg`: The core python package of the project backend.\n        - `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.\n        - `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.\n        - `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.\n        - `./pkg/api`: The api module of the project, containing the http api controllers and services.\n        - `./pkg/plugin`: LangBot bridge for connecting with plugin system.\n    - `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.\n    - `./templates`: Templates of config files, components, etc.\n    - `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.\n    - `./docker`: docker-compose deployment files.\n\n## Backend Development\n\nWe use `uv` to manage dependencies.\n\n```bash\npip install uv\nuv sync --dev\n```\n\nStart the backend and run the project in development mode.\n\n```bash\nuv run main.py\n```\n\nThen you can access the project at `http://127.0.0.1:5300`.\n\n## Frontend Development\n\nWe use `pnpm` to manage dependencies.\n\n```bash\ncd web\ncp .env.example .env\npnpm install\npnpm dev\n```\n\nThen you can access the project at `http://127.0.0.1:3000`.\n\n## Plugin System Architecture\n\nLangBot is composed of various internal components such as Large Language Model tools, commands, messaging platform adapters, LLM requesters, and more. To meet extensibility and flexibility requirements, we have implemented a production-grade plugin system.\n\nEach plugin runs in an independent process, managed uniformly by the Plugin Runtime. It has two operating modes: `stdio` and `websocket`. When LangBot is started directly by users (not running in a container), it uses `stdio` mode, which is common for personal users or lightweight environments. When LangBot runs in a container, it uses `websocket` mode, designed specifically for production environments.\n\nPlugin Runtime automatically starts each installed plugin and interacts through stdio. In plugin development scenarios, developers can use the lbp command-line tool to start plugins and connect to the running Runtime via WebSocket for debugging.\n\n> Plugin SDK, CLI, Runtime, and entities definitions shared between LangBot and plugins are contained in the [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk) repository.\n\n## Some Development Tips and Standards\n\n- LangBot is a global project, any comments in code should be in English, and user experience should be considered in all aspects.\n- Thus you should consider the i18n support in all aspects.\n- LangBot is widely adopted in both toC and toB scenarios, so you should consider the compatibility and security in all aspects.\n- If you were asked to make a commit, please follow the commit message format: \n    - format: <type>(<scope>): <subject>\n    - type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.\n    - scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.\n    - subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.\n- If you changed the definition of database entities, please update the migration file in `src/langbot/pkg/persistence/migrations/` and update the constants.py file in `src/langbot/pkg/utils/constants.py` with the new migration number.\n\n## Some Principles\n\n- Keep it simple, stupid.\n- Entities should not be multiplied unnecessarily\n- 八荣八耻\n\n    以瞎猜接口为耻，以认真查询为荣。\n    以模糊执行为耻，以寻求确认为荣。\n    以臆想业务为耻，以人类确认为荣。\n    以创造接口为耻，以复用现有为荣。\n    以跳过验证为耻，以主动测试为荣。\n    以破坏架构为耻，以遵循规范为荣。\n    以假装理解为耻，以诚实无知为荣。\n    以盲目修改为耻，以谨慎重构为荣。"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "## 参与项目\n\n欢迎为此项目贡献代码或其他支持，以使您的点子或众人期待的功能成为现实，助力社区成长。  \n\n### 贡献形式\n\n- 提交PR，解决issues中提到的bug或期待的功能\n- 提交PR，实现您设想的功能（请先提出issue与项目维护者沟通）\n- 为本项目在其他社交平台撰写文章、制作视频等\n- 为本项目的衍生项目作出贡献，或开发插件增加功能\n\n### 沟通语言规范\n\n- 在 PR 和 Commit Message 中请使用全英文\n- 对于中文用户，issue 中可以使用中文\n\n<hr/>\n\n## Guidelines\n\n### Contribution\n\n- Submit PRs to solve bugs or features in the issues\n- Submit PRs to implement your ideas (Please create an issue first and communicate with the project maintainer)\n- Write articles or make videos about this project on other social platforms\n- Contribute to the development of derivative projects, or develop plugins to add features\n\n### Spoken Language\n\n- Use English in PRs and Commit Messages\n- For English users, you can use English in issues\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:22-alpine AS node\n\nWORKDIR /app\n\nCOPY web ./web\n\nRUN cd web && npm install && npm run build\n\nFROM python:3.12.7-slim\n\nWORKDIR /app\n\nCOPY . .\n\nCOPY --from=node /app/web/out ./web/out\n\nRUN apt update \\\n    && apt install gcc -y \\\n    && python -m pip install --no-cache-dir uv \\\n    && uv sync \\\n    && touch /.dockerenv\n\nCMD [ \"uv\", \"run\", \"--no-sync\", \"main.py\" ]"
  },
  {
    "path": "LICENSE",
    "content": "                                 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": "README.md",
    "content": "<p align=\"center\">\n<a href=\"https://langbot.app\">\n<img width=\"130\" src=\"res/logo-blue.png\" alt=\"LangBot\"/>\n</a>\n\n<div align=\"center\">\n\n<a href=\"https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot\" target=\"_blank\"><img src=\"https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light\" alt=\"LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n\n<h3>Production-grade platform for building agentic IM bots.</h3>\n<h4>Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.</h4>\n\nEnglish / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)\n<img src=\"https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg\" alt=\"python\">\n[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)\n\n<a href=\"https://langbot.app\">Website</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/features\">Features</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/guide\">Docs</a> ｜\n<a href=\"https://docs.langbot.app/en/tags/readme\">API</a> ｜\n<a href=\"https://space.langbot.app/cloud\">Cloud</a> ｜\n<a href=\"https://space.langbot.app\">Plugin Market</a> ｜\n<a href=\"https://langbot.featurebase.app/roadmap\">Roadmap</a>\n\n</div>\n\n</p>\n\n---\n\n## What is LangBot?\n\nLangBot is an **open-source, production-grade platform** for building AI-powered instant messaging bots. It connects Large Language Models (LLMs) to any chat platform, enabling you to create intelligent agents that can converse, execute tasks, and integrate with your existing workflows.\n\n### Key Capabilities\n\n- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).\n- **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.\n- **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises.\n- **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support.\n- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.\n- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.\n\n[→ Learn more about all features](https://docs.langbot.app/en/insight/features)\n\n---\n\n## Quick Start\n\n### ☁️ LangBot Cloud (Recommended)\n\n**[LangBot Cloud](https://space.langbot.app/cloud)** — Zero deployment, ready to use.\n\n### One-Line Launch\n\n```bash\nuvx langbot\n```\n\n> Requires [uv](https://docs.astral.sh/uv/getting-started/installation/). Visit http://localhost:5300 — done.\n\n### Docker Compose\n\n```bash\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\ndocker compose up -d\n```\n\n### One-Click Cloud Deploy\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)\n[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)\n\n**More options:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt) · [Kubernetes](./docker/README_K8S.md)\n\n---\n\n## Supported Platforms\n\n| Platform | Status | Notes |\n|----------|--------|-------|\n| Discord | ✅ |  |\n| Telegram | ✅ |  |\n| Slack | ✅ |  |\n| LINE | ✅ |  |\n| QQ | ✅ | Personal & Official API |\n| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |\n| WeChat | ✅ | Personal & Official Account |\n| Lark | ✅ |  |\n| DingTalk | ✅ |  |\n| KOOK | ✅ |  |\n| Satori | ✅ |  |\n\n---\n\n## Supported LLMs & Integrations\n\n| Provider | Type | Status |\n|----------|------|--------|\n| [OpenAI](https://platform.openai.com/) | LLM | ✅ |\n| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |\n| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |\n| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |\n| [xAI](https://x.ai/) | LLM | ✅ |\n| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |\n| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |\n| [Ollama](https://ollama.com/) | Local LLM | ✅ |\n| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |\n| [Dify](https://dify.ai) | LLMOps | ✅ |\n| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |\n| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |\n| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |\n| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |\n| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |\n| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |\n| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |\n| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |\n| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |\n| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |\n| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |\n\n[→ View all integrations](https://docs.langbot.app/en/insight/features)\n\n---\n\n## Why LangBot?\n\n| Use Case | How LangBot Helps |\n|----------|-------------------|\n| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |\n| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |\n| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |\n| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |\n\n---\n\n## Live Demo\n\n**Try it now:** https://demo.langbot.dev/\n- Email: `demo@langbot.app`\n- Password: `langbot123456`\n\n*Note: Public demo environment. Do not enter sensitive information.*\n\n---\n\n## Community\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)\n\n- [Discord Community](https://discord.gg/wdNEHETs87)\n\n---\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)\n\n---\n\n## Contributors\n\nThanks to all [contributors](https://github.com/langbot-app/LangBot/graphs/contributors) who have helped make LangBot better:\n\n<a href=\"https://github.com/langbot-app/LangBot/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=langbot-app/LangBot\" />\n</a>\n"
  },
  {
    "path": "README_CN.md",
    "content": "<p align=\"center\">\n<a href=\"https://langbot.app\">\n<img width=\"130\" src=\"res/logo-blue.png\" alt=\"LangBot\"/>\n</a>\n\n<div align=\"center\">\n\n<a href=\"https://hellogithub.com/repository/langbot-app/LangBot\" target=\"_blank\"><img src=\"https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R\" alt=\"Featured｜HelloGitHub\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n\n<h3>生产级 AI 即时通信机器人开发平台。</h3>\n<h4>快速构建、调试和部署 AI 机器人到微信、QQ、飞书、Slack、Discord、Telegram 等平台。</h4>\n\n[English](README.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)\n[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/DxZZcNxM1W)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)\n<img src=\"https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg\" alt=\"python\">\n[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)\n[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)\n\n<a href=\"https://langbot.app\">官网</a> ｜\n<a href=\"https://docs.langbot.app/zh/insight/features.html\">特性</a> ｜\n<a href=\"https://docs.langbot.app/zh/insight/guide.html\">文档</a> ｜\n<a href=\"https://docs.langbot.app/zh/tags/readme.html\">API</a> ｜\n<a href=\"https://space.langbot.app/cloud\">Cloud</a> ｜\n<a href=\"https://space.langbot.app\">插件市场</a> ｜\n<a href=\"https://langbot.featurebase.app/roadmap\">路线图</a>\n\n</div>\n\n</p>\n\n---\n\n## 什么是 LangBot？\n\nLangBot 是一个**开源的生产级平台**，用于构建 AI 驱动的即时通信机器人。它将大语言模型（LLM）连接到各种聊天平台，帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。\n\n### 核心能力\n\n- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG（知识库），深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。\n- **全平台支持** — 一套代码，覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。\n- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理，已被多家企业采用。\n- **插件生态** — 数百个插件，事件驱动架构，组件扩展，适配 [MCP 协议](https://modelcontextprotocol.io/)。\n- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人，无需手动编辑配置文件。\n- **多流水线架构** — 不同机器人用于不同场景，具备全面的监控和异常处理能力。\n\n[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)\n\n---\n\n## 快速开始\n\n### ☁️ LangBot Cloud（推荐）\n\n**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署，开箱即用。\n\n### 一键启动\n\n```bash\nuvx langbot\n```\n\n> 需要安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)。访问 http://localhost:5300 即可使用。\n\n### Docker Compose\n\n```bash\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\ndocker compose up -d\n```\n\n### 一键云部署\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)\n[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)\n\n**更多方式：** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [宝塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)\n\n---\n\n## 支持的平台\n\n| 平台 | 状态 | 备注 |\n|------|------|------|\n| QQ | ✅ | 个人号、官方机器人（频道、私聊、群聊） |\n| 微信 | ✅ | 个人微信、微信公众号 |\n| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |\n| 飞书 | ✅ |  |\n| 钉钉 | ✅ |  |\n| Discord | ✅ |  |\n| Telegram | ✅ |  |\n| Slack | ✅ |  |\n| LINE | ✅ |  |\n| KOOK | ✅ |  |\n\n---\n\n## 支持的大模型与集成\n\n| 提供商 | 类型 | 状态 |\n|--------|------|------|\n| [OpenAI](https://platform.openai.com/) | LLM | ✅ |\n| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |\n| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |\n| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |\n| [xAI](https://x.ai/) | LLM | ✅ |\n| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |\n| [智谱AI](https://open.bigmodel.cn/) | LLM | ✅ |\n| [Ollama](https://ollama.com/) | 本地 LLM | ✅ |\n| [LM Studio](https://lmstudio.ai/) | 本地 LLM | ✅ |\n| [Dify](https://dify.ai) | LLMOps | ✅ |\n| [MCP](https://modelcontextprotocol.io/) | 协议 | ✅ |\n| [SiliconFlow](https://siliconflow.cn/) | 聚合平台 | ✅ |\n| [阿里云百炼](https://bailian.console.aliyun.com/) | 聚合平台 | ✅ |\n| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 聚合平台 | ✅ |\n| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 聚合平台 | ✅ |\n| [GiteeAI](https://ai.gitee.com/) | 聚合平台 | ✅ |\n| [胜算云](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 平台 | ✅ |\n| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |\n| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |\n| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |\n| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |\n| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |\n| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |\n\n[→ 查看完整集成列表](https://docs.langbot.app/zh/insight/features.html)\n\n### TTS（语音合成）\n\n| 平台/模型 | 备注 |\n|-----------|------|\n| [FishAudio](https://fish.audio/zh-CN/discovery/) | [插件](https://github.com/the-lazy-me/NewChatVoice) |\n| [海豚 AI](https://www.ttson.cn/?source=thelazy) | [插件](https://github.com/the-lazy-me/NewChatVoice) |\n| [AzureTTS](https://portal.azure.com/) | [插件](https://github.com/Ingnaryk/LangBot_AzureTTS) |\n\n### 文生图\n\n| 平台/模型 | 备注 |\n|-----------|------|\n| 阿里云百炼 | [插件](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |\n\n---\n\n## 为什么选择 LangBot？\n\n| 使用场景 | LangBot 如何帮助 |\n|----------|------------------|\n| **客户服务** | 将 AI Agent 部署到微信/企微/钉钉/飞书，基于知识库自动回答用户问题 |\n| **内部工具** | 将 n8n/Dify 工作流接入企微/钉钉，实现业务流程自动化 |\n| **社群运营** | 在 QQ/Discord 群中使用 AI 驱动的内容审核与智能互动 |\n| **多平台触达** | 一个机器人，覆盖所有平台。通过统一面板集中管理 |\n\n---\n\n## 在线演示\n\n**立即体验：** https://demo.langbot.dev/\n- 邮箱：`demo@langbot.app`\n- 密码：`langbot123456`\n\n*注意：公开演示环境，请不要在其中填入任何敏感信息。*\n\n---\n\n## 社区\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)\n[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/DxZZcNxM1W)\n\n- [Discord 社区](https://discord.gg/wdNEHETs87)\n- [QQ 社区群](https://qm.qq.com/q/DxZZcNxM1W)\n\n---\n\n## Star 趋势\n\n[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)\n\n---\n\n## 贡献者\n\n感谢所有[贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)对 LangBot 的帮助：\n\n<a href=\"https://github.com/langbot-app/LangBot/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=langbot-app/LangBot\" />\n</a>\n\n<!--\n## For Code Agents\n\nIf you're a code agent based on LLM, you should read [AGENTS.md](AGENTS.md) to understand the project structure and development process before you start any development.\n-->\n"
  },
  {
    "path": "README_ES.md",
    "content": "<p align=\"center\">\n<a href=\"https://langbot.app\">\n<img width=\"130\" src=\"res/logo-blue.png\" alt=\"LangBot\"/>\n</a>\n\n<div align=\"center\">\n\n<a href=\"https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot\" target=\"_blank\"><img src=\"https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light\" alt=\"LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n\n<h3>Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.</h3>\n<h4>Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.</h4>\n\n[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / Español / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)\n<img src=\"https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg\" alt=\"python\">\n[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)\n\n<a href=\"https://langbot.app\">Inicio</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/features.html\">Características</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/guide.html\">Documentación</a> ｜\n<a href=\"https://docs.langbot.app/en/tags/readme.html\">API</a> ｜\n<a href=\"https://space.langbot.app\">Mercado de Plugins</a> ｜\n<a href=\"https://langbot.featurebase.app/roadmap\">Hoja de Ruta</a>\n\n</div>\n\n</p>\n\n---\n\n## ¿Qué es LangBot?\n\nLangBot es una **plataforma de código abierto y grado de producción** para construir bots de mensajería instantánea impulsados por IA. Conecta modelos de lenguaje de gran escala (LLMs) con cualquier plataforma de chat, permitiéndole crear agentes inteligentes que pueden conversar, ejecutar tareas e integrarse con sus flujos de trabajo existentes.\n\n### Capacidades Clave\n\n- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).\n- **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.\n- **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas.\n- **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/).\n- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.\n- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.\n\n[→ Conocer más sobre todas las funcionalidades](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## Inicio Rápido\n\n### ☁️ LangBot Cloud (Recomendado)\n\n**[LangBot Cloud](https://space.langbot.app/cloud)** — Sin despliegue, listo para usar.\n\n### Lanzamiento en una línea\n\n```bash\nuvx langbot\n```\n\n> Requiere [uv](https://docs.astral.sh/uv/getting-started/installation/). Visite http://localhost:5300 — listo.\n\n### Docker Compose\n\n```bash\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\ndocker compose up -d\n```\n\n### Despliegue en la Nube con un Clic\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)\n[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)\n\n**Más opciones:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)\n\n---\n\n## Plataformas Soportadas\n\n| Plataforma | Estado | Notas |\n|----------|--------|-------|\n| Discord | ✅ |  |\n| Telegram | ✅ |  |\n| Slack | ✅ |  |\n| LINE | ✅ |  |\n| QQ | ✅ | Personal y API Oficial |\n| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |\n| WeChat | ✅ | Personal y Cuenta Oficial |\n| Lark | ✅ |  |\n| DingTalk | ✅ |  |\n| KOOK | ✅ |  |\n| Satori | ✅ |  |\n\n---\n\n## LLMs e Integraciones Soportadas\n\n| Proveedor | Tipo | Estado |\n|----------|------|--------|\n| [OpenAI](https://platform.openai.com/) | LLM | ✅ |\n| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |\n| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |\n| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |\n| [xAI](https://x.ai/) | LLM | ✅ |\n| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |\n| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |\n| [Ollama](https://ollama.com/) | LLM Local | ✅ |\n| [LM Studio](https://lmstudio.ai/) | LLM Local | ✅ |\n| [Dify](https://dify.ai) | LLMOps | ✅ |\n| [MCP](https://modelcontextprotocol.io/) | Protocolo | ✅ |\n| [SiliconFlow](https://siliconflow.cn/) | Pasarela | ✅ |\n| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Pasarela | ✅ |\n| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Pasarela | ✅ |\n| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Pasarela | ✅ |\n| [GiteeAI](https://ai.gitee.com/) | Pasarela | ✅ |\n| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plataforma GPU | ✅ |\n| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ |\n| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |\n| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |\n| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |\n\n[→ Ver todas las integraciones](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## ¿Por qué LangBot?\n\n| Caso de Uso | Cómo Ayuda LangBot |\n|----------|-------------------|\n| **Atención al cliente** | Despliegue agentes de IA en Slack/Discord/Telegram que respondan preguntas usando su base de conocimientos |\n| **Herramientas internas** | Conecte flujos de trabajo de n8n/Dify a WeCom/DingTalk para procesos empresariales automatizados |\n| **Gestión de comunidades** | Modere grupos de QQ/Discord con filtrado de contenido e interacción impulsados por IA |\n| **Presencia multiplataforma** | Un solo bot, todas las plataformas. Gestione desde un único panel de control |\n\n---\n\n## Demo en Vivo\n\n**Pruébelo ahora:** https://demo.langbot.dev/\n- Correo electrónico: `demo@langbot.app`\n- Contraseña: `langbot123456`\n\n*Nota: Entorno de demostración público. No ingrese información confidencial.*\n\n---\n\n## Comunidad\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)\n\n- [Comunidad de Discord](https://discord.gg/wdNEHETs87)\n\n---\n\n## Historial de Stars\n\n[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)\n\n---\n\n## Colaboradores\n\nGracias a todos los [colaboradores](https://github.com/langbot-app/LangBot/graphs/contributors) que han ayudado a mejorar LangBot:\n\n<a href=\"https://github.com/langbot-app/LangBot/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=langbot-app/LangBot\" />\n</a>\n"
  },
  {
    "path": "README_FR.md",
    "content": "<p align=\"center\">\n<a href=\"https://langbot.app\">\n<img width=\"130\" src=\"res/logo-blue.png\" alt=\"LangBot\"/>\n</a>\n\n<div align=\"center\">\n\n<a href=\"https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot\" target=\"_blank\"><img src=\"https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light\" alt=\"LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n\n<h3>Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.</h3>\n<h4>Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.</h4>\n\n[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / Français / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)\n<img src=\"https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg\" alt=\"python\">\n[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)\n\n<a href=\"https://langbot.app\">Accueil</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/features.html\">Fonctionnalités</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/guide.html\">Documentation</a> ｜\n<a href=\"https://docs.langbot.app/en/tags/readme.html\">API</a> ｜\n<a href=\"https://space.langbot.app\">Marché des Plugins</a> ｜\n<a href=\"https://langbot.featurebase.app/roadmap\">Feuille de Route</a>\n\n</div>\n\n</p>\n\n---\n\n## Qu'est-ce que LangBot ?\n\nLangBot est une **plateforme open-source de niveau production** pour créer des bots de messagerie instantanée alimentés par l'IA. Elle connecte les grands modèles de langage (LLMs) à n'importe quelle plateforme de chat, vous permettant de créer des agents intelligents capables de converser, d'exécuter des tâches et de s'intégrer à vos workflows existants.\n\n### Capacités Clés\n\n- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).\n- **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.\n- **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises.\n- **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/).\n- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.\n- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.\n\n[→ En savoir plus sur toutes les fonctionnalités](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## Démarrage Rapide\n\n### ☁️ LangBot Cloud (Recommandé)\n\n**[LangBot Cloud](https://space.langbot.app/cloud)** — Sans déploiement, prêt à utiliser.\n\n### Lancement en une ligne\n\n```bash\nuvx langbot\n```\n\n> Nécessite [uv](https://docs.astral.sh/uv/getting-started/installation/). Visitez http://localhost:5300 — c'est prêt.\n\n### Docker Compose\n\n```bash\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\ndocker compose up -d\n```\n\n### Déploiement Cloud en un Clic\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)\n[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)\n\n**Plus d'options :** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manuel](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)\n\n---\n\n## Plateformes Supportées\n\n| Plateforme | Statut | Notes |\n|----------|--------|-------|\n| Discord | ✅ |  |\n| Telegram | ✅ |  |\n| Slack | ✅ |  |\n| LINE | ✅ |  |\n| QQ | ✅ | Personnel & API Officielle |\n| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |\n| WeChat | ✅ | Personnel & Compte Officiel |\n| Lark | ✅ |  |\n| DingTalk | ✅ |  |\n| KOOK | ✅ |  |\n| Satori | ✅ |  |\n\n---\n\n## LLMs et Intégrations Supportés\n\n| Fournisseur | Type | Statut |\n|----------|------|--------|\n| [OpenAI](https://platform.openai.com/) | LLM | ✅ |\n| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |\n| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |\n| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |\n| [xAI](https://x.ai/) | LLM | ✅ |\n| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |\n| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |\n| [Ollama](https://ollama.com/) | LLM Local | ✅ |\n| [LM Studio](https://lmstudio.ai/) | LLM Local | ✅ |\n| [Dify](https://dify.ai) | LLMOps | ✅ |\n| [MCP](https://modelcontextprotocol.io/) | Protocole | ✅ |\n| [SiliconFlow](https://siliconflow.cn/) | Passerelle | ✅ |\n| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Passerelle | ✅ |\n| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Passerelle | ✅ |\n| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ |\n| [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ |\n| [接口 AI](https://jiekou.ai/) | Passerelle | ✅ |\n| [302.AI](https://share.302.ai/SuTG99) | Passerelle | ✅ |\n| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |\n| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |\n| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |\n\n[→ Voir toutes les intégrations](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## Pourquoi LangBot ?\n\n| Cas d'Usage | Comment LangBot Aide |\n|----------|-------------------|\n| **Support Client** | Déployez des agents IA sur Slack/Discord/Telegram qui répondent aux questions en utilisant votre base de connaissances |\n| **Outils Internes** | Connectez les workflows n8n/Dify à WeCom/DingTalk pour automatiser vos processus métier |\n| **Gestion de Communauté** | Modérez les groupes QQ/Discord avec un filtrage de contenu et des interactions alimentés par l'IA |\n| **Présence Multi-plateforme** | Un seul bot, toutes les plateformes. Gérez tout depuis un tableau de bord unique |\n\n---\n\n## Démo en Ligne\n\n**Essayez maintenant :** https://demo.langbot.dev/\n- Email : `demo@langbot.app`\n- Mot de passe : `langbot123456`\n\n*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*\n\n---\n\n## Communauté\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)\n\n- [Communauté Discord](https://discord.gg/wdNEHETs87)\n\n---\n\n## Historique des Stars\n\n[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)\n\n---\n\n## Contributeurs\n\nMerci à tous les [contributeurs](https://github.com/langbot-app/LangBot/graphs/contributors) qui ont aidé à améliorer LangBot :\n\n<a href=\"https://github.com/langbot-app/LangBot/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=langbot-app/LangBot\" />\n</a>\n"
  },
  {
    "path": "README_JP.md",
    "content": "<p align=\"center\">\n<a href=\"https://langbot.app\">\n<img width=\"130\" src=\"res/logo-blue.png\" alt=\"LangBot\"/>\n</a>\n\n<div align=\"center\">\n\n<a href=\"https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot\" target=\"_blank\"><img src=\"https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light\" alt=\"LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n\n<h3>AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。</h3>\n<h4>Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。</h4>\n\n[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / 日本語 / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)\n<img src=\"https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg\" alt=\"python\">\n[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)\n\n<a href=\"https://langbot.app\">ホーム</a> ｜\n<a href=\"https://docs.langbot.app/ja/insight/features.html\">機能</a> ｜\n<a href=\"https://docs.langbot.app/ja/insight/guide.html\">ドキュメント</a> ｜\n<a href=\"https://docs.langbot.app/ja/tags/readme.html\">API</a> ｜\n<a href=\"https://space.langbot.app\">プラグインマーケット</a> ｜\n<a href=\"https://langbot.featurebase.app/roadmap\">ロードマップ</a>\n\n</div>\n\n</p>\n\n---\n\n## LangBot とは？\n\nLangBot は、AI搭載のインスタントメッセージングボットを構築するための**オープンソースの本番グレードプラットフォーム**です。大規模言語モデル（LLM）をあらゆるチャットプラットフォームに接続し、会話、タスク実行、既存のワークフローとの統合が可能なインテリジェントエージェントを作成できます。\n\n### 主な機能\n\n- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG（ナレッジベース）を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) と深く統合。\n- **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。\n- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。\n- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。\n- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。\n- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。\n\n[→ すべての機能について詳しく見る](https://docs.langbot.app/ja/insight/features.html)\n\n---\n\n## クイックスタート\n\n### ☁️ LangBot Cloud（推奨）\n\n**[LangBot Cloud](https://space.langbot.app/cloud)** — デプロイ不要、すぐに使えます。\n\n### ワンライン起動\n\n```bash\nuvx langbot\n```\n\n> [uv](https://docs.astral.sh/uv/getting-started/installation/) が必要です。http://localhost:5300 にアクセスして完了。\n\n### Docker Compose\n\n```bash\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\ndocker compose up -d\n```\n\n### ワンクリッククラウドデプロイ\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)\n[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)\n\n**その他:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)\n\n---\n\n## 対応プラットフォーム\n\n| プラットフォーム | ステータス | 備考 |\n|----------|--------|-------|\n| Discord | ✅ |  |\n| Telegram | ✅ |  |\n| Slack | ✅ |  |\n| LINE | ✅ |  |\n| QQ | ✅ | 個人 & 公式API |\n| WeCom | ✅ | 企業WeChat、外部CS、AIボット |\n| WeChat | ✅ | 個人 & 公式アカウント |\n| Lark | ✅ |  |\n| DingTalk | ✅ |  |\n| KOOK | ✅ |  |\n| Satori | ✅ |  |\n\n---\n\n## 対応LLMと統合\n\n| プロバイダー | タイプ | ステータス |\n|----------|------|--------|\n| [OpenAI](https://platform.openai.com/) | LLM | ✅ |\n| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |\n| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |\n| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |\n| [xAI](https://x.ai/) | LLM | ✅ |\n| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |\n| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |\n| [Ollama](https://ollama.com/) | ローカルLLM | ✅ |\n| [LM Studio](https://lmstudio.ai/) | ローカルLLM | ✅ |\n| [Dify](https://dify.ai) | LLMOps | ✅ |\n| [MCP](https://modelcontextprotocol.io/) | プロトコル | ✅ |\n| [SiliconFlow](https://siliconflow.cn/) | ゲートウェイ | ✅ |\n| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ゲートウェイ | ✅ |\n| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ゲートウェイ | ✅ |\n| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ゲートウェイ | ✅ |\n| [GiteeAI](https://ai.gitee.com/) | ゲートウェイ | ✅ |\n| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPUプラットフォーム | ✅ |\n| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ |\n| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |\n| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |\n| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |\n\n[→ すべての統合を表示](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## なぜ LangBot？\n\n| ユースケース | LangBot の活用方法 |\n|----------|-------------------|\n| **カスタマーサポート** | ナレッジベースを活用して質問に回答するAIエージェントをSlack/Discord/Telegramにデプロイ |\n| **社内ツール** | n8n/Difyのワークフローを WeCom/DingTalk に接続し、業務プロセスを自動化 |\n| **コミュニティ管理** | AI搭載のコンテンツフィルタリングとインタラクションでQQ/Discordグループをモデレーション |\n| **マルチプラットフォーム展開** | 1つのボットで全プラットフォームに対応。単一のダッシュボードから管理 |\n\n---\n\n## ライブデモ\n\n**今すぐ試す:** https://demo.langbot.dev/\n- メール: `demo@langbot.app`\n- パスワード: `langbot123456`\n\n*注意: 公開デモ環境です。機密情報を入力しないでください。*\n\n---\n\n## コミュニティ\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)\n\n- [Discord コミュニティ](https://discord.gg/wdNEHETs87)\n\n---\n\n## Star 推移\n\n[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)\n\n---\n\n## コントリビューター\n\nLangBot をより良くするために貢献してくださったすべての[コントリビューター](https://github.com/langbot-app/LangBot/graphs/contributors)に感謝します:\n\n<a href=\"https://github.com/langbot-app/LangBot/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=langbot-app/LangBot\" />\n</a>\n"
  },
  {
    "path": "README_KO.md",
    "content": "<p align=\"center\">\n<a href=\"https://langbot.app\">\n<img width=\"130\" src=\"res/logo-blue.png\" alt=\"LangBot\"/>\n</a>\n\n<div align=\"center\">\n\n<a href=\"https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot\" target=\"_blank\"><img src=\"https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light\" alt=\"LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n\n<h3>AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.</h3>\n<h4>Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.</h4>\n\n[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / 한국어 / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)\n<img src=\"https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg\" alt=\"python\">\n[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)\n\n<a href=\"https://langbot.app\">홈</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/features.html\">기능</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/guide.html\">문서</a> ｜\n<a href=\"https://docs.langbot.app/en/tags/readme.html\">API</a> ｜\n<a href=\"https://space.langbot.app\">플러그인 마켓</a> ｜\n<a href=\"https://langbot.featurebase.app/roadmap\">로드맵</a>\n\n</div>\n\n</p>\n\n---\n\n## LangBot이란?\n\nLangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈소스 프로덕션 등급 플랫폼**입니다. 대규모 언어 모델(LLM)을 모든 채팅 플랫폼에 연결하여 대화, 작업 실행, 기존 워크플로우와의 통합이 가능한 지능형 에이전트를 만들 수 있습니다.\n\n### 핵심 기능\n\n- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) 심층 통합.\n- **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원.\n- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.\n- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원.\n- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.\n- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.\n\n[→ 모든 기능 자세히 보기](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## 빠른 시작\n\n### ☁️ LangBot Cloud (추천)\n\n**[LangBot Cloud](https://space.langbot.app/cloud)** — 배포 없이 바로 사용.\n\n### 원라인 실행\n\n```bash\nuvx langbot\n```\n\n> [uv](https://docs.astral.sh/uv/getting-started/installation/) 설치 필요. http://localhost:5300 방문 — 완료.\n\n### Docker Compose\n\n```bash\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\ndocker compose up -d\n```\n\n### 원클릭 클라우드 배포\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)\n[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)\n\n**더 많은 옵션:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)\n\n---\n\n## 지원 플랫폼\n\n| 플랫폼 | 상태 | 비고 |\n|--------|------|------|\n| Discord | ✅ |  |\n| Telegram | ✅ |  |\n| Slack | ✅ |  |\n| LINE | ✅ |  |\n| QQ | ✅ | 개인 및 공식 API |\n| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |\n| WeChat | ✅ | 개인 및 공식 계정 |\n| Lark | ✅ |  |\n| DingTalk | ✅ |  |\n| KOOK | ✅ |  |\n| Satori | ✅ |  |\n\n---\n\n## 지원 LLM 및 통합\n\n| 제공자 | 유형 | 상태 |\n|--------|------|------|\n| [OpenAI](https://platform.openai.com/) | LLM | ✅ |\n| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |\n| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |\n| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |\n| [xAI](https://x.ai/) | LLM | ✅ |\n| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |\n| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |\n| [Ollama](https://ollama.com/) | 로컬 LLM | ✅ |\n| [LM Studio](https://lmstudio.ai/) | 로컬 LLM | ✅ |\n| [Dify](https://dify.ai) | LLMOps | ✅ |\n| [MCP](https://modelcontextprotocol.io/) | 프로토콜 | ✅ |\n| [SiliconFlow](https://siliconflow.cn/) | 게이트웨이 | ✅ |\n| [Aliyun Bailian](https://bailian.console.aliyun.com/) | 게이트웨이 | ✅ |\n| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 게이트웨이 | ✅ |\n| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 게이트웨이 | ✅ |\n| [GiteeAI](https://ai.gitee.com/) | 게이트웨이 | ✅ |\n| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 플랫폼 | ✅ |\n| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ |\n| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |\n| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |\n| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |\n\n[→ 모든 통합 보기](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## 왜 LangBot인가?\n\n| 사용 사례 | LangBot 활용 방법 |\n|-----------|-------------------|\n| **고객 지원** | 지식 베이스를 활용하여 질문에 답변하는 AI 에이전트를 Slack/Discord/Telegram에 배포 |\n| **내부 도구** | n8n/Dify 워크플로우를 WeCom/DingTalk에 연결하여 비즈니스 프로세스 자동화 |\n| **커뮤니티 관리** | AI 기반 콘텐츠 필터링 및 상호작용으로 QQ/Discord 그룹 관리 |\n| **멀티 플랫폼** | 하나의 봇으로 모든 플랫폼 지원. 단일 대시보드에서 관리 |\n\n---\n\n## 라이브 데모\n\n**지금 체험:** https://demo.langbot.dev/\n- 이메일: `demo@langbot.app`\n- 비밀번호: `langbot123456`\n\n*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*\n\n---\n\n## 커뮤니티\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)\n\n- [Discord 커뮤니티](https://discord.gg/wdNEHETs87)\n\n---\n\n## Star 추이\n\n[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)\n\n---\n\n## 기여자\n\nLangBot을 더 나은 프로젝트로 만들어 주신 모든 [기여자](https://github.com/langbot-app/LangBot/graphs/contributors)분들께 감사드립니다:\n\n<a href=\"https://github.com/langbot-app/LangBot/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=langbot-app/LangBot\" />\n</a>\n"
  },
  {
    "path": "README_RU.md",
    "content": "<p align=\"center\">\n<a href=\"https://langbot.app\">\n<img width=\"130\" src=\"res/logo-blue.png\" alt=\"LangBot\"/>\n</a>\n\n<div align=\"center\">\n\n<a href=\"https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot\" target=\"_blank\"><img src=\"https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light\" alt=\"LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n\n<h3>Платформа производственного уровня для создания агентных IM-ботов.</h3>\n<h4>Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.</h4>\n\n[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / Русский / [Tiếng Việt](README_VI.md)\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)\n<img src=\"https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg\" alt=\"python\">\n[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)\n\n<a href=\"https://langbot.app\">Главная</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/features.html\">Возможности</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/guide.html\">Документация</a> ｜\n<a href=\"https://docs.langbot.app/en/tags/readme.html\">API</a> ｜\n<a href=\"https://space.langbot.app\">Магазин плагинов</a> ｜\n<a href=\"https://langbot.featurebase.app/roadmap\">Дорожная карта</a>\n\n</div>\n\n</p>\n\n---\n\n## Что такое LangBot?\n\nLangBot — это **платформа с открытым исходным кодом производственного уровня** для создания ИИ-ботов в мессенджерах. Она связывает большие языковые модели (LLM) с любой чат-платформой, позволяя создавать интеллектуальных агентов, которые могут вести диалоги, выполнять задачи и интегрироваться с вашими существующими рабочими процессами.\n\n### Ключевые возможности\n\n- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).\n- **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.\n- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде.\n- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/).\n- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.\n- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.\n\n[→ Подробнее обо всех возможностях](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## Быстрый старт\n\n### ☁️ LangBot Cloud (Рекомендуется)\n\n**[LangBot Cloud](https://space.langbot.app/cloud)** — Без развёртывания, готово к использованию.\n\n### Запуск одной командой\n\n```bash\nuvx langbot\n```\n\n> Требуется [uv](https://docs.astral.sh/uv/getting-started/installation/). Откройте http://localhost:5300 — готово.\n\n### Docker Compose\n\n```bash\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\ndocker compose up -d\n```\n\n### Облачное развертывание одним кликом\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)\n[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)\n\n**Другие варианты:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Ручная установка](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)\n\n---\n\n## Поддерживаемые платформы\n\n| Платформа | Статус | Примечания |\n|-----------|--------|------------|\n| Discord | ✅ |  |\n| Telegram | ✅ |  |\n| Slack | ✅ |  |\n| LINE | ✅ |  |\n| QQ | ✅ | Личный и официальный API |\n| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |\n| WeChat | ✅ | Личный и официальный аккаунт |\n| Lark | ✅ |  |\n| DingTalk | ✅ |  |\n| KOOK | ✅ |  |\n| Satori | ✅ |  |\n\n---\n\n## Поддерживаемые LLM и интеграции\n\n| Провайдер | Тип | Статус |\n|-----------|-----|--------|\n| [OpenAI](https://platform.openai.com/) | LLM | ✅ |\n| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |\n| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |\n| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |\n| [xAI](https://x.ai/) | LLM | ✅ |\n| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |\n| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |\n| [Ollama](https://ollama.com/) | Локальный LLM | ✅ |\n| [LM Studio](https://lmstudio.ai/) | Локальный LLM | ✅ |\n| [Dify](https://dify.ai) | LLMOps | ✅ |\n| [MCP](https://modelcontextprotocol.io/) | Протокол | ✅ |\n| [SiliconFlow](https://siliconflow.cn/) | Шлюз | ✅ |\n| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Шлюз | ✅ |\n| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ |\n| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ |\n| [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ |\n| [302.AI](https://share.302.ai/SuTG99) | Шлюз | ✅ |\n| [接口 AI](https://jiekou.ai/) | Шлюз | ✅ |\n| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |\n| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |\n| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |\n\n[→ Смотреть все интеграции](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## Почему LangBot?\n\n| Сценарий использования | Как помогает LangBot |\n|------------------------|----------------------|\n| **Поддержка клиентов** | Разверните ИИ-агентов в Slack/Discord/Telegram, которые отвечают на вопросы, используя вашу базу знаний |\n| **Внутренние инструменты** | Подключите рабочие процессы n8n/Dify к WeCom/DingTalk для автоматизации бизнес-процессов |\n| **Управление сообществом** | Модерируйте группы QQ/Discord с помощью ИИ-фильтрации контента и взаимодействия |\n| **Мультиплатформенное присутствие** | Один бот — все платформы. Управляйте из единой панели |\n\n---\n\n## Демо\n\n**Попробуйте прямо сейчас:** https://demo.langbot.dev/\n- Email: `demo@langbot.app`\n- Пароль: `langbot123456`\n\n*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*\n\n---\n\n## Сообщество\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)\n\n- [Сообщество Discord](https://discord.gg/wdNEHETs87)\n\n---\n\n## История Stars\n\n[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)\n\n---\n\n## Участники\n\nСпасибо всем [участникам](https://github.com/langbot-app/LangBot/graphs/contributors), которые помогли сделать LangBot лучше:\n\n<a href=\"https://github.com/langbot-app/LangBot/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=langbot-app/LangBot\" />\n</a>\n"
  },
  {
    "path": "README_TW.md",
    "content": "<p align=\"center\">\n<a href=\"https://langbot.app\">\n<img width=\"130\" src=\"res/logo-blue.png\" alt=\"LangBot\"/>\n</a>\n\n<div align=\"center\">\n\n<a href=\"https://hellogithub.com/repository/langbot-app/LangBot\" target=\"_blank\"><img src=\"https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R\" alt=\"Featured｜HelloGitHub\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n\n<h3>生產級 AI 即時通訊機器人開發平台。</h3>\n<h4>快速建構、除錯和部署 AI 機器人到微信、QQ、飛書、Slack、Discord、Telegram 等平台。</h4>\n\n[English](README.md) / [简体中文](README_CN.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)\n[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)\n<img src=\"https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg\" alt=\"python\">\n[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)\n[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)\n\n<a href=\"https://langbot.app\">官網</a> ｜\n<a href=\"https://docs.langbot.app/zh/insight/features.html\">特性</a> ｜\n<a href=\"https://docs.langbot.app/zh/insight/guide.html\">文件</a> ｜\n<a href=\"https://docs.langbot.app/zh/tags/readme.html\">API</a> ｜\n<a href=\"https://space.langbot.app\">外掛市場</a> ｜\n<a href=\"https://langbot.featurebase.app/roadmap\">路線圖</a>\n\n</div>\n\n</p>\n\n---\n\n## 什麼是 LangBot？\n\nLangBot 是一個**開源的生產級平台**，用於建構 AI 驅動的即時通訊機器人。它將大語言模型（LLM）連接到各種聊天平台，幫助你創建能夠對話、執行任務、並整合到現有工作流程中的智能 Agent。\n\n### 核心能力\n\n- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG（知識庫），深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。\n- **全平台支援** — 一套程式碼，覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。\n- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理，已被多家企業採用。\n- **外掛生態** — 數百個外掛，事件驅動架構，組件擴展，適配 [MCP 協議](https://modelcontextprotocol.io/)。\n- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人，無需手動編輯設定檔。\n- **多流水線架構** — 不同機器人用於不同場景，具備全面的監控和異常處理能力。\n\n[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)\n\n---\n\n## 快速開始\n\n### ☁️ LangBot Cloud（推薦）\n\n**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署，開箱即用。\n\n### 一鍵啟動\n\n```bash\nuvx langbot\n```\n\n> 需要安裝 [uv](https://docs.astral.sh/uv/getting-started/installation/)。訪問 http://localhost:5300 即可使用。\n\n### Docker Compose\n\n```bash\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\ndocker compose up -d\n```\n\n### 一鍵雲端部署\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)\n[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)\n\n**更多方式：** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手動部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [寶塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)\n\n---\n\n## 支援的平台\n\n| 平台 | 狀態 | 備註 |\n|------|------|------|\n| QQ | ✅ | 個人號、官方機器人（頻道、私聊、群聊） |\n| 微信 | ✅ | 個人微信、微信公眾號 |\n| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |\n| 飛書 | ✅ |  |\n| 釘釘 | ✅ |  |\n| Discord | ✅ |  |\n| Telegram | ✅ |  |\n| Slack | ✅ |  |\n| LINE | ✅ |  |\n| KOOK | ✅ |  |\n| Satori | ✅ |  |\n\n---\n\n## 支援的大模型與整合\n\n| 提供商 | 類型 | 狀態 |\n|--------|------|------|\n| [OpenAI](https://platform.openai.com/) | LLM | ✅ |\n| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |\n| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |\n| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |\n| [xAI](https://x.ai/) | LLM | ✅ |\n| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |\n| [智譜AI](https://open.bigmodel.cn/) | LLM | ✅ |\n| [Ollama](https://ollama.com/) | 本地 LLM | ✅ |\n| [LM Studio](https://lmstudio.ai/) | 本地 LLM | ✅ |\n| [Dify](https://dify.ai) | LLMOps | ✅ |\n| [MCP](https://modelcontextprotocol.io/) | 協議 | ✅ |\n| [SiliconFlow](https://siliconflow.cn/) | 聚合平台 | ✅ |\n| [阿里雲百煉](https://bailian.console.aliyun.com/) | 聚合平台 | ✅ |\n| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 聚合平台 | ✅ |\n| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 聚合平台 | ✅ |\n| [GiteeAI](https://ai.gitee.com/) | 聚合平台 | ✅ |\n| [勝算雲](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 平台 | ✅ |\n| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |\n| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |\n| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |\n| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |\n\n### TTS（語音合成）\n\n| 平台/模型 | 備註 |\n|-----------|------|\n| [FishAudio](https://fish.audio/zh-CN/discovery/) | [外掛](https://github.com/the-lazy-me/NewChatVoice) |\n| [海豚 AI](https://www.ttson.cn/?source=thelazy) | [外掛](https://github.com/the-lazy-me/NewChatVoice) |\n| [AzureTTS](https://portal.azure.com/) | [外掛](https://github.com/Ingnaryk/LangBot_AzureTTS) |\n\n### 文生圖\n\n| 平台/模型 | 備註 |\n|-----------|------|\n| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |\n\n[→ 查看完整整合列表](https://docs.langbot.app/zh/insight/features.html)\n\n---\n\n## 為什麼選擇 LangBot？\n\n| 使用場景 | LangBot 如何幫助 |\n|----------|------------------|\n| **客戶服務** | 將 AI Agent 部署到微信/企微/釘釘/飛書，基於知識庫自動回答使用者問題 |\n| **內部工具** | 將 n8n/Dify 工作流接入企微/釘釘，實現業務流程自動化 |\n| **社群運營** | 在 QQ/Discord 群中使用 AI 驅動的內容審核與智能互動 |\n| **多平台觸達** | 一個機器人，覆蓋所有平台。透過統一面板集中管理 |\n\n---\n\n## 線上演示\n\n**立即體驗：** https://demo.langbot.dev/\n- 信箱：`demo@langbot.app`\n- 密碼：`langbot123456`\n\n*注意：公開演示環境，請不要在其中填入任何敏感資訊。*\n\n---\n\n## 社群\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)\n[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum)\n\n- [Discord 社群](https://discord.gg/wdNEHETs87)\n- [QQ 社群群](https://qm.qq.com/q/JLi38whHum)\n\n---\n\n## Star 趨勢\n\n[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)\n\n---\n\n## 貢獻者\n\n感謝所有[貢獻者](https://github.com/langbot-app/LangBot/graphs/contributors)對 LangBot 的幫助：\n\n<a href=\"https://github.com/langbot-app/LangBot/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=langbot-app/LangBot\" />\n</a>\n"
  },
  {
    "path": "README_VI.md",
    "content": "<p align=\"center\">\n<a href=\"https://langbot.app\">\n<img width=\"130\" src=\"res/logo-blue.png\" alt=\"LangBot\"/>\n</a>\n\n<div align=\"center\">\n\n<a href=\"https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot\" target=\"_blank\"><img src=\"https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light\" alt=\"LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n\n<h3>Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.</h3>\n<h4>Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.</h4>\n\n[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / Tiếng Việt\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)\n<img src=\"https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg\" alt=\"python\">\n[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)\n\n<a href=\"https://langbot.app\">Trang chủ</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/features.html\">Tính năng</a> ｜\n<a href=\"https://docs.langbot.app/en/insight/guide.html\">Tài liệu</a> ｜\n<a href=\"https://docs.langbot.app/en/tags/readme.html\">API</a> ｜\n<a href=\"https://space.langbot.app\">Chợ Plugin</a> ｜\n<a href=\"https://langbot.featurebase.app/roadmap\">Lộ trình</a>\n\n</div>\n\n</p>\n\n---\n\n## LangBot là gì?\n\nLangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để xây dựng bot nhắn tin tức thời được hỗ trợ bởi AI. Nó kết nối các Mô hình Ngôn ngữ Lớn (LLM) với bất kỳ nền tảng chat nào, cho phép bạn tạo các agent thông minh có thể trò chuyện, thực hiện tác vụ và tích hợp với quy trình làm việc hiện có của bạn.\n\n### Khả năng chính\n\n- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).\n- **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.\n- **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng.\n- **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/).\n- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.\n- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.\n\n[→ Tìm hiểu thêm về tất cả tính năng](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## Bắt đầu nhanh\n\n### ☁️ LangBot Cloud (Khuyên dùng)\n\n**[LangBot Cloud](https://space.langbot.app/cloud)** — Không cần triển khai, sẵn sàng sử dụng.\n\n### Khởi chạy một dòng\n\n```bash\nuvx langbot\n```\n\n> Yêu cầu [uv](https://docs.astral.sh/uv/getting-started/installation/). Truy cập http://localhost:5300 — xong.\n\n### Docker Compose\n\n```bash\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\ndocker compose up -d\n```\n\n### Triển khai đám mây một cú nhấp\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)\n[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)\n\n**Thêm tùy chọn:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Thủ công](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)\n\n---\n\n## Nền tảng được hỗ trợ\n\n| Nền tảng | Trạng thái | Ghi chú |\n|----------|--------|-------|\n| Discord | ✅ |  |\n| Telegram | ✅ |  |\n| Slack | ✅ |  |\n| LINE | ✅ |  |\n| QQ | ✅ | Cá nhân & API chính thức |\n| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |\n| WeChat | ✅ | Cá nhân & Tài khoản công khai |\n| Lark | ✅ |  |\n| DingTalk | ✅ |  |\n| KOOK | ✅ |  |\n| Satori | ✅ |  |\n\n---\n\n## LLM và tích hợp được hỗ trợ\n\n| Nhà cung cấp | Loại | Trạng thái |\n|----------|------|--------|\n| [OpenAI](https://platform.openai.com/) | LLM | ✅ |\n| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |\n| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |\n| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |\n| [xAI](https://x.ai/) | LLM | ✅ |\n| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |\n| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |\n| [Ollama](https://ollama.com/) | LLM cục bộ | ✅ |\n| [LM Studio](https://lmstudio.ai/) | LLM cục bộ | ✅ |\n| [Dify](https://dify.ai) | LLMOps | ✅ |\n| [MCP](https://modelcontextprotocol.io/) | Giao thức | ✅ |\n| [SiliconFlow](https://siliconflow.cn/) | Cổng | ✅ |\n| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Cổng | ✅ |\n| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Cổng | ✅ |\n| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Cổng | ✅ |\n| [GiteeAI](https://ai.gitee.com/) | Cổng | ✅ |\n| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Nền tảng GPU | ✅ |\n| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ |\n| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |\n| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |\n| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |\n\n[→ Xem tất cả tích hợp](https://docs.langbot.app/en/insight/features.html)\n\n---\n\n## Tại sao chọn LangBot?\n\n| Trường hợp sử dụng | LangBot giúp như thế nào |\n|----------|-------------------|\n| **Hỗ trợ khách hàng** | Triển khai agent AI trên Slack/Discord/Telegram để trả lời câu hỏi bằng cơ sở kiến thức của bạn |\n| **Công cụ nội bộ** | Kết nối quy trình n8n/Dify với WeCom/DingTalk để tự động hóa quy trình kinh doanh |\n| **Quản lý cộng đồng** | Quản lý nhóm QQ/Discord với tính năng lọc nội dung và tương tác được hỗ trợ bởi AI |\n| **Đa nền tảng** | Một bot, tất cả nền tảng. Quản lý từ một bảng điều khiển duy nhất |\n\n---\n\n## Demo trực tuyến\n\n**Thử ngay:** https://demo.langbot.dev/\n- Email: `demo@langbot.app`\n- Mật khẩu: `langbot123456`\n\n*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*\n\n---\n\n## Cộng đồng\n\n[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)\n\n- [Cộng đồng Discord](https://discord.gg/wdNEHETs87)\n\n---\n\n## Lịch sử Star\n\n[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)\n\n---\n\n## Người đóng góp\n\nCảm ơn tất cả [người đóng góp](https://github.com/langbot-app/LangBot/graphs/contributors) đã giúp LangBot trở nên tốt hơn:\n\n<a href=\"https://github.com/langbot-app/LangBot/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=langbot-app/LangBot\" />\n</a>\n"
  },
  {
    "path": "codecov.yml",
    "content": "coverage:\n  status:\n    project: off\n    patch: off"
  },
  {
    "path": "docker/README_K8S.md",
    "content": "# LangBot Kubernetes 部署指南 / Kubernetes Deployment Guide\n\n[简体中文](#简体中文) | [English](#english)\n\n---\n\n## 简体中文\n\n### 概述\n\n本指南提供了在 Kubernetes 集群中部署 LangBot 的完整步骤。Kubernetes 部署配置基于 `docker-compose.yaml`，适用于生产环境的容器化部署。\n\n### 前置要求\n\n- Kubernetes 集群（版本 1.19+）\n- `kubectl` 命令行工具已配置并可访问集群\n- 集群中有可用的存储类（StorageClass）用于持久化存储（可选但推荐）\n- 至少 2 vCPU 和 4GB RAM 的可用资源\n\n### 架构说明\n\nKubernetes 部署包含以下组件：\n\n1. **langbot**: 主应用服务\n   - 提供 Web UI（端口 5300）\n   - 处理平台 webhook（端口 2280-2290）\n   - 数据持久化卷\n   \n2. **langbot-plugin-runtime**: 插件运行时服务\n   - WebSocket 通信（端口 5400）\n   - 插件数据持久化卷\n\n3. **持久化存储**:\n   - `langbot-data`: LangBot 主数据\n   - `langbot-plugins`: 插件文件\n   - `langbot-plugin-runtime-data`: 插件运行时数据\n\n### 快速开始\n\n#### 1. 下载部署文件\n\n```bash\n# 克隆仓库\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\n\n# 或直接下载 kubernetes.yaml\nwget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml\n```\n\n#### 2. 部署到 Kubernetes\n\n```bash\n# 应用所有配置\nkubectl apply -f kubernetes.yaml\n\n# 检查部署状态\nkubectl get all -n langbot\n\n# 查看 Pod 日志\nkubectl logs -n langbot -l app=langbot -f\n```\n\n#### 3. 访问 LangBot\n\n默认情况下，LangBot 服务使用 ClusterIP 类型，只能在集群内部访问。您可以选择以下方式之一来访问：\n\n**选项 A: 端口转发（推荐用于测试）**\n\n```bash\nkubectl port-forward -n langbot svc/langbot 5300:5300\n```\n\n然后访问 http://localhost:5300\n\n**选项 B: NodePort（适用于开发环境）**\n\n编辑 `kubernetes.yaml`，取消注释 NodePort Service 部分，然后：\n\n```bash\nkubectl apply -f kubernetes.yaml\n# 获取节点 IP\nkubectl get nodes -o wide\n# 访问 http://<NODE_IP>:30300\n```\n\n**选项 C: LoadBalancer（适用于云环境）**\n\n编辑 `kubernetes.yaml`，取消注释 LoadBalancer Service 部分，然后：\n\n```bash\nkubectl apply -f kubernetes.yaml\n# 获取外部 IP\nkubectl get svc -n langbot langbot-loadbalancer\n# 访问 http://<EXTERNAL_IP>\n```\n\n**选项 D: Ingress（推荐用于生产环境）**\n\n确保集群中已安装 Ingress Controller（如 nginx-ingress），然后：\n\n1. 编辑 `kubernetes.yaml` 中的 Ingress 配置\n2. 修改域名为您的实际域名\n3. 应用配置：\n\n```bash\nkubectl apply -f kubernetes.yaml\n# 访问 http://langbot.yourdomain.com\n```\n\n### 配置说明\n\n#### 环境变量\n\n在 `ConfigMap` 中配置环境变量：\n\n```yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: langbot-config\n  namespace: langbot\ndata:\n  TZ: \"Asia/Shanghai\"  # 修改为您的时区\n```\n\n#### 存储配置\n\n默认使用动态存储分配。如果您有特定的 StorageClass，请在 PVC 中指定：\n\n```yaml\nspec:\n  storageClassName: your-storage-class-name\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 10Gi\n```\n\n#### 资源限制\n\n根据您的需求调整资源限制：\n\n```yaml\nresources:\n  requests:\n    memory: \"1Gi\"\n    cpu: \"500m\"\n  limits:\n    memory: \"4Gi\"\n    cpu: \"2000m\"\n```\n\n### 常用操作\n\n#### 查看日志\n\n```bash\n# 查看 LangBot 主服务日志\nkubectl logs -n langbot -l app=langbot -f\n\n# 查看插件运行时日志\nkubectl logs -n langbot -l app=langbot-plugin-runtime -f\n```\n\n#### 重启服务\n\n```bash\n# 重启 LangBot\nkubectl rollout restart deployment/langbot -n langbot\n\n# 重启插件运行时\nkubectl rollout restart deployment/langbot-plugin-runtime -n langbot\n```\n\n#### 更新镜像\n\n```bash\n# 更新到最新版本\nkubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest\nkubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest\n\n# 检查更新状态\nkubectl rollout status deployment/langbot -n langbot\n```\n\n#### 扩容（不推荐）\n\n注意：由于 LangBot 使用 ReadWriteOnce 的持久化存储，不支持多副本扩容。如需高可用，请考虑使用 ReadWriteMany 存储或其他架构方案。\n\n#### 备份数据\n\n```bash\n# 备份 PVC 数据\nkubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data\nkubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz\n```\n\n### 卸载\n\n```bash\n# 删除所有资源（保留 PVC）\nkubectl delete deployment,service,configmap -n langbot --all\n\n# 删除 PVC（会删除数据）\nkubectl delete pvc -n langbot --all\n\n# 删除命名空间\nkubectl delete namespace langbot\n```\n\n### 故障排查\n\n#### Pod 无法启动\n\n```bash\n# 查看 Pod 状态\nkubectl get pods -n langbot\n\n# 查看详细信息\nkubectl describe pod -n langbot <pod-name>\n\n# 查看事件\nkubectl get events -n langbot --sort-by='.lastTimestamp'\n```\n\n#### 存储问题\n\n```bash\n# 检查 PVC 状态\nkubectl get pvc -n langbot\n\n# 检查 PV\nkubectl get pv\n```\n\n#### 网络访问问题\n\n```bash\n# 检查 Service\nkubectl get svc -n langbot\n\n# 检查端口转发\nkubectl port-forward -n langbot svc/langbot 5300:5300\n```\n\n### 生产环境建议\n\n1. **使用特定版本标签**：避免使用 `latest` 标签，使用具体版本号如 `rockchin/langbot:v1.0.0`\n2. **配置资源限制**：根据实际负载调整 CPU 和内存限制\n3. **使用 Ingress + TLS**：配置 HTTPS 访问和证书管理\n4. **配置监控和告警**：集成 Prometheus、Grafana 等监控工具\n5. **定期备份**：配置自动备份策略保护数据\n6. **使用专用 StorageClass**：为生产环境配置高性能存储\n7. **配置亲和性规则**：确保 Pod 调度到合适的节点\n\n### 高级配置\n\n#### 使用 Secrets 管理敏感信息\n\n如果需要配置 API 密钥等敏感信息：\n\n```yaml\napiVersion: v1\nkind: Secret\nmetadata:\n  name: langbot-secrets\n  namespace: langbot\ntype: Opaque\ndata:\n  api_key: <base64-encoded-value>\n```\n\n然后在 Deployment 中引用：\n\n```yaml\nenv:\n- name: API_KEY\n  valueFrom:\n    secretKeyRef:\n      name: langbot-secrets\n      key: api_key\n```\n\n#### 配置水平自动扩缩容（HPA）\n\n注意：需要确保使用 ReadWriteMany 存储类型\n\n```yaml\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nmetadata:\n  name: langbot-hpa\n  namespace: langbot\nspec:\n  scaleTargetRef:\n    apiVersion: apps/v1\n    kind: Deployment\n    name: langbot\n  minReplicas: 1\n  maxReplicas: 3\n  metrics:\n  - type: Resource\n    resource:\n      name: cpu\n      target:\n        type: Utilization\n        averageUtilization: 70\n```\n\n### 参考资源\n\n- [LangBot 官方文档](https://docs.langbot.app)\n- [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html)\n- [Kubernetes 官方文档](https://kubernetes.io/docs/)\n\n---\n\n## English\n\n### Overview\n\nThis guide provides complete steps for deploying LangBot in a Kubernetes cluster. The Kubernetes deployment configuration is based on `docker-compose.yaml` and is suitable for production containerized deployments.\n\n### Prerequisites\n\n- Kubernetes cluster (version 1.19+)\n- `kubectl` command-line tool configured with cluster access\n- Available StorageClass in the cluster for persistent storage (optional but recommended)\n- At least 2 vCPU and 4GB RAM of available resources\n\n### Architecture\n\nThe Kubernetes deployment includes the following components:\n\n1. **langbot**: Main application service\n   - Provides Web UI (port 5300)\n   - Handles platform webhooks (ports 2280-2290)\n   - Data persistence volume\n   \n2. **langbot-plugin-runtime**: Plugin runtime service\n   - WebSocket communication (port 5400)\n   - Plugin data persistence volume\n\n3. **Persistent Storage**:\n   - `langbot-data`: LangBot main data\n   - `langbot-plugins`: Plugin files\n   - `langbot-plugin-runtime-data`: Plugin runtime data\n\n### Quick Start\n\n#### 1. Download Deployment Files\n\n```bash\n# Clone repository\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot/docker\n\n# Or download kubernetes.yaml directly\nwget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml\n```\n\n#### 2. Deploy to Kubernetes\n\n```bash\n# Apply all configurations\nkubectl apply -f kubernetes.yaml\n\n# Check deployment status\nkubectl get all -n langbot\n\n# View Pod logs\nkubectl logs -n langbot -l app=langbot -f\n```\n\n#### 3. Access LangBot\n\nBy default, LangBot service uses ClusterIP type, accessible only within the cluster. Choose one of the following methods to access:\n\n**Option A: Port Forwarding (Recommended for testing)**\n\n```bash\nkubectl port-forward -n langbot svc/langbot 5300:5300\n```\n\nThen visit http://localhost:5300\n\n**Option B: NodePort (Suitable for development)**\n\nEdit `kubernetes.yaml`, uncomment the NodePort Service section, then:\n\n```bash\nkubectl apply -f kubernetes.yaml\n# Get node IP\nkubectl get nodes -o wide\n# Visit http://<NODE_IP>:30300\n```\n\n**Option C: LoadBalancer (Suitable for cloud environments)**\n\nEdit `kubernetes.yaml`, uncomment the LoadBalancer Service section, then:\n\n```bash\nkubectl apply -f kubernetes.yaml\n# Get external IP\nkubectl get svc -n langbot langbot-loadbalancer\n# Visit http://<EXTERNAL_IP>\n```\n\n**Option D: Ingress (Recommended for production)**\n\nEnsure an Ingress Controller (e.g., nginx-ingress) is installed in the cluster, then:\n\n1. Edit the Ingress configuration in `kubernetes.yaml`\n2. Change the domain to your actual domain\n3. Apply configuration:\n\n```bash\nkubectl apply -f kubernetes.yaml\n# Visit http://langbot.yourdomain.com\n```\n\n### Configuration\n\n#### Environment Variables\n\nConfigure environment variables in ConfigMap:\n\n```yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: langbot-config\n  namespace: langbot\ndata:\n  TZ: \"Asia/Shanghai\"  # Change to your timezone\n```\n\n#### Storage Configuration\n\nUses dynamic storage provisioning by default. If you have a specific StorageClass, specify it in PVC:\n\n```yaml\nspec:\n  storageClassName: your-storage-class-name\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 10Gi\n```\n\n#### Resource Limits\n\nAdjust resource limits based on your needs:\n\n```yaml\nresources:\n  requests:\n    memory: \"1Gi\"\n    cpu: \"500m\"\n  limits:\n    memory: \"4Gi\"\n    cpu: \"2000m\"\n```\n\n### Common Operations\n\n#### View Logs\n\n```bash\n# View LangBot main service logs\nkubectl logs -n langbot -l app=langbot -f\n\n# View plugin runtime logs\nkubectl logs -n langbot -l app=langbot-plugin-runtime -f\n```\n\n#### Restart Services\n\n```bash\n# Restart LangBot\nkubectl rollout restart deployment/langbot -n langbot\n\n# Restart plugin runtime\nkubectl rollout restart deployment/langbot-plugin-runtime -n langbot\n```\n\n#### Update Images\n\n```bash\n# Update to latest version\nkubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest\nkubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest\n\n# Check update status\nkubectl rollout status deployment/langbot -n langbot\n```\n\n#### Scaling (Not Recommended)\n\nNote: Due to LangBot using ReadWriteOnce persistent storage, multi-replica scaling is not supported. For high availability, consider using ReadWriteMany storage or alternative architectures.\n\n#### Backup Data\n\n```bash\n# Backup PVC data\nkubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data\nkubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz\n```\n\n### Uninstall\n\n```bash\n# Delete all resources (keep PVCs)\nkubectl delete deployment,service,configmap -n langbot --all\n\n# Delete PVCs (will delete data)\nkubectl delete pvc -n langbot --all\n\n# Delete namespace\nkubectl delete namespace langbot\n```\n\n### Troubleshooting\n\n#### Pods Not Starting\n\n```bash\n# Check Pod status\nkubectl get pods -n langbot\n\n# View detailed information\nkubectl describe pod -n langbot <pod-name>\n\n# View events\nkubectl get events -n langbot --sort-by='.lastTimestamp'\n```\n\n#### Storage Issues\n\n```bash\n# Check PVC status\nkubectl get pvc -n langbot\n\n# Check PV\nkubectl get pv\n```\n\n#### Network Access Issues\n\n```bash\n# Check Service\nkubectl get svc -n langbot\n\n# Test port forwarding\nkubectl port-forward -n langbot svc/langbot 5300:5300\n```\n\n### Production Recommendations\n\n1. **Use specific version tags**: Avoid using `latest` tag, use specific version like `rockchin/langbot:v1.0.0`\n2. **Configure resource limits**: Adjust CPU and memory limits based on actual load\n3. **Use Ingress + TLS**: Configure HTTPS access and certificate management\n4. **Configure monitoring and alerts**: Integrate monitoring tools like Prometheus, Grafana\n5. **Regular backups**: Configure automated backup strategy to protect data\n6. **Use dedicated StorageClass**: Configure high-performance storage for production\n7. **Configure affinity rules**: Ensure Pods are scheduled to appropriate nodes\n\n### Advanced Configuration\n\n#### Using Secrets for Sensitive Information\n\nIf you need to configure sensitive information like API keys:\n\n```yaml\napiVersion: v1\nkind: Secret\nmetadata:\n  name: langbot-secrets\n  namespace: langbot\ntype: Opaque\ndata:\n  api_key: <base64-encoded-value>\n```\n\nThen reference in Deployment:\n\n```yaml\nenv:\n- name: API_KEY\n  valueFrom:\n    secretKeyRef:\n      name: langbot-secrets\n      key: api_key\n```\n\n#### Configure Horizontal Pod Autoscaling (HPA)\n\nNote: Requires ReadWriteMany storage type\n\n```yaml\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nmetadata:\n  name: langbot-hpa\n  namespace: langbot\nspec:\n  scaleTargetRef:\n    apiVersion: apps/v1\n    kind: Deployment\n    name: langbot\n  minReplicas: 1\n  maxReplicas: 3\n  metrics:\n  - type: Resource\n    resource:\n      name: cpu\n      target:\n        type: Utilization\n        averageUtilization: 70\n```\n\n### References\n\n- [LangBot Official Documentation](https://docs.langbot.app)\n- [Docker Deployment Guide](https://docs.langbot.app/zh/deploy/langbot/docker.html)\n- [Kubernetes Official Documentation](https://kubernetes.io/docs/)\n"
  },
  {
    "path": "docker/deploy-k8s-test.sh",
    "content": "#!/bin/bash\n# Quick test script for LangBot Kubernetes deployment\n# This script helps you test the Kubernetes deployment locally\n\nset -e\n\necho \"🚀 LangBot Kubernetes Deployment Test Script\"\necho \"==============================================\"\necho \"\"\n\n# Check for kubectl\nif ! command -v kubectl &> /dev/null; then\n    echo \"❌ kubectl is not installed. Please install kubectl first.\"\n    echo \"Visit: https://kubernetes.io/docs/tasks/tools/\"\n    exit 1\nfi\n\necho \"✓ kubectl is installed\"\n\n# Check if kubectl can connect to a cluster\nif ! kubectl cluster-info &> /dev/null; then\n    echo \"\"\n    echo \"⚠️  No Kubernetes cluster found.\"\n    echo \"\"\n    echo \"To test locally, you can use:\"\n    echo \"  - kind: https://kind.sigs.k8s.io/\"\n    echo \"  - minikube: https://minikube.sigs.k8s.io/\"\n    echo \"  - k3s: https://k3s.io/\"\n    echo \"\"\n    echo \"Example with kind:\"\n    echo \"  kind create cluster --name langbot-test\"\n    echo \"\"\n    exit 1\nfi\n\necho \"✓ Connected to Kubernetes cluster\"\nkubectl cluster-info\necho \"\"\n\n# Ask user to confirm\nread -p \"Do you want to deploy LangBot to this cluster? (y/N) \" -n 1 -r\necho\nif [[ ! $REPLY =~ ^[Yy]$ ]]; then\n    echo \"Deployment cancelled.\"\n    exit 0\nfi\n\necho \"\"\necho \"📦 Deploying LangBot...\"\nkubectl apply -f kubernetes.yaml\n\necho \"\"\necho \"⏳ Waiting for pods to be ready...\"\nkubectl wait --for=condition=ready pod -l app=langbot -n langbot --timeout=300s\nkubectl wait --for=condition=ready pod -l app=langbot-plugin-runtime -n langbot --timeout=300s\n\necho \"\"\necho \"✅ Deployment complete!\"\necho \"\"\necho \"📊 Deployment status:\"\nkubectl get all -n langbot\n\necho \"\"\necho \"🌐 To access LangBot Web UI, run:\"\necho \"   kubectl port-forward -n langbot svc/langbot 5300:5300\"\necho \"\"\necho \"Then visit: http://localhost:5300\"\necho \"\"\necho \"📝 To view logs:\"\necho \"   kubectl logs -n langbot -l app=langbot -f\"\necho \"\"\necho \"🗑️  To uninstall:\"\necho \"   kubectl delete namespace langbot\"\necho \"\"\n"
  },
  {
    "path": "docker/docker-compose.yaml",
    "content": "# Docker Compose configuration for LangBot\n# For Kubernetes deployment, see kubernetes.yaml and README_K8S.md\nversion: \"3\"\n\nservices:\n\n  langbot_plugin_runtime:\n    image: rockchin/langbot:latest\n    container_name: langbot_plugin_runtime\n    volumes:\n      - ./data/plugins:/app/data/plugins\n    ports:\n      - 5401:5401\n    restart: on-failure\n    environment:\n      - TZ=Asia/Shanghai\n    command: [\"uv\", \"run\", \"--no-sync\", \"-m\", \"langbot_plugin.cli.__init__\", \"rt\"]\n    networks:\n      - langbot_network\n\n  langbot:\n    image: rockchin/langbot:latest\n    container_name: langbot\n    volumes:\n      - ./data:/app/data\n    restart: on-failure\n    environment:\n      - TZ=Asia/Shanghai\n    ports:\n      - 5300:5300  # For web ui and webhook callback\n      - 2280-2285:2280-2285  # For platform reverse connection\n    networks:\n      - langbot_network\n\nnetworks:\n  langbot_network:\n    driver: bridge\n"
  },
  {
    "path": "docker/kubernetes.yaml",
    "content": "# Kubernetes Deployment for LangBot\n# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml\n# \n# Usage:\n#   kubectl apply -f kubernetes.yaml\n#\n# Prerequisites:\n#   - A Kubernetes cluster (1.19+)\n#   - kubectl configured to communicate with your cluster\n#   - (Optional) A StorageClass for dynamic volume provisioning\n#\n# Components:\n#   - Namespace: langbot\n#   - PersistentVolumeClaims for data persistence\n#   - Deployments for langbot and langbot_plugin_runtime\n#   - Services for network access\n#   - ConfigMap for timezone configuration\n\n---\n# Namespace\napiVersion: v1\nkind: Namespace\nmetadata:\n  name: langbot\n  labels:\n    app: langbot\n\n---\n# PersistentVolumeClaim for LangBot data\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  name: langbot-data\n  namespace: langbot\nspec:\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 10Gi\n  # Uncomment and modify if you have a specific StorageClass\n  # storageClassName: your-storage-class\n\n---\n# PersistentVolumeClaim for LangBot plugins\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  name: langbot-plugins\n  namespace: langbot\nspec:\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 5Gi\n  # Uncomment and modify if you have a specific StorageClass\n  # storageClassName: your-storage-class\n\n---\n# PersistentVolumeClaim for Plugin Runtime data\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  name: langbot-plugin-runtime-data\n  namespace: langbot\nspec:\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 5Gi\n  # Uncomment and modify if you have a specific StorageClass\n  # storageClassName: your-storage-class\n\n---\n# ConfigMap for environment configuration\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: langbot-config\n  namespace: langbot\ndata:\n  TZ: \"Asia/Shanghai\"\n  PLUGIN__RUNTIME_WS_URL: \"ws://langbot-plugin-runtime:5400/control/ws\"\n\n---\n# Deployment for LangBot Plugin Runtime\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: langbot-plugin-runtime\n  namespace: langbot\n  labels:\n    app: langbot-plugin-runtime\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: langbot-plugin-runtime\n  template:\n    metadata:\n      labels:\n        app: langbot-plugin-runtime\n    spec:\n      containers:\n      - name: langbot-plugin-runtime\n        image: rockchin/langbot:latest\n        imagePullPolicy: Always\n        command: [\"uv\", \"run\", \"-m\", \"langbot_plugin.cli.__init__\", \"rt\"]\n        ports:\n        - containerPort: 5400\n          name: runtime\n          protocol: TCP\n        env:\n        - name: TZ\n          valueFrom:\n            configMapKeyRef:\n              name: langbot-config\n              key: TZ\n        volumeMounts:\n        - name: plugin-data\n          mountPath: /app/data/plugins\n        resources:\n          requests:\n            memory: \"512Mi\"\n            cpu: \"250m\"\n          limits:\n            memory: \"2Gi\"\n            cpu: \"1000m\"\n        # Liveness probe to restart container if it becomes unresponsive\n        livenessProbe:\n          tcpSocket:\n            port: 5400\n          initialDelaySeconds: 30\n          periodSeconds: 10\n          timeoutSeconds: 5\n          failureThreshold: 3\n        # Readiness probe to know when container is ready to accept traffic\n        readinessProbe:\n          tcpSocket:\n            port: 5400\n          initialDelaySeconds: 10\n          periodSeconds: 5\n          timeoutSeconds: 3\n          failureThreshold: 3\n      volumes:\n      - name: plugin-data\n        persistentVolumeClaim:\n          claimName: langbot-plugin-runtime-data\n      restartPolicy: Always\n\n---\n# Service for LangBot Plugin Runtime\napiVersion: v1\nkind: Service\nmetadata:\n  name: langbot-plugin-runtime\n  namespace: langbot\n  labels:\n    app: langbot-plugin-runtime\nspec:\n  type: ClusterIP\n  selector:\n    app: langbot-plugin-runtime\n  ports:\n  - port: 5400\n    targetPort: 5400\n    protocol: TCP\n    name: runtime\n\n---\n# Deployment for LangBot\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: langbot\n  namespace: langbot\n  labels:\n    app: langbot\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: langbot\n  template:\n    metadata:\n      labels:\n        app: langbot\n    spec:\n      containers:\n      - name: langbot\n        image: rockchin/langbot:latest\n        imagePullPolicy: Always\n        ports:\n        - containerPort: 5300\n          name: web\n          protocol: TCP\n        - containerPort: 2280\n          name: webhook-start\n          protocol: TCP\n        # Note: Kubernetes doesn't support port ranges directly in container ports\n        # The webhook ports 2280-2290 are available, but we only expose the start of the range\n        # If you need all ports exposed, consider using a Service with multiple port definitions\n        env:\n        - name: TZ\n          valueFrom:\n            configMapKeyRef:\n              name: langbot-config\n              key: TZ\n        - name: PLUGIN__RUNTIME_WS_URL\n          valueFrom:\n            configMapKeyRef:\n              name: langbot-config\n              key: PLUGIN__RUNTIME_WS_URL\n        volumeMounts:\n        - name: data\n          mountPath: /app/data\n        - name: plugins\n          mountPath: /app/plugins\n        resources:\n          requests:\n            memory: \"1Gi\"\n            cpu: \"500m\"\n          limits:\n            memory: \"4Gi\"\n            cpu: \"2000m\"\n        # Liveness probe to restart container if it becomes unresponsive\n        livenessProbe:\n          httpGet:\n            path: /\n            port: 5300\n          initialDelaySeconds: 60\n          periodSeconds: 10\n          timeoutSeconds: 5\n          failureThreshold: 3\n        # Readiness probe to know when container is ready to accept traffic\n        readinessProbe:\n          httpGet:\n            path: /\n            port: 5300\n          initialDelaySeconds: 30\n          periodSeconds: 5\n          timeoutSeconds: 3\n          failureThreshold: 3\n      volumes:\n      - name: data\n        persistentVolumeClaim:\n          claimName: langbot-data\n      - name: plugins\n        persistentVolumeClaim:\n          claimName: langbot-plugins\n      restartPolicy: Always\n\n---\n# Service for LangBot (ClusterIP for internal access)\napiVersion: v1\nkind: Service\nmetadata:\n  name: langbot\n  namespace: langbot\n  labels:\n    app: langbot\nspec:\n  type: ClusterIP\n  selector:\n    app: langbot\n  ports:\n  - port: 5300\n    targetPort: 5300\n    protocol: TCP\n    name: web\n  - port: 2280\n    targetPort: 2280\n    protocol: TCP\n    name: webhook-2280\n  - port: 2281\n    targetPort: 2281\n    protocol: TCP\n    name: webhook-2281\n  - port: 2282\n    targetPort: 2282\n    protocol: TCP\n    name: webhook-2282\n  - port: 2283\n    targetPort: 2283\n    protocol: TCP\n    name: webhook-2283\n  - port: 2284\n    targetPort: 2284\n    protocol: TCP\n    name: webhook-2284\n  - port: 2285\n    targetPort: 2285\n    protocol: TCP\n    name: webhook-2285\n  - port: 2286\n    targetPort: 2286\n    protocol: TCP\n    name: webhook-2286\n  - port: 2287\n    targetPort: 2287\n    protocol: TCP\n    name: webhook-2287\n  - port: 2288\n    targetPort: 2288\n    protocol: TCP\n    name: webhook-2288\n  - port: 2289\n    targetPort: 2289\n    protocol: TCP\n    name: webhook-2289\n  - port: 2290\n    targetPort: 2290\n    protocol: TCP\n    name: webhook-2290\n\n---\n# Ingress for external access (Optional - requires Ingress Controller)\n# Uncomment and modify the following section if you want to expose LangBot via Ingress\n# apiVersion: networking.k8s.io/v1\n# kind: Ingress\n# metadata:\n#   name: langbot-ingress\n#   namespace: langbot\n#   annotations:\n#     # Uncomment and modify based on your ingress controller\n#     # nginx.ingress.kubernetes.io/rewrite-target: /\n#     # cert-manager.io/cluster-issuer: letsencrypt-prod\n# spec:\n#   ingressClassName: nginx  # Change based on your ingress controller\n#   rules:\n#   - host: langbot.yourdomain.com  # Change to your domain\n#     http:\n#       paths:\n#       - path: /\n#         pathType: Prefix\n#         backend:\n#           service:\n#             name: langbot\n#             port:\n#               number: 5300\n#   # Uncomment for TLS/HTTPS\n#   # tls:\n#   # - hosts:\n#   #   - langbot.yourdomain.com\n#   #   secretName: langbot-tls\n\n---\n# Service for LangBot with LoadBalancer (Alternative to Ingress)\n# Uncomment the following if you want to expose LangBot directly via LoadBalancer\n# This is useful in cloud environments (AWS, GCP, Azure, etc.)\n# apiVersion: v1\n# kind: Service\n# metadata:\n#   name: langbot-loadbalancer\n#   namespace: langbot\n#   labels:\n#     app: langbot\n# spec:\n#   type: LoadBalancer\n#   selector:\n#     app: langbot\n#   ports:\n#   - port: 80\n#     targetPort: 5300\n#     protocol: TCP\n#     name: web\n#   - port: 2280\n#     targetPort: 2280\n#     protocol: TCP\n#     name: webhook-start\n#   # Add more webhook ports as needed\n\n---\n# Service for LangBot with NodePort (Alternative for exposing service)\n# Uncomment if you want to expose LangBot via NodePort\n# This is useful for testing or when LoadBalancer is not available\n# apiVersion: v1\n# kind: Service\n# metadata:\n#   name: langbot-nodeport\n#   namespace: langbot\n#   labels:\n#     app: langbot\n# spec:\n#   type: NodePort\n#   selector:\n#     app: langbot\n#   ports:\n#   - port: 5300\n#     targetPort: 5300\n#     nodePort: 30300  # Must be in range 30000-32767\n#     protocol: TCP\n#     name: web\n#   - port: 2280\n#     targetPort: 2280\n#     nodePort: 30280  # Must be in range 30000-32767\n#     protocol: TCP\n#     name: webhook\n"
  },
  {
    "path": "docs/API_KEY_AUTH.md",
    "content": "# API Key Authentication\n\nLangBot now supports API key authentication for external systems to access its HTTP service API.\n\n## Managing API Keys\n\nAPI keys can be managed through the web interface:\n\n1. Log in to the LangBot web interface\n2. Click the \"API Keys\" button at the bottom of the sidebar\n3. Create, view, copy, or delete API keys as needed\n\n## Using API Keys\n\n### Authentication Headers\n\nInclude your API key in the request header using one of these methods:\n\n**Method 1: X-API-Key header (Recommended)**\n```\nX-API-Key: lbk_your_api_key_here\n```\n\n**Method 2: Authorization Bearer token**\n```\nAuthorization: Bearer lbk_your_api_key_here\n```\n\n## Available APIs\n\nAll existing LangBot APIs now support **both user token and API key authentication**. This means you can use API keys to access:\n\n- **Model Management** - `/api/v1/provider/models/llm` and `/api/v1/provider/models/embedding`\n- **Bot Management** - `/api/v1/platform/bots`\n- **Pipeline Management** - `/api/v1/pipelines`\n- **Knowledge Base** - `/api/v1/knowledge/*`\n- **MCP Servers** - `/api/v1/mcp/servers`\n- And more...\n\n### Authentication Methods\n\nEach endpoint accepts **either**:\n1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users\n2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - for external services\n\n## Example: Model Management\n\n### List All LLM Models\n\n```http\nGET /api/v1/provider/models/llm\nX-API-Key: lbk_your_api_key_here\n```\n\nResponse:\n```json\n{\n  \"code\": 0,\n  \"msg\": \"ok\",\n  \"data\": {\n    \"models\": [\n      {\n        \"uuid\": \"model-uuid\",\n        \"name\": \"GPT-4\",\n        \"description\": \"OpenAI GPT-4 model\",\n        \"requester\": \"openai-chat-completions\",\n        \"requester_config\": {...},\n        \"abilities\": [\"chat\", \"vision\"],\n        \"created_at\": \"2024-01-01T00:00:00\",\n        \"updated_at\": \"2024-01-01T00:00:00\"\n      }\n    ]\n  }\n}\n```\n\n### Create a New LLM Model\n\n```http\nPOST /api/v1/provider/models/llm\nX-API-Key: lbk_your_api_key_here\nContent-Type: application/json\n\n{\n  \"name\": \"My Custom Model\",\n  \"description\": \"Description of the model\",\n  \"requester\": \"openai-chat-completions\",\n  \"requester_config\": {\n    \"model\": \"gpt-4\",\n    \"args\": {}\n  },\n  \"api_keys\": [\n    {\n      \"name\": \"default\",\n      \"keys\": [\"sk-...\"]\n    }\n  ],\n  \"abilities\": [\"chat\"],\n  \"extra_args\": {}\n}\n```\n\n### Update an LLM Model\n\n```http\nPUT /api/v1/provider/models/llm/{model_uuid}\nX-API-Key: lbk_your_api_key_here\nContent-Type: application/json\n\n{\n  \"name\": \"Updated Model Name\",\n  \"description\": \"Updated description\",\n  ...\n}\n```\n\n### Delete an LLM Model\n\n```http\nDELETE /api/v1/provider/models/llm/{model_uuid}\nX-API-Key: lbk_your_api_key_here\n```\n\n## Example: Bot Management\n\n### List All Bots\n\n```http\nGET /api/v1/platform/bots\nX-API-Key: lbk_your_api_key_here\n```\n\n### Create a New Bot\n\n```http\nPOST /api/v1/platform/bots\nX-API-Key: lbk_your_api_key_here\nContent-Type: application/json\n\n{\n  \"name\": \"My Bot\",\n  \"adapter\": \"telegram\",\n  \"config\": {...}\n}\n```\n\n## Example: Pipeline Management\n\n### List All Pipelines\n\n```http\nGET /api/v1/pipelines\nX-API-Key: lbk_your_api_key_here\n```\n\n### Create a New Pipeline\n\n```http\nPOST /api/v1/pipelines\nX-API-Key: lbk_your_api_key_here\nContent-Type: application/json\n\n{\n  \"name\": \"My Pipeline\",\n  \"config\": {...}\n}\n```\n\n## Error Responses\n\n### 401 Unauthorized\n\n```json\n{\n  \"code\": -1,\n  \"msg\": \"No valid authentication provided (user token or API key required)\"\n}\n```\n\nor\n\n```json\n{\n  \"code\": -1,\n  \"msg\": \"Invalid API key\"\n}\n```\n\n### 404 Not Found\n\n```json\n{\n  \"code\": -1,\n  \"msg\": \"Resource not found\"\n}\n```\n\n### 500 Internal Server Error\n\n```json\n{\n  \"code\": -2,\n  \"msg\": \"Error message details\"\n}\n```\n\n## Security Best Practices\n\n1. **Keep API keys secure**: Store them securely and never commit them to version control\n2. **Use HTTPS**: Always use HTTPS in production to encrypt API key transmission\n3. **Rotate keys regularly**: Create new API keys periodically and delete old ones\n4. **Use descriptive names**: Give your API keys meaningful names to track their usage\n5. **Delete unused keys**: Remove API keys that are no longer needed\n6. **Use X-API-Key header**: Prefer using the `X-API-Key` header for clarity\n\n## Example: Python Client\n\n```python\nimport requests\n\nAPI_KEY = \"lbk_your_api_key_here\"\nBASE_URL = \"http://your-langbot-server:5300\"\n\nheaders = {\n    \"X-API-Key\": API_KEY,\n    \"Content-Type\": \"application/json\"\n}\n\n# List all models\nresponse = requests.get(f\"{BASE_URL}/api/v1/provider/models/llm\", headers=headers)\nmodels = response.json()[\"data\"][\"models\"]\n\nprint(f\"Found {len(models)} models\")\nfor model in models:\n    print(f\"- {model['name']}: {model['description']}\")\n\n# Create a new bot\nbot_data = {\n    \"name\": \"My Telegram Bot\",\n    \"adapter\": \"telegram\",\n    \"config\": {\n        \"token\": \"your-telegram-token\"\n    }\n}\n\nresponse = requests.post(\n    f\"{BASE_URL}/api/v1/platform/bots\",\n    headers=headers,\n    json=bot_data\n)\n\nif response.status_code == 200:\n    bot_uuid = response.json()[\"data\"][\"uuid\"]\n    print(f\"Bot created with UUID: {bot_uuid}\")\n```\n\n## Example: cURL\n\n```bash\n# List all models\ncurl -X GET \\\n  -H \"X-API-Key: lbk_your_api_key_here\" \\\n  http://your-langbot-server:5300/api/v1/provider/models/llm\n\n# Create a new pipeline\ncurl -X POST \\\n  -H \"X-API-Key: lbk_your_api_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"name\": \"My Pipeline\",\n    \"config\": {...}\n  }' \\\n  http://your-langbot-server:5300/api/v1/pipelines\n\n# Get bot logs\ncurl -X POST \\\n  -H \"X-API-Key: lbk_your_api_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"from_index\": -1,\n    \"max_count\": 10\n  }' \\\n  http://your-langbot-server:5300/api/v1/platform/bots/{bot_uuid}/logs\n```\n\n## Notes\n\n- The same endpoints work for both the web UI (with user tokens) and external services (with API keys)\n- No need to learn different API paths - use the existing API documentation with API key authentication\n- All endpoints that previously required user authentication now also accept API keys\n\n"
  },
  {
    "path": "docs/MIGRATION_SUMMARY.md",
    "content": "# WebChat 到 WebSocket 迁移总结\n\n## 概述\n\n已完全移除旧的基于SSE的WebChat系统，并替换为基于WebSocket的双向实时通信系统。这是一个内置在LangBot中的完整IM系统，支持流式输出。\n\n## 已删除的文件\n\n### 后端\n- ❌ `src/langbot/pkg/api/http/controller/groups/pipelines/webchat.py` - 旧的SSE路由\n- ❌ `src/langbot/pkg/platform/sources/webchat.py` - 旧的WebChat适配器\n- ❌ `src/langbot/pkg/platform/sources/webchat.yaml` - 旧的配置文件\n\n### 前端\n- ❌ BackendClient中所有SSE相关代码已完全移除\n- ❌ DebugDialog中所有SSE相关逻辑已完全替换\n\n## 新增的文件\n\n### 后端核心文件\n\n**1. WebSocket连接管理器**\n```\nsrc/langbot/pkg/platform/sources/websocket_manager.py\n```\n- 管理所有并发WebSocket连接\n- 线程安全的连接池\n- 按流水线、会话类型分组\n- 广播和单播消息功能\n- 连接统计和监控\n\n**2. WebSocket适配器**\n```\nsrc/langbot/pkg/platform/sources/websocket_adapter.py\n```\n- 实现平台适配器接口\n- **完整流式支持** (`reply_message_chunk` 方法)\n- 双向消息流处理\n- 消息历史管理\n- 会话管理\n\n**3. WebSocket路由控制器**\n```\nsrc/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py\n```\n- WebSocket端点处理\n- REST API接口\n- 心跳机制\n- 连接生命周期管理\n\n**4. 配置文件**\n```\nsrc/langbot/pkg/platform/sources/websocket.yaml\n```\n- WebSocket适配器元数据\n\n### 前端核心文件\n\n**1. WebSocket客户端**\n```\nweb/src/app/infra/websocket/WebSocketClient.ts\n```\n- WebSocket连接管理\n- 自动重连（最多5次）\n- 心跳机制（30秒）\n- 事件回调系统\n\n**2. 更新的组件**\n```\nweb/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx\n```\n- 完全重写，使用WebSocket\n- 实时连接状态显示\n- 流式消息支持\n- 自动重连\n\n**3. HTTP客户端更新**\n```\nweb/src/app/infra/http/BackendClient.ts\n```\n- 移除所有旧的WebChat API\n- 仅保留WebSocket API\n\n### 测试工具\n\n**Python测试客户端**\n```\ntest_websocket_client.py\n```\n- 单连接交互测试\n- 多连接并发测试\n- 命令行工具\n\n### 文档\n\n**使用文档**\n```\nWEBSOCKET_README.md\n```\n- 完整的API文档\n- 架构说明\n- 使用示例\n- 故障排查\n\n## 核心变更\n\n### 后端变更\n\n**1. botmgr.py**\n- ❌ 移除 `webchat_proxy_bot`\n- ✅ 仅保留 `websocket_proxy_bot`\n- ✅ 更新适配器过滤逻辑（排除`websocket`而非`webchat`）\n\n**2. 适配器注册**\n```python\n# 旧代码（已删除）\nwebchat_adapter_class = self.adapter_dict['webchat']\nself.webchat_proxy_bot = RuntimeBot(...)\n\n# 新代码\nwebsocket_adapter_class = self.adapter_dict['websocket']\nself.websocket_proxy_bot = RuntimeBot(\n    uuid='websocket-proxy-bot',\n    name='WebSocket',\n    adapter='websocket',\n    ...\n)\n```\n\n### 前端变更\n\n**1. API调用完全更换**\n\n旧代码（已删除）:\n```typescript\n// SSE流式请求\nawait fetch(url, {\n  method: 'POST',\n  body: JSON.stringify({ is_stream: true })\n})\n// 手动解析 text/event-stream\n```\n\n新代码:\n```typescript\n// WebSocket实时通信\nconst wsClient = new WebSocketClient(pipelineId, sessionType);\nawait wsClient.connect();\n\nwsClient.onMessage((message) => {\n  // 流式消息自动处理\n  setMessages(prev => [...prev, message]);\n});\n\nwsClient.sendMessage(messageChain);\n```\n\n**2. 连接状态管理**\n\n新增功能:\n- ✅ 实时连接状态指示器（绿色/红色圆点）\n- ✅ 连接/断开toast提示\n- ✅ 自动重连逻辑\n- ✅ 心跳保活\n\n**3. 流式支持**\n\n完整的流式消息处理:\n```typescript\nwsClient.onMessage((message) => {\n  if (message.is_final) {\n    // 最终消息\n    finalizeBotMessage(message);\n  } else {\n    // 中间消息块，实时更新UI\n    updateBotMessage(message);\n  }\n});\n```\n\n## API对比\n\n### WebSocket端点\n\n**连接**\n```\nws://localhost:8000/api/v1/pipelines/<pipeline_uuid>/ws/connect?session_type=<person|group>\n```\n\n**消息格式**\n\n客户端发送:\n```json\n{\n  \"type\": \"message\",\n  \"message\": [\n    {\"type\": \"Plain\", \"text\": \"你好\"}\n  ]\n}\n```\n\n服务器响应（流式）:\n```json\n{\n  \"type\": \"response\",\n  \"data\": {\n    \"id\": 1,\n    \"role\": \"assistant\",\n    \"content\": \"你好，我是...\",\n    \"is_final\": false,\n    \"timestamp\": \"2025-01-28T...\"\n  }\n}\n```\n\n### REST API\n\n| 端点 | 方法 | 说明 |\n|------|------|------|\n| `/api/v1/pipelines/<uuid>/ws/messages/<type>` | GET | 获取消息历史 |\n| `/api/v1/pipelines/<uuid>/ws/reset/<type>` | POST | 重置会话 |\n| `/api/v1/pipelines/<uuid>/ws/connections` | GET | 获取连接统计 |\n| `/api/v1/pipelines/<uuid>/ws/broadcast` | POST | 广播消息 |\n\n## 流式支持详解\n\n### 后端流式实现\n\n**WebSocket Adapter**\n```python\nasync def reply_message_chunk(\n    self,\n    message_source: platform_events.MessageEvent,\n    bot_message,\n    message: platform_message.MessageChain,\n    quote_origin: bool = False,\n    is_final: bool = False,\n) -> dict:\n    \"\"\"回复消息块 - 流式\"\"\"\n    message_data = WebSocketMessage(\n        id=-1,\n        role='assistant',\n        content=str(message),\n        message_chain=[component.__dict__ for component in message],\n        timestamp=datetime.now().isoformat(),\n        is_final=is_final and bot_message.tool_calls is None,\n    )\n\n    # 发送到队列，由WebSocket连接处理发送\n    await session.resp_queues[message_id].put(message_data)\n    return message_data.model_dump()\n\nasync def is_stream_output_supported(self) -> bool:\n    \"\"\"WebSocket始终支持流式输出\"\"\"\n    return True\n```\n\n### 前端流式处理\n\n**DebugDialog组件**\n```typescript\nwsClient.onMessage((message) => {\n  setMessages((prevMessages) => {\n    const existingIndex = prevMessages.findIndex(\n      (msg) => msg.role === 'assistant' && msg.content === 'Generating...'\n    );\n\n    if (existingIndex !== -1) {\n      // 更新正在生成的消息\n      const updatedMessages = [...prevMessages];\n      updatedMessages[existingIndex] = message;\n      return updatedMessages;\n    } else {\n      // 添加新消息\n      return [...prevMessages, message];\n    }\n  });\n});\n```\n\n## 兼容性说明\n\n### ⚠️ 不兼容旧版本\n\n此次迁移**完全不兼容**旧的WebChat系统：\n\n1. **API端点变更**\n   - 旧: `/api/v1/pipelines/<uuid>/chat/send`\n   - 新: `ws://.../<uuid>/ws/connect`\n\n2. **通信协议变更**\n   - 旧: HTTP + SSE (Server-Sent Events)\n   - 新: WebSocket (双向)\n\n3. **流式实现变更**\n   - 旧: `text/event-stream` 格式\n   - 新: WebSocket JSON消息\n\n### 迁移要求\n\n使用新系统需要:\n1. ✅ 前端必须支持WebSocket\n2. ✅ 后端必须运行新的WebSocket适配器\n3. ✅ 清除旧的WebChat相关配置\n\n## 优势对比\n\n| 特性 | 旧WebChat (SSE) | 新WebSocket |\n|------|----------------|-------------|\n| 双向通信 | ❌ 单向（服务器→客户端） | ✅ 双向 |\n| 主动推送 | ❌ 不支持 | ✅ 支持 |\n| 连接管理 | ❌ 无状态 | ✅ 有状态，完整生命周期 |\n| 流式输出 | ✅ 支持 | ✅ 支持（更优） |\n| 心跳机制 | ❌ 无 | ✅ 30秒心跳 |\n| 自动重连 | ❌ 无 | ✅ 最多5次 |\n| 多连接 | ⚠️ 难以管理 | ✅ 完整支持 |\n| 连接状态 | ❌ 不可见 | ✅ 实时显示 |\n| 广播功能 | ❌ 不支持 | ✅ 支持 |\n\n## 测试方式\n\n### 1. Python测试客户端\n\n```bash\n# 单连接测试\npython test_websocket_client.py <pipeline_uuid>\n\n# 指定会话类型\npython test_websocket_client.py <pipeline_uuid> --session-type group\n\n# 多连接并发测试（5个连接）\npython test_websocket_client.py <pipeline_uuid> --multi 5\n```\n\n### 2. 前端测试\n\n1. 启动LangBot服务器\n2. 访问前端界面\n3. 打开流水线调试对话框\n4. 观察连接状态指示器（左下角圆点）\n5. 发送消息测试流式响应\n\n### 3. 浏览器控制台测试\n\n```javascript\nconst ws = new WebSocket('ws://localhost:8000/api/v1/pipelines/<uuid>/ws/connect?session_type=person');\n\nws.onopen = () => {\n  console.log('已连接');\n  ws.send(JSON.stringify({\n    type: 'message',\n    message: [{type: 'Plain', text: '你好'}]\n  }));\n};\n\nws.onmessage = (event) => {\n  console.log('收到:', JSON.parse(event.data));\n};\n```\n\n## 常见问题\n\n### Q: 为什么完全删除旧代码而不保留兼容性？\nA: 根据需求，不需要考虑任何对老版本的兼容性，彻底迁移可以避免代码冗余和维护负担。\n\n### Q: 流式输出如何工作？\nA:\n1. 后端通过`reply_message_chunk`发送消息块\n2. 消息块放入队列\n3. WebSocket连接从队列取出并发送\n4. 前端实时更新UI\n5. `is_final=true`表示最后一块\n\n### Q: 如何确保连接不断开？\nA:\n1. 客户端每30秒发送心跳（ping）\n2. 服务器响应pong\n3. 连接断开时自动重连（最多5次）\n\n### Q: 如何实现后端主动推送？\nA:\n1. 调用 `/api/v1/pipelines/<uuid>/ws/broadcast` API\n2. 消息会被推送到该流水线的所有连接\n3. 前端通过`onBroadcast`回调接收\n\n## 总结\n\n✅ **完成的工作**\n- 完全移除旧的WebChat/SSE系统\n- 实现完整的WebSocket双向通信系统\n- 支持流式输出\n- 支持多连接并发\n- 实现自动重连和心跳机制\n- 提供完整的测试工具和文档\n\n✅ **核心特性**\n- 双向实时通信\n- 流式消息支持\n- 多连接管理\n- 自动重连\n- 心跳保活\n- 连接状态可视化\n- 广播消息\n\n✅ **技术亮点**\n- 异步架构（asyncio）\n- 线程安全的连接管理\n- 独立的消息队列\n- 完整的错误处理\n- 模块化设计\n\n🎉 系统已完全迁移到WebSocket，无任何旧代码遗留！\n"
  },
  {
    "path": "docs/PYPI_INSTALLATION.md",
    "content": "# LangBot PyPI Package Installation\n\n## Quick Start with uvx\n\nThe easiest way to run LangBot is using `uvx` (recommended for quick testing):\n\n```bash\nuvx langbot\n```\n\nThis will automatically download and run the latest version of LangBot.\n\n## Install with pip/uv\n\nYou can also install LangBot as a regular Python package:\n\n```bash\n# Using pip\npip install langbot\n\n# Using uv\nuv pip install langbot\n```\n\nThen run it:\n\n```bash\nlangbot\n```\n\nOr using Python module syntax:\n\n```bash\npython -m langbot\n```\n\n## Installation with Frontend\n\nWhen published to PyPI, the LangBot package includes the pre-built frontend files. You don't need to build the frontend separately.\n\n## Data Directory\n\nWhen running LangBot as a package, it will create a `data/` directory in your current working directory to store configuration, logs, and other runtime data. You can run LangBot from any directory, and it will set up its data directory there.\n\n## Command Line Options\n\nLangBot supports the following command line options:\n\n- `--standalone-runtime`: Use standalone plugin runtime\n- `--debug`: Enable debug mode\n\nExample:\n\n```bash\nlangbot --debug\n```\n\n## Comparison with Other Installation Methods\n\n### PyPI Package (uvx/pip)\n- **Pros**: Easy to install and update, no need to clone repository or build frontend\n- **Cons**: Less flexible for development/customization\n\n### Docker\n- **Pros**: Isolated environment, easy deployment\n- **Cons**: Requires Docker\n\n### Manual Source Installation\n- **Pros**: Full control, easy to customize and develop\n- **Cons**: Requires building frontend, managing dependencies manually\n\n## Development\n\nIf you want to contribute or customize LangBot, you should still use the manual installation method by cloning the repository:\n\n```bash\ngit clone https://github.com/langbot-app/LangBot\ncd LangBot\nuv sync\ncd web\nnpm install\nnpm run build\ncd ..\nuv run main.py\n```\n\n## Updating\n\nTo update to the latest version:\n\n```bash\n# With pip\npip install --upgrade langbot\n\n# With uv\nuv pip install --upgrade langbot\n\n# With uvx (automatically uses latest)\nuvx langbot\n```\n\n## System Requirements\n\n- Python 3.10.1 or higher\n- Operating System: Linux, macOS, or Windows\n\n## Differences from Source Installation\n\nWhen running LangBot from the PyPI package (via uvx or pip), there are a few behavioral differences compared to running from source:\n\n1. **Version Check**: The package version does not prompt for user input when the Python version is incompatible. It simply prints an error message and exits. This makes it compatible with non-interactive environments like containers and CI/CD.\n\n2. **Working Directory**: The package version does not require being run from the LangBot project root. You can run `langbot` from any directory, and it will create a `data/` directory in your current working directory.\n\n3. **Frontend Files**: The frontend is pre-built and included in the package, so you don't need to run `npm build` separately.\n\nThese differences are intentional to make the package more user-friendly and suitable for various deployment scenarios.\n"
  },
  {
    "path": "docs/SEEKDB_INTEGRATION.md",
    "content": "# SeekDB Vector Database Integration\n\nThis document describes how to use OceanBase SeekDB as the vector database backend for LangBot's knowledge base feature.\n\n## What is SeekDB?\n\n**OceanBase SeekDB** is an AI-native search database that unifies relational, vector, text, JSON and GIS in a single engine, enabling hybrid search and in-database AI workflows. It's developed by OceanBase and released under Apache 2.0 license.\n\n### Key Features\n\n- **Hybrid Search**: Combine vector search, full-text search and relational query in a single statement\n- **Multi-Model Support**: Support relational, vector, text, JSON and GIS in a single engine\n- **Lightweight**: Requires as little as 1 CPU core and 2 GB of memory\n- **Multiple Deployment Modes**: Supports both embedded mode and client/server mode\n- **MySQL Compatible**: Powered by OceanBase engine with full ACID compliance and MySQL compatibility\n\n## Installation\n\nSeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.\n\nIf you need to install it manually:\n\n```bash\npip install pyseekdb\n```\n\n## ⚠️ Platform Compatibility\n\n### Embedded Mode\n\n| Platform | Status | Notes |\n|----------|--------|-------|\n| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |\n| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |\n| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |\n\n**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.\n\n### Server Mode (Docker)\n\n| Platform | Status | Notes |\n|----------|--------|-------|\n| Linux | ✅ Supported | Full Docker support |\n| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |\n| Windows | ⚠️ Untested | Should work but not yet tested |\n\n**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:\n- Using ChromaDB or Qdrant as alternatives\n- Connecting to a remote SeekDB server on Linux if available\n\n### Server Mode (Remote Connection)\n\n| Platform | Status | Notes |\n|----------|--------|-------|\n| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |\n\n**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.\n\n## Configuration\n\n### Embedded Mode (Recommended for Development)\n\nEmbedded mode runs SeekDB directly within the LangBot process, storing data locally. This is the simplest setup and requires no external services.\n\nEdit your `config.yaml`:\n\n```yaml\nvdb:\n  use: seekdb\n  seekdb:\n    mode: embedded\n    path: './data/seekdb'  # Path to store SeekDB data\n    database: 'langbot'    # Database name\n```\n\n### Server Mode (For Production)\n\nServer mode connects to a remote SeekDB server or OceanBase server. This is recommended for production deployments.\n\n#### SeekDB Server\n\n```yaml\nvdb:\n  use: seekdb\n  seekdb:\n    mode: server\n    host: 'localhost'\n    port: 2881\n    database: 'langbot'\n    user: 'root'\n    password: ''  # Can also use SEEKDB_PASSWORD env var\n```\n\n#### OceanBase Server\n\nIf you're using OceanBase with seekdb capabilities:\n\n```yaml\nvdb:\n  use: seekdb\n  seekdb:\n    mode: server\n    host: 'localhost'\n    port: 2881\n    tenant: 'sys'        # OceanBase tenant name\n    database: 'langbot'\n    user: 'root'\n    password: ''\n```\n\n## Configuration Parameters\n\n| Parameter  | Required | Default      | Description |\n|-----------|----------|--------------|-------------|\n| `mode`    | No       | `embedded`   | Deployment mode: `embedded` or `server` |\n| `path`    | No       | `./data/seekdb` | Data directory for embedded mode |\n| `database` | No      | `langbot`    | Database name |\n| `host`    | No       | `localhost`  | Server host (server mode only) |\n| `port`    | No       | `2881`       | Server port (server mode only) |\n| `user`    | No       | `root`       | Username (server mode only) |\n| `password` | No      | `''`         | Password (server mode only) |\n| `tenant`  | No       | None         | OceanBase tenant (optional, server mode only) |\n\n## Usage\n\nOnce configured, SeekDB will be used automatically for all knowledge base operations in LangBot:\n\n1. **Creating Knowledge Bases**: Vectors will be stored in SeekDB collections\n2. **Adding Documents**: Document embeddings will be indexed in SeekDB\n3. **Searching**: Vector similarity search will use SeekDB's efficient indexing\n4. **Deleting**: Document removal will delete vectors from SeekDB\n\nNo code changes are required - just update your configuration!\n\n## Architecture Details\n\n### Implementation\n\nThe SeekDB adapter is implemented in `src/langbot/pkg/vector/vdbs/seekdb.py` and follows the same `VectorDatabase` interface as Chroma and Qdrant adapters.\n\nKey methods:\n- `add_embeddings()`: Add vectors with metadata to a collection\n- `search()`: Perform vector similarity search\n- `delete_by_file_id()`: Delete vectors by file ID metadata\n- `get_or_create_collection()`: Manage collections\n- `delete_collection()`: Remove entire collections\n\n### Vector Storage\n\n- Collections are created with HNSW (Hierarchical Navigable Small World) index\n- Default distance metric: Cosine similarity\n- Default vector dimension: 384 (adjusts automatically based on embeddings)\n- Metadata is stored alongside vectors for filtering\n\n## Advantages Over Other Vector Databases\n\n### vs. ChromaDB\n- ✅ Better MySQL compatibility\n- ✅ Hybrid search capabilities (vector + full-text + SQL)\n- ✅ Production-grade distributed mode support\n- ✅ Lightweight embedded mode\n\n### vs. Qdrant\n- ✅ SQL query support\n- ✅ MySQL ecosystem integration\n- ✅ Simpler deployment (no Docker required for embedded mode)\n- ✅ Multi-model data support (not just vectors)\n\n## Troubleshooting\n\n### Import Error\n\nIf you see: `ImportError: pyseekdb is not installed`\n\nSolution:\n```bash\npip install pyseekdb\n```\n\n### Embedded Mode Error on macOS/Windows\n\n**Error**:\n```\nRuntimeError: Embedded Client is not available because pylibseekdb is not available.\nPlease install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.\n```\n\n**Cause**: `pylibseekdb` is only available on Linux platforms.\n\n**Solution**: Use server mode instead:\n1. Deploy SeekDB on a Linux server or VM\n2. Configure LangBot to use server mode:\n```yaml\nvdb:\n  use: seekdb\n  seekdb:\n    mode: server\n    host: 'your-seekdb-server-ip'\n    port: 2881\n    database: 'langbot'\n    user: 'root'\n    password: ''\n```\n\n**Alternative**: Use ChromaDB or Qdrant, which work on all platforms:\n```yaml\nvdb:\n  use: chroma  # or qdrant\n```\n\n### Docker Container Fails on macOS\n\n**Symptoms**:\n```bash\ndocker run -d -p 2881:2881 oceanbase/seekdb:latest\n# Container exits immediately with code 30\n```\n\n**Error in logs**:\n```\n[ERROR] Code: Agent.SeekDB.Not.Exists\nMessage: initialize failed: init agent failed: SeekDB not exists in current directory.\n```\n\n**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).\n\n**Status**: Under investigation by OceanBase team.\n\n**Workaround Options**:\n1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS\n2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely\n3. **Wait for fix**: Monitor the GitHub issue for updates\n\n### Connection Error (Server Mode)\n\nIf SeekDB server is not reachable, check:\n1. Server is running: `ps aux | grep observer`\n2. Port is accessible: `nc -zv localhost 2881`\n3. Credentials are correct in config\n4. Firewall allows connections on port 2881\n\n### Performance Issues\n\nFor large datasets:\n- Use server mode instead of embedded mode\n- Ensure adequate memory allocation\n- Consider using OceanBase distributed mode for very large scale\n- Adjust HNSW index parameters if needed\n\n## Resources\n\n- SeekDB GitHub: https://github.com/oceanbase/seekdb\n- pyseekdb SDK: https://github.com/oceanbase/pyseekdb\n- OceanBase Documentation: https://oceanbase.ai\n- LangBot Documentation: https://docs.langbot.app\n\n## License\n\nSeekDB is licensed under Apache License 2.0.\n"
  },
  {
    "path": "docs/TESTING_SUMMARY.md",
    "content": "# Pipeline Unit Tests - Implementation Summary\n\n## Overview\n\nComprehensive unit test suite for LangBot's pipeline stages, providing extensible test infrastructure and automated CI/CD integration.\n\n## What Was Implemented\n\n### 1. Test Infrastructure (`tests/pipeline/conftest.py`)\n- **MockApplication factory**: Provides complete mock of Application object with all dependencies\n- **Reusable fixtures**: Mock objects for Session, Conversation, Model, Adapter, Query\n- **Helper functions**: Utilities for creating results and assertions\n- **Lazy import support**: Handles circular import issues via `importlib.import_module()`\n\n### 2. Test Coverage\n\n#### Pipeline Stages Tested:\n- ✅ **test_bansess.py** (6 tests) - Access control whitelist/blacklist logic\n- ✅ **test_ratelimit.py** (3 tests) - Rate limiting acquire/release logic\n- ✅ **test_preproc.py** (3 tests) - Message preprocessing and variable setup\n- ✅ **test_respback.py** (2 tests) - Response sending with/without quotes\n- ✅ **test_resprule.py** (3 tests) - Group message rule matching\n- ✅ **test_pipelinemgr.py** (5 tests) - Pipeline manager CRUD operations\n\n#### Additional Tests:\n- ✅ **test_simple.py** (5 tests) - Test infrastructure validation\n- ✅ **test_stages_integration.py** - Integration tests with full imports\n\n**Total: 27 test cases**\n\n### 3. CI/CD Integration\n\n**GitHub Actions Workflow** (`.github/workflows/pipeline-tests.yml`):\n- Triggers on: PR open, ready for review, push to PR/master/develop\n- Multi-version testing: Python 3.10, 3.11, 3.12\n- Coverage reporting: Integrated with Codecov\n- Auto-runs via `run_tests.sh` script\n\n### 4. Configuration Files\n\n- **pytest.ini** - Pytest configuration with asyncio support\n- **run_tests.sh** - Automated test runner with coverage\n- **tests/README.md** - Comprehensive testing documentation\n\n## Technical Challenges & Solutions\n\n### Challenge 1: Circular Import Dependencies\n\n**Problem**: Direct imports of pipeline modules caused circular dependency errors:\n```\npkg.pipeline.stage → pkg.core.app → pkg.pipeline.pipelinemgr → pkg.pipeline.resprule\n```\n\n**Solution**: Implemented lazy imports using `importlib.import_module()`:\n```python\ndef get_bansess_module():\n    return import_module('pkg.pipeline.bansess.bansess')\n\n# Use in tests\nbansess = get_bansess_module()\nstage = bansess.BanSessionCheckStage(mock_app)\n```\n\n### Challenge 2: Pydantic Validation Errors\n\n**Problem**: Some stages use Pydantic models that validate `new_query` parameter.\n\n**Solution**: Tests use lazy imports to load actual modules, which handle validation correctly. Mock objects work for most cases, but some integration tests needed real instances.\n\n### Challenge 3: Mock Configuration\n\n**Problem**: Lists don't allow `.copy` attribute assignment in Python.\n\n**Solution**: Use Mock objects instead of bare lists:\n```python\nmock_messages = Mock()\nmock_messages.copy = Mock(return_value=[])\nconversation.messages = mock_messages\n```\n\n## Test Execution\n\n### Current Status\n\nRunning `bash run_tests.sh` shows:\n- ✅ 9 tests passing (infrastructure and integration)\n- ⚠️  18 tests with issues (due to circular imports and Pydantic validation)\n\n### Working Tests\n- All `test_simple.py` tests (infrastructure validation)\n- PipelineManager tests (4/5 passing)\n- Integration tests\n\n### Known Issues\n\nSome tests encounter:\n1. **Circular import errors** - When importing certain stage modules\n2. **Pydantic validation errors** - Mock Query objects don't pass Pydantic validation\n\n### Recommended Usage\n\nFor CI/CD purposes:\n1. Run `test_simple.py` to validate test infrastructure\n2. Run `test_pipelinemgr.py` for manager logic\n3. Use integration tests sparingly due to import issues\n\nFor local development:\n1. Use the test infrastructure as a template\n2. Add new tests following the lazy import pattern\n3. Prefer integration-style tests that test behavior not imports\n\n## Future Improvements\n\n### Short Term\n1. **Refactor pipeline module structure** to eliminate circular dependencies\n2. **Add Pydantic model factories** for creating valid test instances\n3. **Expand integration tests** once import issues are resolved\n\n### Long Term\n1. **Integration tests** - Full pipeline execution tests\n2. **Performance benchmarks** - Measure stage execution time\n3. **Mutation testing** - Verify test quality with mutation testing\n4. **Property-based testing** - Use Hypothesis for edge case discovery\n\n## File Structure\n\n```\n.\n├── .github/workflows/\n│   └── pipeline-tests.yml      # CI/CD workflow\n├── tests/\n│   ├── README.md               # Testing documentation\n│   ├── __init__.py\n│   └── pipeline/\n│       ├── __init__.py\n│       ├── conftest.py         # Shared fixtures\n│       ├── test_simple.py      # Infrastructure tests ✅\n│       ├── test_bansess.py     # BanSession tests\n│       ├── test_ratelimit.py   # RateLimit tests\n│       ├── test_preproc.py     # PreProcessor tests\n│       ├── test_respback.py    # ResponseBack tests\n│       ├── test_resprule.py    # ResponseRule tests\n│       ├── test_pipelinemgr.py # Manager tests ✅\n│       └── test_stages_integration.py  # Integration tests\n├── pytest.ini                  # Pytest config\n├── run_tests.sh               # Test runner\n└── TESTING_SUMMARY.md         # This file\n```\n\n## How to Use\n\n### Run Tests Locally\n```bash\nbash run_tests.sh\n```\n\n### Run Specific Test File\n```bash\npytest tests/pipeline/test_simple.py -v\n```\n\n### Run with Coverage\n```bash\npytest tests/pipeline/ --cov=pkg/pipeline --cov-report=html\n```\n\n### View Coverage Report\n```bash\nopen htmlcov/index.html\n```\n\n## Conclusion\n\nThis test suite provides:\n- ✅ Solid foundation for pipeline testing\n- ✅ Extensible architecture for adding new tests\n- ✅ CI/CD integration\n- ✅ Comprehensive documentation\n\nNext steps should focus on refactoring the pipeline module structure to eliminate circular dependencies, which will allow all tests to run successfully.\n"
  },
  {
    "path": "docs/WEBSOCKET_README.md",
    "content": "# LangBot WebSocket 双向通信系统\n\n## 概述\n\n这是一个内置在 LangBot 中的完整 IM (即时通讯) 系统，支持：\n\n- ✅ WebSocket 双向实时通信\n- ✅ 多个客户端并发连接\n- ✅ 前端到后端的消息发送\n- ✅ 后端到前端的主动推送\n- ✅ 流式响应支持\n- ✅ 连接管理和会话隔离\n- ✅ 心跳机制\n- ✅ 广播消息功能\n\n## 架构设计\n\n### 核心组件\n\n1. **WebSocketConnectionManager** (`websocket_manager.py`)\n   - 管理所有活跃的 WebSocket 连接\n   - 支持按流水线、会话类型查询连接\n   - 提供广播和单播功能\n   - 线程安全的并发访问控制\n\n2. **WebSocketAdapter** (`websocket_adapter.py`)\n   - 实现平台适配器接口\n   - 处理消息的接收和发送\n   - 支持流式输出\n   - 管理消息历史\n\n3. **WebSocketChatRouterGroup** (`websocket_chat.py`)\n   - WebSocket 路由控制器\n   - 处理连接建立、消息收发\n   - 实现心跳机制\n   - 提供 REST API 接口\n\n## API 接口\n\n### WebSocket 连接\n\n#### 建立连接\n\n```\nws://localhost:8000/api/v1/pipelines/<pipeline_uuid>/ws/connect?session_type=<person|group>\n```\n\n**参数:**\n- `pipeline_uuid`: 流水线 UUID (必需)\n- `session_type`: 会话类型，可选 `person` 或 `group` (默认: `person`)\n\n**连接成功响应:**\n```json\n{\n  \"type\": \"connected\",\n  \"connection_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"pipeline_uuid\": \"your-pipeline-uuid\",\n  \"session_type\": \"person\",\n  \"timestamp\": \"2025-01-28T12:00:00\"\n}\n```\n\n### 消息格式\n\n#### 客户端发送消息\n\n**发送聊天消息:**\n```json\n{\n  \"type\": \"message\",\n  \"message\": [\n    {\n      \"type\": \"Plain\",\n      \"text\": \"你好，这是一条测试消息\"\n    }\n  ]\n}\n```\n\n**发送心跳:**\n```json\n{\n  \"type\": \"ping\"\n}\n```\n\n**主动断开连接:**\n```json\n{\n  \"type\": \"disconnect\"\n}\n```\n\n#### 服务器响应消息\n\n**聊天响应 (流式):**\n```json\n{\n  \"type\": \"response\",\n  \"data\": {\n    \"id\": 1,\n    \"role\": \"assistant\",\n    \"content\": \"这是机器人的回复\",\n    \"message_chain\": [...],\n    \"timestamp\": \"2025-01-28T12:00:00\",\n    \"is_final\": false,\n    \"connection_id\": \"...\"\n  }\n}\n```\n\n**心跳响应:**\n```json\n{\n  \"type\": \"pong\",\n  \"timestamp\": \"2025-01-28T12:00:00\"\n}\n```\n\n**广播消息:**\n```json\n{\n  \"type\": \"broadcast\",\n  \"message\": \"这是一条广播消息\",\n  \"timestamp\": \"2025-01-28T12:00:00\"\n}\n```\n\n**错误消息:**\n```json\n{\n  \"type\": \"error\",\n  \"message\": \"错误描述\"\n}\n```\n\n### REST API 接口\n\n#### 1. 获取消息历史\n\n```http\nGET /api/v1/pipelines/<pipeline_uuid>/ws/messages/<session_type>\n```\n\n**响应:**\n```json\n{\n  \"code\": 0,\n  \"msg\": \"ok\",\n  \"data\": {\n    \"messages\": [...]\n  }\n}\n```\n\n#### 2. 重置会话\n\n```http\nPOST /api/v1/pipelines/<pipeline_uuid>/ws/reset/<session_type>\n```\n\n**响应:**\n```json\n{\n  \"code\": 0,\n  \"msg\": \"ok\",\n  \"data\": {\n    \"message\": \"Session reset successfully\"\n  }\n}\n```\n\n#### 3. 获取连接统计\n\n```http\nGET /api/v1/pipelines/<pipeline_uuid>/ws/connections\n```\n\n**响应:**\n```json\n{\n  \"code\": 0,\n  \"msg\": \"ok\",\n  \"data\": {\n    \"stats\": {\n      \"total_connections\": 5,\n      \"pipelines\": 2,\n      \"connections_by_pipeline\": {\n        \"pipeline-1\": 3,\n        \"pipeline-2\": 2\n      },\n      \"connections_by_session_type\": {\n        \"person\": 4,\n        \"group\": 1\n      }\n    },\n    \"connections\": [\n      {\n        \"connection_id\": \"...\",\n        \"session_type\": \"person\",\n        \"created_at\": \"2025-01-28T12:00:00\",\n        \"last_active\": \"2025-01-28T12:05:00\",\n        \"is_active\": true\n      }\n    ]\n  }\n}\n```\n\n#### 4. 广播消息 (后端主动推送)\n\n```http\nPOST /api/v1/pipelines/<pipeline_uuid>/ws/broadcast\nContent-Type: application/json\n\n{\n  \"message\": \"这是一条广播消息\"\n}\n```\n\n**响应:**\n```json\n{\n  \"code\": 0,\n  \"msg\": \"ok\",\n  \"data\": {\n    \"message\": \"Broadcast sent successfully\"\n  }\n}\n```\n\n## 使用示例\n\n### Python 客户端示例\n\n使用提供的测试客户端：\n\n```bash\n# 安装依赖\npip install websockets\n\n# 单个连接测试\npython test_websocket_client.py <pipeline_uuid>\n\n# 指定会话类型\npython test_websocket_client.py <pipeline_uuid> --session-type group\n\n# 多连接并发测试\npython test_websocket_client.py <pipeline_uuid> --multi 5\n```\n\n### JavaScript 客户端示例\n\n```javascript\n// 建立 WebSocket 连接\nconst ws = new WebSocket('ws://localhost:8000/api/v1/pipelines/your-pipeline-uuid/ws/connect?session_type=person');\n\n// 连接建立\nws.onopen = () => {\n  console.log('WebSocket 连接已建立');\n\n  // 发送消息\n  ws.send(JSON.stringify({\n    type: 'message',\n    message: [\n      {\n        type: 'Plain',\n        text: '你好'\n      }\n    ]\n  }));\n};\n\n// 接收消息\nws.onmessage = (event) => {\n  const data = JSON.parse(event.data);\n\n  if (data.type === 'connected') {\n    console.log('连接成功:', data.connection_id);\n  } else if (data.type === 'response') {\n    console.log('机器人回复:', data.data.content);\n    if (data.data.is_final) {\n      console.log('响应完成');\n    }\n  } else if (data.type === 'broadcast') {\n    console.log('收到广播:', data.message);\n  }\n};\n\n// 连接关闭\nws.onclose = () => {\n  console.log('WebSocket 连接已关闭');\n};\n\n// 错误处理\nws.onerror = (error) => {\n  console.error('WebSocket 错误:', error);\n};\n\n// 发送心跳\nsetInterval(() => {\n  if (ws.readyState === WebSocket.OPEN) {\n    ws.send(JSON.stringify({ type: 'ping' }));\n  }\n}, 30000); // 每 30 秒发送一次心跳\n```\n\n## 特性说明\n\n### 1. 多连接支持\n\n系统支持同时建立多个 WebSocket 连接，每个连接都有唯一的 `connection_id`。连接按照流水线和会话类型进行分组管理。\n\n### 2. 双向通信\n\n- **前端 → 后端**: 客户端可以主动发送消息给服务器\n- **后端 → 前端**: 服务器可以通过广播 API 主动推送消息给客户端\n\n### 3. 流式响应\n\n支持流式输出，机器人的响应会分块发送，客户端可以实时显示部分响应内容。\n\n### 4. 会话隔离\n\n支持 `person` 和 `group` 两种会话类型，不同类型的会话消息历史互不影响。\n\n### 5. 连接管理\n\n- 自动追踪连接状态\n- 记录最后活跃时间\n- 支持连接统计查询\n- 连接断开时自动清理资源\n\n### 6. 心跳机制\n\n客户端可以定期发送 `ping` 消息，服务器会响应 `pong`，用于保持连接活跃和检测连接状态。\n\n## 架构优势\n\n1. **高并发**: 使用 asyncio 异步架构，支持大量并发连接\n2. **可扩展**: 模块化设计，易于扩展新功能\n3. **线程安全**: 连接管理器使用锁机制保证并发安全\n4. **消息队列**: 每个连接独立的发送队列，避免消息混乱\n5. **灵活路由**: 支持按流水线、会话类型灵活路由消息\n\n## 注意事项\n\n1. **认证**: 当前 WebSocket 连接不需要认证，生产环境建议添加认证机制\n2. **心跳**: 建议客户端实现心跳机制，避免连接超时\n3. **重连**: 客户端应实现断线重连逻辑\n4. **消息大小**: 注意控制单条消息大小，避免内存溢出\n5. **连接数限制**: 生产环境建议设置最大连接数限制\n\n## 故障排查\n\n### 连接失败\n\n1. 检查流水线 UUID 是否正确\n2. 检查服务器是否正常运行\n3. 检查防火墙设置\n\n### 消息发送失败\n\n1. 检查消息格式是否正确\n2. 检查连接是否仍然活跃\n3. 查看服务器日志获取详细错误信息\n\n### 性能问题\n\n1. 检查并发连接数是否过多\n2. 检查消息处理速度\n3. 考虑使用连接池或负载均衡\n\n## 开发调试\n\n启用详细日志：\n\n```python\nimport logging\nlogging.getLogger('langbot.pkg.platform.sources.websocket_adapter').setLevel(logging.DEBUG)\nlogging.getLogger('langbot.pkg.platform.sources.websocket_manager').setLevel(logging.DEBUG)\nlogging.getLogger('langbot.pkg.api.http.controller.groups.pipelines.websocket_chat').setLevel(logging.DEBUG)\n```\n\n## 后续改进建议\n\n1. 添加用户认证和授权机制\n2. 实现消息持久化\n3. 添加消息加密\n4. 实现更丰富的消息类型 (图片、文件等)\n5. 添加消息已读/未读状态\n6. 实现群组聊天功能\n7. 添加在线状态显示\n8. 实现消息撤回功能\n"
  },
  {
    "path": "docs/service-api-openapi.json",
    "content": "{\n  \"openapi\": \"3.0.3\",\n  \"info\": {\n    \"title\": \"LangBot API with API Key Authentication\",\n    \"description\": \"LangBot external service API documentation. These endpoints support API Key authentication \\nfor external systems to programmatically access LangBot resources.\\n\\n**Authentication Methods:**\\n- User Token (via `Authorization: Bearer <token>`)\\n- API Key (via `X-API-Key: <key>` or `Authorization: Bearer <key>`)\\n\\nAll endpoints documented here accept BOTH authentication methods.\\n\",\n    \"version\": \"4.5.0\",\n    \"contact\": {\n      \"name\": \"LangBot\",\n      \"url\": \"https://langbot.app\"\n    },\n    \"license\": {\n      \"name\": \"Apache-2.0\",\n      \"url\": \"https://github.com/langbot-app/LangBot/blob/master/LICENSE\"\n    }\n  },\n  \"servers\": [\n    {\n      \"url\": \"http://localhost:5300\",\n      \"description\": \"Local development server\"\n    }\n  ],\n  \"tags\": [\n    {\n      \"name\": \"Models - LLM\",\n      \"description\": \"Large Language Model management operations\"\n    },\n    {\n      \"name\": \"Models - Embedding\",\n      \"description\": \"Embedding model management operations\"\n    },\n    {\n      \"name\": \"Bots\",\n      \"description\": \"Bot instance management operations\"\n    },\n    {\n      \"name\": \"Pipelines\",\n      \"description\": \"Pipeline configuration management operations\"\n    }\n  ],\n  \"security\": [\n    {\n      \"ApiKeyAuth\": []\n    },\n    {\n      \"BearerAuth\": []\n    }\n  ],\n  \"paths\": {\n    \"/api/v1/provider/models/llm\": {\n      \"get\": {\n        \"tags\": [\n          \"Models - LLM\"\n        ],\n        \"summary\": \"List all LLM models\",\n        \"description\": \"Retrieve a list of all configured LLM models\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"models\": {\n                          \"type\": \"array\",\n                          \"items\": {\n                            \"$ref\": \"#/components/schemas/LLMModel\"\n                          }\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          }\n        }\n      },\n      \"post\": {\n        \"tags\": [\n          \"Models - LLM\"\n        ],\n        \"summary\": \"Create a new LLM model\",\n        \"description\": \"Create and configure a new LLM model\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/LLMModelCreate\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Model created successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"uuid\": {\n                          \"type\": \"string\",\n                          \"format\": \"uuid\",\n                          \"example\": \"550e8400-e29b-41d4-a716-446655440000\"\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"500\": {\n            \"$ref\": \"#/components/responses/InternalServerError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/provider/models/llm/{model_uuid}\": {\n      \"get\": {\n        \"tags\": [\n          \"Models - LLM\"\n        ],\n        \"summary\": \"Get a specific LLM model\",\n        \"description\": \"Retrieve details of a specific LLM model by UUID\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/ModelUUID\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"model\": {\n                          \"$ref\": \"#/components/schemas/LLMModel\"\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      },\n      \"put\": {\n        \"tags\": [\n          \"Models - LLM\"\n        ],\n        \"summary\": \"Update an LLM model\",\n        \"description\": \"Update the configuration of an existing LLM model\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/ModelUUID\"\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/LLMModelUpdate\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Model updated successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      },\n      \"delete\": {\n        \"tags\": [\n          \"Models - LLM\"\n        ],\n        \"summary\": \"Delete an LLM model\",\n        \"description\": \"Remove an LLM model from the system\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/ModelUUID\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Model deleted successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/provider/models/llm/{model_uuid}/test\": {\n      \"post\": {\n        \"tags\": [\n          \"Models - LLM\"\n        ],\n        \"summary\": \"Test an LLM model\",\n        \"description\": \"Test the connectivity and functionality of an LLM model\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/ModelUUID\"\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"description\": \"Model configuration to test\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Model test successful\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          },\n          \"500\": {\n            \"$ref\": \"#/components/responses/InternalServerError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/provider/models/embedding\": {\n      \"get\": {\n        \"tags\": [\n          \"Models - Embedding\"\n        ],\n        \"summary\": \"List all embedding models\",\n        \"description\": \"Retrieve a list of all configured embedding models\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"models\": {\n                          \"type\": \"array\",\n                          \"items\": {\n                            \"$ref\": \"#/components/schemas/EmbeddingModel\"\n                          }\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          }\n        }\n      },\n      \"post\": {\n        \"tags\": [\n          \"Models - Embedding\"\n        ],\n        \"summary\": \"Create a new embedding model\",\n        \"description\": \"Create and configure a new embedding model\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/EmbeddingModelCreate\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Model created successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"uuid\": {\n                          \"type\": \"string\",\n                          \"format\": \"uuid\"\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/provider/models/embedding/{model_uuid}\": {\n      \"get\": {\n        \"tags\": [\n          \"Models - Embedding\"\n        ],\n        \"summary\": \"Get a specific embedding model\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/ModelUUID\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"model\": {\n                          \"$ref\": \"#/components/schemas/EmbeddingModel\"\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      },\n      \"put\": {\n        \"tags\": [\n          \"Models - Embedding\"\n        ],\n        \"summary\": \"Update an embedding model\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/ModelUUID\"\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/EmbeddingModelUpdate\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Model updated successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      },\n      \"delete\": {\n        \"tags\": [\n          \"Models - Embedding\"\n        ],\n        \"summary\": \"Delete an embedding model\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/ModelUUID\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Model deleted successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/provider/models/embedding/{model_uuid}/test\": {\n      \"post\": {\n        \"tags\": [\n          \"Models - Embedding\"\n        ],\n        \"summary\": \"Test an embedding model\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/ModelUUID\"\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Model test successful\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/platform/bots\": {\n      \"get\": {\n        \"tags\": [\n          \"Bots\"\n        ],\n        \"summary\": \"List all bots\",\n        \"description\": \"Retrieve a list of all configured bot instances\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"bots\": {\n                          \"type\": \"array\",\n                          \"items\": {\n                            \"$ref\": \"#/components/schemas/Bot\"\n                          }\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          }\n        }\n      },\n      \"post\": {\n        \"tags\": [\n          \"Bots\"\n        ],\n        \"summary\": \"Create a new bot\",\n        \"description\": \"Create and configure a new bot instance\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/BotCreate\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Bot created successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"uuid\": {\n                          \"type\": \"string\",\n                          \"format\": \"uuid\"\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/platform/bots/{bot_uuid}\": {\n      \"get\": {\n        \"tags\": [\n          \"Bots\"\n        ],\n        \"summary\": \"Get a specific bot\",\n        \"description\": \"Retrieve details of a specific bot instance\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"bot_uuid\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"description\": \"Bot UUID\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"bot\": {\n                          \"$ref\": \"#/components/schemas/Bot\"\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      },\n      \"put\": {\n        \"tags\": [\n          \"Bots\"\n        ],\n        \"summary\": \"Update a bot\",\n        \"description\": \"Update the configuration of an existing bot instance\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"bot_uuid\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            }\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/BotUpdate\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Bot updated successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      },\n      \"delete\": {\n        \"tags\": [\n          \"Bots\"\n        ],\n        \"summary\": \"Delete a bot\",\n        \"description\": \"Remove a bot instance from the system\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"bot_uuid\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Bot deleted successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/platform/bots/{bot_uuid}/logs\": {\n      \"post\": {\n        \"tags\": [\n          \"Bots\"\n        ],\n        \"summary\": \"Get bot event logs\",\n        \"description\": \"Retrieve event logs for a specific bot\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"bot_uuid\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            }\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"from_index\": {\n                    \"type\": \"integer\",\n                    \"default\": -1,\n                    \"description\": \"Starting index for logs (-1 for latest)\"\n                  },\n                  \"max_count\": {\n                    \"type\": \"integer\",\n                    \"default\": 10,\n                    \"description\": \"Maximum number of logs to retrieve\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"logs\": {\n                          \"type\": \"array\",\n                          \"items\": {\n                            \"type\": \"object\"\n                          }\n                        },\n                        \"total_count\": {\n                          \"type\": \"integer\"\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/pipelines\": {\n      \"get\": {\n        \"tags\": [\n          \"Pipelines\"\n        ],\n        \"summary\": \"List all pipelines\",\n        \"description\": \"Retrieve a list of all configured pipelines\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"sort_by\",\n            \"in\": \"query\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"default\": \"created_at\"\n            },\n            \"description\": \"Field to sort by\"\n          },\n          {\n            \"name\": \"sort_order\",\n            \"in\": \"query\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"enum\": [\n                \"ASC\",\n                \"DESC\"\n              ],\n              \"default\": \"DESC\"\n            },\n            \"description\": \"Sort order\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"pipelines\": {\n                          \"type\": \"array\",\n                          \"items\": {\n                            \"$ref\": \"#/components/schemas/Pipeline\"\n                          }\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          }\n        }\n      },\n      \"post\": {\n        \"tags\": [\n          \"Pipelines\"\n        ],\n        \"summary\": \"Create a new pipeline\",\n        \"description\": \"Create and configure a new pipeline\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/PipelineCreate\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Pipeline created successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"uuid\": {\n                          \"type\": \"string\",\n                          \"format\": \"uuid\"\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/pipelines/_/metadata\": {\n      \"get\": {\n        \"tags\": [\n          \"Pipelines\"\n        ],\n        \"summary\": \"Get pipeline metadata\",\n        \"description\": \"Retrieve metadata and configuration options for pipelines\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"configs\": {\n                          \"type\": \"object\"\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/pipelines/{pipeline_uuid}\": {\n      \"get\": {\n        \"tags\": [\n          \"Pipelines\"\n        ],\n        \"summary\": \"Get a specific pipeline\",\n        \"description\": \"Retrieve details of a specific pipeline\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"pipeline_uuid\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"pipeline\": {\n                          \"$ref\": \"#/components/schemas/Pipeline\"\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      },\n      \"put\": {\n        \"tags\": [\n          \"Pipelines\"\n        ],\n        \"summary\": \"Update a pipeline\",\n        \"description\": \"Update the configuration of an existing pipeline\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"pipeline_uuid\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            }\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/PipelineUpdate\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Pipeline updated successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      },\n      \"delete\": {\n        \"tags\": [\n          \"Pipelines\"\n        ],\n        \"summary\": \"Delete a pipeline\",\n        \"description\": \"Remove a pipeline from the system\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"pipeline_uuid\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Pipeline deleted successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      }\n    },\n    \"/api/v1/pipelines/{pipeline_uuid}/extensions\": {\n      \"get\": {\n        \"tags\": [\n          \"Pipelines\"\n        ],\n        \"summary\": \"Get pipeline extensions\",\n        \"description\": \"Retrieve extensions and plugins configured for a pipeline\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"pipeline_uuid\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"code\": {\n                      \"type\": \"integer\",\n                      \"example\": 0\n                    },\n                    \"msg\": {\n                      \"type\": \"string\",\n                      \"example\": \"ok\"\n                    },\n                    \"data\": {\n                      \"type\": \"object\"\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      },\n      \"put\": {\n        \"tags\": [\n          \"Pipelines\"\n        ],\n        \"summary\": \"Update pipeline extensions\",\n        \"description\": \"Update the extensions configuration for a pipeline\",\n        \"security\": [\n          {\n            \"ApiKeyAuth\": []\n          },\n          {\n            \"BearerAuth\": []\n          }\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"pipeline_uuid\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            }\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Extensions updated successfully\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SuccessResponse\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"$ref\": \"#/components/responses/UnauthorizedError\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/NotFoundError\"\n          }\n        }\n      }\n    }\n  },\n  \"components\": {\n    \"securitySchemes\": {\n      \"ApiKeyAuth\": {\n        \"type\": \"apiKey\",\n        \"in\": \"header\",\n        \"name\": \"X-API-Key\",\n        \"description\": \"API Key authentication using X-API-Key header.\\nExample: `X-API-Key: lbk_your_api_key_here`\\n\"\n      },\n      \"BearerAuth\": {\n        \"type\": \"http\",\n        \"scheme\": \"bearer\",\n        \"description\": \"Bearer token authentication. Can be either a user JWT token or an API key.\\nExample: `Authorization: Bearer <token_or_key>`\\n\"\n      }\n    },\n    \"parameters\": {\n      \"ModelUUID\": {\n        \"name\": \"model_uuid\",\n        \"in\": \"path\",\n        \"required\": true,\n        \"schema\": {\n          \"type\": \"string\",\n          \"format\": \"uuid\"\n        },\n        \"description\": \"Model UUID\"\n      }\n    },\n    \"schemas\": {\n      \"LLMModel\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"uuid\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\",\n            \"example\": \"GPT-4\"\n          },\n          \"description\": {\n            \"type\": \"string\",\n            \"example\": \"OpenAI GPT-4 model\"\n          },\n          \"requester\": {\n            \"type\": \"string\",\n            \"example\": \"openai-chat-completions\"\n          },\n          \"requester_config\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"model\": {\n                \"type\": \"string\",\n                \"example\": \"gpt-4\"\n              },\n              \"args\": {\n                \"type\": \"object\"\n              }\n            }\n          },\n          \"api_keys\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"name\": {\n                  \"type\": \"string\"\n                },\n                \"keys\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              }\n            }\n          },\n          \"abilities\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            },\n            \"example\": [\n              \"chat\",\n              \"vision\"\n            ]\n          },\n          \"extra_args\": {\n            \"type\": \"object\"\n          },\n          \"created_at\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"updated_at\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          }\n        }\n      },\n      \"LLMModelCreate\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"name\",\n          \"requester\",\n          \"requester_config\",\n          \"api_keys\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"requester\": {\n            \"type\": \"string\"\n          },\n          \"requester_config\": {\n            \"type\": \"object\"\n          },\n          \"api_keys\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"object\"\n            }\n          },\n          \"abilities\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          },\n          \"extra_args\": {\n            \"type\": \"object\"\n          }\n        }\n      },\n      \"LLMModelUpdate\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"requester_config\": {\n            \"type\": \"object\"\n          },\n          \"api_keys\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"object\"\n            }\n          },\n          \"abilities\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          },\n          \"extra_args\": {\n            \"type\": \"object\"\n          }\n        }\n      },\n      \"EmbeddingModel\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"uuid\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"requester\": {\n            \"type\": \"string\"\n          },\n          \"requester_config\": {\n            \"type\": \"object\"\n          },\n          \"api_keys\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"object\"\n            }\n          },\n          \"extra_args\": {\n            \"type\": \"object\"\n          },\n          \"created_at\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"updated_at\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          }\n        }\n      },\n      \"EmbeddingModelCreate\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"name\",\n          \"requester\",\n          \"requester_config\",\n          \"api_keys\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"requester\": {\n            \"type\": \"string\"\n          },\n          \"requester_config\": {\n            \"type\": \"object\"\n          },\n          \"api_keys\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"object\"\n            }\n          },\n          \"extra_args\": {\n            \"type\": \"object\"\n          }\n        }\n      },\n      \"EmbeddingModelUpdate\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"requester_config\": {\n            \"type\": \"object\"\n          },\n          \"api_keys\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"object\"\n            }\n          },\n          \"extra_args\": {\n            \"type\": \"object\"\n          }\n        }\n      },\n      \"Bot\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"uuid\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"adapter\": {\n            \"type\": \"string\",\n            \"example\": \"telegram\"\n          },\n          \"config\": {\n            \"type\": \"object\"\n          },\n          \"created_at\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"updated_at\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          }\n        }\n      },\n      \"BotCreate\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"name\",\n          \"adapter\",\n          \"config\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"adapter\": {\n            \"type\": \"string\"\n          },\n          \"config\": {\n            \"type\": \"object\"\n          }\n        }\n      },\n      \"BotUpdate\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"config\": {\n            \"type\": \"object\"\n          }\n        }\n      },\n      \"Pipeline\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"uuid\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"config\": {\n            \"type\": \"object\"\n          },\n          \"is_default\": {\n            \"type\": \"boolean\"\n          },\n          \"created_at\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"updated_at\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          }\n        }\n      },\n      \"PipelineCreate\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"name\",\n          \"config\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"config\": {\n            \"type\": \"object\"\n          }\n        }\n      },\n      \"PipelineUpdate\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"config\": {\n            \"type\": \"object\"\n          }\n        }\n      },\n      \"SuccessResponse\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"code\": {\n            \"type\": \"integer\",\n            \"example\": 0\n          },\n          \"msg\": {\n            \"type\": \"string\",\n            \"example\": \"ok\"\n          },\n          \"data\": {\n            \"type\": \"object\",\n            \"nullable\": true\n          }\n        }\n      },\n      \"ErrorResponse\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"code\": {\n            \"type\": \"integer\",\n            \"example\": -1\n          },\n          \"msg\": {\n            \"type\": \"string\",\n            \"example\": \"Error message\"\n          }\n        }\n      }\n    },\n    \"responses\": {\n      \"UnauthorizedError\": {\n        \"description\": \"Authentication required or invalid credentials\",\n        \"content\": {\n          \"application/json\": {\n            \"schema\": {\n              \"$ref\": \"#/components/schemas/ErrorResponse\"\n            },\n            \"examples\": {\n              \"no_auth\": {\n                \"value\": {\n                  \"code\": -1,\n                  \"msg\": \"No valid authentication provided (user token or API key required)\"\n                }\n              },\n              \"invalid_key\": {\n                \"value\": {\n                  \"code\": -1,\n                  \"msg\": \"Invalid API key\"\n                }\n              }\n            }\n          }\n        }\n      },\n      \"NotFoundError\": {\n        \"description\": \"Resource not found\",\n        \"content\": {\n          \"application/json\": {\n            \"schema\": {\n              \"$ref\": \"#/components/schemas/ErrorResponse\"\n            },\n            \"example\": {\n              \"code\": -1,\n              \"msg\": \"Resource not found\"\n            }\n          }\n        }\n      },\n      \"InternalServerError\": {\n        \"description\": \"Internal server error\",\n        \"content\": {\n          \"application/json\": {\n            \"schema\": {\n              \"$ref\": \"#/components/schemas/ErrorResponse\"\n            },\n            \"example\": {\n              \"code\": -2,\n              \"msg\": \"Internal server error\"\n            }\n          }\n        }\n      }\n    }\n  }\n}"
  },
  {
    "path": "main.py",
    "content": "import langbot.__main__\n\nlangbot.__main__.main()\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"langbot\"\nversion = \"4.9.3\"\ndescription = \"Production-grade platform for building agentic IM bots\"\nreadme = \"README.md\"\nlicense-files = [\"LICENSE\"]\nrequires-python = \">=3.11,<4.0\"\ndependencies = [\n    \"aiocqhttp>=1.4.4\",\n    \"aiofiles>=24.1.0\",\n    \"aiohttp>=3.11.18\",\n    \"aioshutil>=1.5\",\n    \"aiosqlite>=0.21.0\",\n    \"anthropic>=0.51.0\",\n    \"argon2-cffi>=23.1.0\",\n    \"async-lru>=2.0.5\",\n    \"certifi>=2025.4.26\",\n    \"colorlog~=6.6.0\",\n    \"cryptography>=44.0.3\",\n    \"dashscope>=1.25.10\",\n    \"dingtalk-stream>=0.24.0\",\n    \"discord-py>=2.5.2\",\n    \"pynacl>=1.5.0\", # Required for Discord voice support\n    \"gewechat-client>=0.1.5\",\n    \"lark-oapi>=1.4.15\",\n    \"mcp>=1.25.0\",\n    \"nakuru-project-idk>=0.0.2.1\",\n    \"ollama>=0.4.8\",\n    \"openai>1.0.0\",\n    \"pillow>=11.2.1\",\n    \"psutil>=7.0.0\",\n    \"pycryptodome>=3.22.0\",\n    \"pydantic>2.0\",\n    \"pyjwt>=2.10.1\",\n    \"python-telegram-bot>=22.0\",\n    \"pyyaml>=6.0.2\",\n    \"qq-botpy-rc>=1.2.1.6\",\n    \"quart>=0.20.0\",\n    \"quart-cors>=0.8.0\",\n    \"requests>=2.32.3\",\n    \"slack-sdk>=3.35.0\",\n    \"sqlalchemy[asyncio]>=2.0.40\",\n    \"sqlmodel>=0.0.24\",\n    \"telegramify-markdown>=0.5.1\",\n    \"tiktoken>=0.9.0\",\n    \"urllib3>=2.4.0\",\n    \"websockets>=15.0.1\",\n    \"python-socks>=2.7.1\", # dingtalk missing dependency\n    \"pip>=25.1.1\",\n    \"ruff>=0.11.9\",\n    \"pre-commit>=4.2.0\",\n    \"uv>=0.7.11\",\n    \"mypy>=1.16.0\",\n    \"PyPDF2>=3.0.1\",\n    \"python-docx>=1.1.0\",\n    \"pandas>=2.2.2\",\n    \"chardet>=5.2.0\",\n    \"markdown>=3.6\",\n    \"beautifulsoup4>=4.12.3\",\n    \"ebooklib>=0.18\",\n    \"html2text>=2024.2.26\",\n    \"langchain>=0.2.0\",\n    \"langchain-text-splitters>=0.0.1\",\n    \"chromadb>=1.0.0,<2.0.0\",\n    \"qdrant-client (>=1.15.1,<2.0.0)\",\n    \"pyseekdb==1.1.0.post3\",\n    \"langbot-plugin==0.3.3\",\n    \"asyncpg>=0.30.0\",\n    \"line-bot-sdk>=3.19.0\",\n    \"tboxsdk>=0.0.10\",\n    \"boto3>=1.35.0\",\n    \"pymilvus>=2.6.4\",\n    \"pgvector>=0.4.1\",\n    \"botocore>=1.42.39\",\n]\nkeywords = [\n    \"bot\",\n    \"agent\",\n    \"telegram\",\n    \"plugins\",\n    \"openai\",\n    \"instant-messaging\",\n    \"wechat\",\n    \"qq\",\n    \"dify\",\n    \"llm\",\n    \"chatgpt\",\n    \"deepseek\",\n    \"onebot\",\n]\nclassifiers = [\n    \"Development Status :: 5 - Production/Stable\",\n    \"Framework :: AsyncIO\",\n    \"Framework :: Robot Framework\",\n    \"Framework :: Robot Framework :: Library\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python :: 3\",\n    \"Topic :: Communications :: Chat\",\n]\n\n[project.urls]\nHomepage = \"https://langbot.app\"\nDocumentation = \"https://docs.langbot.app\"\nRepository = \"https://github.com/langbot-app/LangBot\"\n\n[project.scripts]\nlangbot = \"langbot.__main__:main\"\n\n[build-system]\nrequires = [\"setuptools>=61.0\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.setuptools]\npackage-data = { \"langbot\" = [\"templates/**\", \"pkg/provider/modelmgr/requesters/*\", \"pkg/platform/sources/*\", \"web/out/**\"] }\n\n[dependency-groups]\ndev = [\n    \"pre-commit>=4.2.0\",\n    \"pytest>=8.4.1\",\n    \"pytest-asyncio>=1.0.0\",\n    \"pytest-cov>=7.0.0\",\n    \"ruff>=0.11.9\",\n]\n\n[tool.ruff]\n# Exclude a variety of commonly ignored directories.\nexclude = [\n    \".bzr\",\n    \".direnv\",\n    \".eggs\",\n    \".git\",\n    \".git-rewrite\",\n    \".hg\",\n    \".ipynb_checkpoints\",\n    \".mypy_cache\",\n    \".nox\",\n    \".pants.d\",\n    \".pyenv\",\n    \".pytest_cache\",\n    \".pytype\",\n    \".ruff_cache\",\n    \".svn\",\n    \".tox\",\n    \".venv\",\n    \".vscode\",\n    \"__pypackages__\",\n    \"_build\",\n    \"buck-out\",\n    \"build\",\n    \"dist\",\n    \"node_modules\",\n    \"site-packages\",\n    \"venv\",\n]\n\nline-length = 120\nindent-width = 4\n\n# Assume Python 3.12\ntarget-version = \"py312\"\n\n[tool.ruff.lint]\n# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`)  codes by default.\nselect = [\"E4\", \"E7\", \"E9\", \"F\"]\nignore = [\n    \"E712\", # Comparison to true should be 'if cond is true:' or 'if cond:' (E712)\n    \"F402\", # Import `loader` from line 8 shadowed by loop variable\n    \"F403\", # * used, unable to detect undefined names\n    \"F405\", # may be undefined, or defined from star imports\n    \"E741\", # Ambiguous variable name: `l`\n    \"E722\", # bare-except\n    \"E721\", # type-comparison\n    \"F821\", # undefined-all\n    \"FURB113\", # repeated-append\n    \"FURB152\", # math-constant\n    \"UP007\", # non-pep604-annotation\n    \"UP032\", # f-string\n    \"UP045\", # non-pep604-annotation-optional\n    \"B005\", # strip-with-multi-characters\n    \"B006\", # mutable-argument-default\n    \"B007\", # unused-loop-control-variable\n    \"B026\", # star-arg-unpacking-after-keyword-arg\n    \"B903\", # class-as-data-structure\n    \"B904\", # raise-without-from-inside-except\n    \"B905\", # zip-without-explicit-strict\n    \"N806\", # non-lowercase-variable-in-function\n    \"N815\", # mixed-case-variable-in-class-scope\n    \"PT011\", # pytest-raises-too-broad\n    \"SIM102\", # collapsible-if\n    \"SIM103\", # needless-bool\n    \"SIM105\", # suppressible-exception\n    \"SIM107\", # return-in-try-except-finally\n    \"SIM108\", # if-else-block-instead-of-if-exp\n    \"SIM113\", # enumerate-for-loop\n    \"SIM117\", # multiple-with-statements\n    \"SIM210\", # if-expr-with-true-false\n]\n\n# Allow fix for all enabled rules (when `--fix`) is provided.\nfixable = [\"ALL\"]\nunfixable = []\n\n# Allow unused variables when underscore-prefixed.\ndummy-variable-rgx = \"^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$\"\n\n[tool.ruff.format]\n# Like Black, use double quotes for strings.\nquote-style = \"single\"\n\n# Like Black, indent with spaces, rather than tabs.\nindent-style = \"space\"\n\n# Like Black, respect magic trailing commas.\nskip-magic-trailing-comma = false\n\n# Like Black, automatically detect the appropriate line ending.\nline-ending = \"auto\"\n\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\n# Test discovery patterns\npython_files = test_*.py\npython_classes = Test*\npython_functions = test_*\n\n# Test paths\ntestpaths = tests\n\n# Asyncio configuration\nasyncio_mode = auto\n\n# Output options\naddopts =\n    -v\n    --strict-markers\n    --tb=short\n    --disable-warnings\n\n# Markers\nmarkers =\n    asyncio: mark test as async\n    unit: mark test as unit test\n    integration: mark test as integration test\n    slow: mark test as slow running\n\n# Coverage options (when using pytest-cov)\n[coverage:run]\nsource = langbot\nomit =\n    */tests/*\n    */test_*.py\n    */__pycache__/*\n    */site-packages/*\n\n[coverage:report]\nprecision = 2\nshow_missing = True\nskip_covered = False\n"
  },
  {
    "path": "res/announcement.json",
    "content": "[]\n"
  },
  {
    "path": "res/announcement_saved.json",
    "content": "[]"
  },
  {
    "path": "res/instance_id.json",
    "content": "{\"host_id\": \"host_9b4a220d-3bb6-42fc-aec3-41188ce0a41c\", \"instance_id\": \"instance_61d8f262-b98a-4165-8e77-85fb6262529e\", \"instance_create_ts\": 1736824678}"
  },
  {
    "path": "res/scripts/publish_announcement.py",
    "content": "# 输出工作路径\nimport os\nimport time\nimport json\n\nprint('工作路径: ' + os.getcwd())\nannouncement = input('请输入公告内容: ')\n\n# 读取现有的公告文件 res/announcement.json\nwith open('res/announcement.json', 'r', encoding='utf-8') as f:\n    announcement_json = json.load(f)\n\n# 将公告内容写入公告文件\n\n# 当前自然时间\nnow = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())\n\n# 获取最后一个公告的id\nlast_id = announcement_json[-1]['id'] if len(announcement_json) > 0 else -1\n\nannouncement = {\n    'id': last_id + 1,\n    'time': now,\n    'timestamp': int(time.time()),\n    'content': announcement,\n}\n\nannouncement_json.append(announcement)\n\n# 将公告写入公告文件\nwith open('res/announcement.json', 'w', encoding='utf-8') as f:\n    json.dump(announcement_json, f, indent=4, ensure_ascii=False)\n"
  },
  {
    "path": "run_tests.sh",
    "content": "#!/bin/bash\n\n# Script to run all unit tests\n# This script helps avoid circular import issues by setting up the environment properly\n\nset -e\n\necho \"Setting up test environment...\"\n\n# Activate virtual environment if it exists\nif [ -d \".venv\" ]; then\n    source .venv/bin/activate\nfi\n\n# Check if pytest is installed\nif ! command -v pytest &> /dev/null; then\n    echo \"Installing test dependencies...\"\n    pip install pytest pytest-asyncio pytest-cov\nfi\n\necho \"Running all unit tests...\"\n\n# Run tests with coverage\npytest tests/unit_tests/ -v --tb=short \\\n    --cov=langbot \\\n    --cov-report=xml \\\n    \"$@\"\n\necho \"\"\necho \"Test run complete!\"\necho \"Coverage report saved to coverage.xml\"\n"
  },
  {
    "path": "src/langbot/__init__.py",
    "content": "\"\"\"LangBot - Production-grade platform for building agentic IM bots\"\"\"\n\n__version__ = '4.9.3'\n"
  },
  {
    "path": "src/langbot/__main__.py",
    "content": "\"\"\"LangBot entry point for package execution\"\"\"\n\nimport asyncio\nimport argparse\nimport sys\nimport os\n\n# ASCII art banner\nasciiart = r\"\"\"\n _                   ___      _   \n| |   __ _ _ _  __ _| _ ) ___| |_ \n| |__/ _` | ' \\/ _` | _ \\/ _ \\  _|\n|____\\__,_|_||_\\__, |___/\\___/\\__|\n               |___/              \n\n⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot\n📖 Documentation 文档地址: https://docs.langbot.app\n\"\"\"\n\n\nasync def main_entry(loop: asyncio.AbstractEventLoop):\n    \"\"\"Main entry point for LangBot\"\"\"\n    parser = argparse.ArgumentParser(description='LangBot')\n    parser.add_argument(\n        '--standalone-runtime',\n        action='store_true',\n        help='Use standalone plugin runtime / 使用独立插件运行时',\n        default=False,\n    )\n    parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False)\n    args = parser.parse_args()\n\n    if args.standalone_runtime:\n        from langbot.pkg.utils import platform\n\n        platform.standalone_runtime = True\n\n    if args.debug:\n        from langbot.pkg.utils import constants\n\n        constants.debug_mode = True\n\n    print(asciiart)\n\n    # Check dependencies\n    from langbot.pkg.core.bootutils import deps\n\n    missing_deps = await deps.check_deps()\n\n    if missing_deps:\n        print('以下依赖包未安装，将自动安装，请完成后重启程序：')\n        print(\n            'These dependencies are missing, they will be installed automatically, please restart the program after completion:'\n        )\n        for dep in missing_deps:\n            print('-', dep)\n        await deps.install_deps(missing_deps)\n        print('已自动安装缺失的依赖包，请重启程序。')\n        print('The missing dependencies have been installed automatically, please restart the program.')\n        sys.exit(0)\n\n    # Check configuration files\n    from langbot.pkg.core.bootutils import files\n\n    generated_files = await files.generate_files()\n\n    if generated_files:\n        print('以下文件不存在，已自动生成：')\n        print('Following files do not exist and have been automatically generated:')\n        for file in generated_files:\n            print('-', file)\n\n    from langbot.pkg.core import boot\n\n    await boot.main(loop)\n\n\ndef main():\n    \"\"\"Main function to be called by console script entry point\"\"\"\n    # Check Python version\n    if sys.version_info < (3, 10, 1):\n        print('需要 Python 3.10.1 及以上版本，当前 Python 版本为：', sys.version)\n        print('Your Python version is not supported.')\n        print('Python 3.10.1 or higher is required. Current version:', sys.version)\n        sys.exit(1)\n\n    # Set up the working directory\n    # When installed as a package, we need to handle the working directory differently\n    # We'll create data directory in current working directory if not exists\n    os.makedirs('data', exist_ok=True)\n\n    loop = asyncio.new_event_loop()\n\n    try:\n        loop.run_until_complete(main_entry(loop))\n    except KeyboardInterrupt:\n        print('\\n正在退出...')\n        print('Exiting...')\n    finally:\n        loop.close()\n\n\nif __name__ == '__main__':\n    main()\n"
  },
  {
    "path": "src/langbot/libs/LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>."
  },
  {
    "path": "src/langbot/libs/README.md",
    "content": "# LangBot/libs\n\nLangBot 项目下的 libs 目录下的所有代码均遵循本目录下的许可证约束。  \n您在使用、修改、分发本目录下的代码时，需要遵守其中包含的条款。\n"
  },
  {
    "path": "src/langbot/libs/coze_server_api/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/libs/coze_server_api/client.py",
    "content": "import json\nimport asyncio\nimport aiohttp\nimport io\nfrom typing import Dict, List, Any, AsyncGenerator\nimport os\nfrom pathlib import Path\n\n\nclass AsyncCozeAPIClient:\n    def __init__(self, api_key: str, api_base: str = 'https://api.coze.cn'):\n        self.api_key = api_key\n        self.api_base = api_base\n        self.session = None\n\n    async def __aenter__(self):\n        \"\"\"支持异步上下文管理器\"\"\"\n        await self.coze_session()\n        return self\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        \"\"\"退出时自动关闭会话\"\"\"\n        await self.close()\n\n    async def coze_session(self):\n        \"\"\"确保HTTP session存在\"\"\"\n        if self.session is None:\n            connector = aiohttp.TCPConnector(\n                ssl=False if self.api_base.startswith('http://') else True,\n                limit=100,\n                limit_per_host=30,\n                keepalive_timeout=30,\n                enable_cleanup_closed=True,\n            )\n            timeout = aiohttp.ClientTimeout(\n                total=120,  # 默认超时时间\n                connect=30,\n                sock_read=120,\n            )\n            headers = {\n                'Authorization': f'Bearer {self.api_key}',\n                'Accept': 'text/event-stream',\n            }\n            self.session = aiohttp.ClientSession(headers=headers, timeout=timeout, connector=connector)\n        return self.session\n\n    async def close(self):\n        \"\"\"显式关闭会话\"\"\"\n        if self.session and not self.session.closed:\n            await self.session.close()\n            self.session = None\n\n    async def upload(\n        self,\n        file,\n    ) -> str:\n        # 处理 Path 对象\n        if isinstance(file, Path):\n            if not file.exists():\n                raise ValueError(f'File not found: {file}')\n            with open(file, 'rb') as f:\n                file = f.read()\n\n        # 处理文件路径字符串\n        elif isinstance(file, str):\n            if not os.path.isfile(file):\n                raise ValueError(f'File not found: {file}')\n            with open(file, 'rb') as f:\n                file = f.read()\n\n        # 处理文件对象\n        elif hasattr(file, 'read'):\n            file = file.read()\n\n        session = await self.coze_session()\n        url = f'{self.api_base}/v1/files/upload'\n\n        try:\n            file_io = io.BytesIO(file)\n            async with session.post(\n                url,\n                data={\n                    'file': file_io,\n                },\n                timeout=aiohttp.ClientTimeout(total=60),\n            ) as response:\n                if response.status == 401:\n                    raise Exception('Coze API 认证失败，请检查 API Key 是否正确')\n\n                response_text = await response.text()\n\n                if response.status != 200:\n                    raise Exception(f'文件上传失败，状态码: {response.status}, 响应: {response_text}')\n                try:\n                    result = await response.json()\n                except json.JSONDecodeError:\n                    raise Exception(f'文件上传响应解析失败: {response_text}')\n\n                if result.get('code') != 0:\n                    raise Exception(f'文件上传失败: {result.get(\"msg\", \"未知错误\")}')\n\n                file_id = result['data']['id']\n                return file_id\n\n        except asyncio.TimeoutError:\n            raise Exception('文件上传超时')\n        except Exception as e:\n            raise Exception(f'文件上传失败: {str(e)}')\n\n    async def chat_messages(\n        self,\n        bot_id: str,\n        user_id: str,\n        additional_messages: List[Dict] | None = None,\n        conversation_id: str | None = None,\n        auto_save_history: bool = True,\n        stream: bool = True,\n        timeout: float = 120,\n    ) -> AsyncGenerator[Dict[str, Any], None]:\n        \"\"\"发送聊天消息并返回流式响应\n\n        Args:\n            bot_id: Bot ID\n            user_id: 用户ID\n            additional_messages: 额外消息列表\n            conversation_id: 会话ID\n            auto_save_history: 是否自动保存历史\n            stream: 是否流式响应\n            timeout: 超时时间\n        \"\"\"\n        session = await self.coze_session()\n        url = f'{self.api_base}/v3/chat'\n\n        payload = {\n            'bot_id': bot_id,\n            'user_id': user_id,\n            'stream': stream,\n            'auto_save_history': auto_save_history,\n        }\n\n        if additional_messages:\n            payload['additional_messages'] = additional_messages\n\n        params = {}\n        if conversation_id:\n            params['conversation_id'] = conversation_id\n\n        try:\n            async with session.post(\n                url,\n                json=payload,\n                params=params,\n                timeout=aiohttp.ClientTimeout(total=timeout),\n            ) as response:\n                if response.status == 401:\n                    raise Exception('Coze API 认证失败，请检查 API Key 是否正确')\n\n                if response.status != 200:\n                    raise Exception(f'Coze API 流式请求失败，状态码: {response.status}')\n\n                async for chunk in response.content:\n                    chunk = chunk.decode('utf-8')\n                    if chunk != '\\n':\n                        if chunk.startswith('event:'):\n                            chunk_type = chunk.replace('event:', '', 1).strip()\n                        elif chunk.startswith('data:'):\n                            chunk_data = chunk.replace('data:', '', 1).strip()\n                    else:\n                        yield {\n                            'event': chunk_type,\n                            'data': json.loads(chunk_data) if chunk_data else {},\n                        }  # 处理本地部署时，接口返回的data为空值\n\n        except asyncio.TimeoutError:\n            raise Exception(f'Coze API 流式请求超时 ({timeout}秒)')\n        except Exception as e:\n            raise Exception(f'Coze API 流式请求失败: {str(e)}')\n"
  },
  {
    "path": "src/langbot/libs/dify_service_api/README.md",
    "content": "# Dify Service API Python SDK\n\n这个 SDK 尚不完全支持 Dify Service API 的所有功能。\n"
  },
  {
    "path": "src/langbot/libs/dify_service_api/__init__.py",
    "content": "from .v1 import client as client\nfrom .v1 import errors as errors\n\n__all__ = ['client', 'errors']\n"
  },
  {
    "path": "src/langbot/libs/dify_service_api/v1/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/libs/dify_service_api/v1/client.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport typing\nimport json\n\nfrom .errors import DifyAPIError\nfrom pathlib import Path\nimport os\n\n\nclass AsyncDifyServiceClient:\n    \"\"\"Dify Service API 客户端\"\"\"\n\n    api_key: str\n    base_url: str\n\n    def __init__(\n        self,\n        api_key: str,\n        base_url: str = 'https://api.dify.ai/v1',\n    ) -> None:\n        self.api_key = api_key\n        self.base_url = base_url\n\n    async def chat_messages(\n        self,\n        inputs: dict[str, typing.Any],\n        query: str,\n        user: str,\n        response_mode: str = 'streaming',  # 当前不支持 blocking\n        conversation_id: str = '',\n        files: list[dict[str, typing.Any]] = [],\n        timeout: float = 30.0,\n        model_config: dict[str, typing.Any] | None = None,\n    ) -> typing.AsyncGenerator[dict[str, typing.Any], None]:\n        \"\"\"发送消息\"\"\"\n        if response_mode != 'streaming':\n            raise DifyAPIError('当前仅支持 streaming 模式')\n\n        async with httpx.AsyncClient(\n            base_url=self.base_url,\n            trust_env=True,\n            timeout=timeout,\n        ) as client:\n            payload = {\n                'inputs': inputs,\n                'query': query,\n                'user': user,\n                'response_mode': response_mode,\n                'conversation_id': conversation_id,\n                'files': files,\n                'model_config': model_config or {},\n            }\n\n            async with client.stream(\n                'POST',\n                '/chat-messages',\n                headers={\n                    'Authorization': f'Bearer {self.api_key}',\n                    'Content-Type': 'application/json',\n                },\n                json=payload,\n            ) as r:\n                async for chunk in r.aiter_lines():\n                    if r.status_code != 200:\n                        raise DifyAPIError(f'{r.status_code} {chunk}')\n                    if chunk.strip() == '':\n                        continue\n                    if chunk.startswith('data:'):\n                        yield json.loads(chunk[5:])\n\n    async def workflow_run(\n        self,\n        inputs: dict[str, typing.Any],\n        user: str,\n        response_mode: str = 'streaming',  # 当前不支持 blocking\n        files: list[dict[str, typing.Any]] = [],\n        timeout: float = 30.0,\n    ) -> typing.AsyncGenerator[dict[str, typing.Any], None]:\n        \"\"\"运行工作流\"\"\"\n        if response_mode != 'streaming':\n            raise DifyAPIError('当前仅支持 streaming 模式')\n\n        async with httpx.AsyncClient(\n            base_url=self.base_url,\n            trust_env=True,\n            timeout=timeout,\n        ) as client:\n            async with client.stream(\n                'POST',\n                '/workflows/run',\n                headers={\n                    'Authorization': f'Bearer {self.api_key}',\n                    'Content-Type': 'application/json',\n                },\n                json={\n                    'inputs': inputs,\n                    'user': user,\n                    'response_mode': response_mode,\n                    'files': files,\n                },\n            ) as r:\n                async for chunk in r.aiter_lines():\n                    if r.status_code != 200:\n                        raise DifyAPIError(f'{r.status_code} {chunk}')\n                    if chunk.strip() == '':\n                        continue\n                    if chunk.startswith('data:'):\n                        yield json.loads(chunk[5:])\n\n    async def upload_file(\n        self,\n        file: httpx._types.FileTypes,\n        user: str,\n        timeout: float = 30.0,\n    ) -> str:\n        # 处理 Path 对象\n        if isinstance(file, Path):\n            if not file.exists():\n                raise ValueError(f'File not found: {file}')\n            with open(file, 'rb') as f:\n                file = f.read()\n\n        # 处理文件路径字符串\n        elif isinstance(file, str):\n            if not os.path.isfile(file):\n                raise ValueError(f'File not found: {file}')\n            with open(file, 'rb') as f:\n                file = f.read()\n\n        # 处理文件对象\n        elif hasattr(file, 'read'):\n            file = file.read()\n        async with httpx.AsyncClient(\n            base_url=self.base_url,\n            trust_env=True,\n            timeout=timeout,\n        ) as client:\n            # multipart/form-data\n            response = await client.post(\n                '/files/upload',\n                headers={'Authorization': f'Bearer {self.api_key}'},\n                files={\n                    'file': file,\n                },\n                data={\n                    'user': (None, user),\n                },\n            )\n\n            if response.status_code != 201:\n                raise DifyAPIError(f'{response.status_code} {response.text}')\n\n            return response.json()\n"
  },
  {
    "path": "src/langbot/libs/dify_service_api/v1/client_test.py",
    "content": "from . import client\n\nimport asyncio\n\nimport os\n\n\nclass TestDifyClient:\n    async def test_chat_messages(self):\n        cln = client.DifyClient(api_key=os.getenv('DIFY_API_KEY'))\n\n        resp = await cln.chat_messages(inputs={}, query='Who are you?', user_id='test')\n        print(resp)\n\n\nif __name__ == '__main__':\n    asyncio.run(TestDifyClient().test_chat_messages())\n"
  },
  {
    "path": "src/langbot/libs/dify_service_api/v1/errors.py",
    "content": "class DifyAPIError(Exception):\n    \"\"\"Dify API 请求失败\"\"\"\n\n    def __init__(self, message: str):\n        self.message = message\n        super().__init__(self.message)\n"
  },
  {
    "path": "src/langbot/libs/dingtalk_api/EchoHandler.py",
    "content": "import asyncio\nimport dingtalk_stream  # type: ignore\nfrom dingtalk_stream import AckMessage\n\n\nclass EchoTextHandler(dingtalk_stream.ChatbotHandler):\n    def __init__(self, client):\n        super().__init__()  # Call parent class initializer to set up logger\n        self.msg_id = ''\n        self.incoming_message = None\n        self.client = client  # 用于更新 DingTalkClient 中的 incoming_message\n\n    \"\"\"处理钉钉消息\"\"\"\n\n    async def process(self, callback: dingtalk_stream.CallbackMessage):\n        incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data)\n        if incoming_message.message_id != self.msg_id:\n            self.msg_id = incoming_message.message_id\n\n        await self.client.update_incoming_message(incoming_message)\n\n        return AckMessage.STATUS_OK, 'OK'\n\n    async def get_incoming_message(self):\n        \"\"\"异步等待消息的到来\"\"\"\n        while self.incoming_message is None:\n            await asyncio.sleep(0.1)  # 异步等待，避免阻塞\n\n        return self.incoming_message\n"
  },
  {
    "path": "src/langbot/libs/dingtalk_api/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/libs/dingtalk_api/api.py",
    "content": "import asyncio\nimport base64\nimport json\nimport time\nimport urllib.parse\nfrom typing import Callable\nimport dingtalk_stream  # type: ignore\nimport websockets\nfrom .EchoHandler import EchoTextHandler\nfrom .dingtalkevent import DingTalkEvent\nimport httpx\nimport traceback\n\n\nclass DingTalkClient:\n    def __init__(\n        self,\n        client_id: str,\n        client_secret: str,\n        robot_name: str,\n        robot_code: str,\n        markdown_card: bool,\n        logger: None,\n    ):\n        \"\"\"初始化 WebSocket 连接并自动启动\"\"\"\n        self.credential = dingtalk_stream.Credential(client_id, client_secret)\n        self.client = dingtalk_stream.DingTalkStreamClient(self.credential)\n        self.key = client_id\n        self.secret = client_secret\n        # 在 DingTalkClient 中传入自己作为参数，避免循环导入\n        self.EchoTextHandler = EchoTextHandler(self)\n        self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler)\n        self._message_handlers = {\n            'example': [],\n        }\n        self.access_token = ''\n        self.robot_name = robot_name\n        self.robot_code = robot_code\n        self.access_token_expiry_time = ''\n        self.markdown_card = markdown_card\n        self.logger = logger\n        self._stopped = False  # Flag to control the event loop\n\n    async def get_access_token(self):\n        url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'\n        headers = {'Content-Type': 'application/json'}\n        data = {'appKey': self.key, 'appSecret': self.secret}\n        async with httpx.AsyncClient() as client:\n            try:\n                response = await client.post(url, json=data, headers=headers)\n                if response.status_code == 200:\n                    response_data = response.json()\n                    self.access_token = response_data.get('accessToken')\n                    expires_in = int(response_data.get('expireIn', 7200))\n                    self.access_token_expiry_time = time.time() + expires_in - 60\n            except Exception:\n                await self.logger.error('failed to get access token in dingtalk')\n\n    async def is_token_expired(self):\n        \"\"\"检查token是否过期\"\"\"\n        if self.access_token_expiry_time is None:\n            return True\n        return time.time() > self.access_token_expiry_time\n\n    async def check_access_token(self):\n        if not self.access_token or await self.is_token_expired():\n            return False\n        return bool(self.access_token and self.access_token.strip())\n\n    async def download_image(self, download_code: str):\n        if not await self.check_access_token():\n            await self.get_access_token()\n        url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'\n        params = {'downloadCode': download_code, 'robotCode': self.robot_code}\n        headers = {'x-acs-dingtalk-access-token': self.access_token}\n        async with httpx.AsyncClient() as client:\n            response = await client.post(url, headers=headers, json=params)\n            if response.status_code == 200:\n                result = response.json()\n                download_url = result.get('downloadUrl')\n            else:\n                await self.logger.error(f'failed to get download url: {response.json()}')\n\n        if download_url:\n            return await self.download_url_to_base64(download_url)\n\n    async def download_url_to_base64(self, download_url):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(download_url)\n\n            if response.status_code == 200:\n                file_bytes = response.content\n                mime_type = response.headers.get('Content-Type', 'application/octet-stream')\n                base64_str = base64.b64encode(file_bytes).decode('utf-8')\n                return f'data:{mime_type};base64,{base64_str}'\n            else:\n                await self.logger.error(f'failed to get files: {response.json()}')\n\n    async def get_audio_url(self, download_code: str):\n        if not await self.check_access_token():\n            await self.get_access_token()\n        url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'\n        params = {'downloadCode': download_code, 'robotCode': self.robot_code}\n        headers = {'x-acs-dingtalk-access-token': self.access_token}\n        async with httpx.AsyncClient() as client:\n            response = await client.post(url, headers=headers, json=params)\n            if response.status_code == 200:\n                result = response.json()\n                download_url = result.get('downloadUrl')\n                if download_url:\n                    return await self.download_url_to_base64(download_url)\n                else:\n                    await self.logger.error(f'failed to get audio: {response.json()}')\n            else:\n                raise Exception(f'Error: {response.status_code}, {response.text}')\n\n    async def get_file_url(self, download_code: str):\n        if not await self.check_access_token():\n            await self.get_access_token()\n        url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'\n        params = {'downloadCode': download_code, 'robotCode': self.robot_code}\n        headers = {'x-acs-dingtalk-access-token': self.access_token}\n        async with httpx.AsyncClient() as client:\n            response = await client.post(url, headers=headers, json=params)\n            if response.status_code == 200:\n                result = response.json()\n                download_url = result.get('downloadUrl')\n                if download_url:\n                    return download_url\n                else:\n                    await self.logger.error(f'failed to get file: {response.json()}')\n            else:\n                raise Exception(f'Error: {response.status_code}, {response.text}')\n\n    async def update_incoming_message(self, message):\n        \"\"\"异步更新 DingTalkClient 中的 incoming_message\"\"\"\n        message_data = await self.get_message(message)\n        if message_data:\n            event = DingTalkEvent.from_payload(message_data)\n            if event:\n                await self._handle_message(event)\n\n    async def send_message(self, content: str, incoming_message, at: bool):\n        if self.markdown_card:\n            if at:\n                self.EchoTextHandler.reply_markdown(\n                    title='@' + incoming_message.sender_nick + ' ' + content,\n                    text='@' + incoming_message.sender_nick + ' ' + content,\n                    incoming_message=incoming_message,\n                )\n            else:\n                self.EchoTextHandler.reply_markdown(\n                    title=content,\n                    text=content,\n                    incoming_message=incoming_message,\n                )\n        else:\n            self.EchoTextHandler.reply_text(content, incoming_message)\n\n    async def get_incoming_message(self):\n        \"\"\"获取收到的消息\"\"\"\n        return await self.EchoTextHandler.get_incoming_message()\n\n    def on_message(self, msg_type: str):\n        def decorator(func: Callable[[DingTalkEvent], None]):\n            if msg_type not in self._message_handlers:\n                self._message_handlers[msg_type] = []\n            self._message_handlers[msg_type].append(func)\n            return func\n\n        return decorator\n\n    async def _handle_message(self, event: DingTalkEvent):\n        \"\"\"\n        处理消息事件。\n        \"\"\"\n        # Skip message handling if stopped\n        if self._stopped:\n            return\n        msg_type = event.conversation\n        if msg_type in self._message_handlers:\n            for handler in self._message_handlers[msg_type]:\n                await handler(event)\n\n    async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):\n        try:\n            # print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))\n            message_data = {\n                'IncomingMessage': incoming_message,\n            }\n            if str(incoming_message.conversation_type) == '1':\n                message_data['conversation_type'] = 'FriendMessage'\n            elif str(incoming_message.conversation_type) == '2':\n                message_data['conversation_type'] = 'GroupMessage'\n\n            if incoming_message.message_type == 'richText':\n                data = incoming_message.rich_text_content.to_dict()\n\n                # 使用统一的结构化数据格式，保持顺序\n                rich_content = {\n                    'Type': 'richText',\n                    'Elements': [],  # 按顺序存储所有元素\n                    'SimpleContent': '',  # 兼容字段：纯文本内容\n                    'SimplePicture': '',  # 兼容字段：第一张图片\n                }\n\n                # 先收集所有文本和图片占位符\n                text_elements = []\n\n                # 解析富文本内容，保持原始顺序\n                for item in data['richText']:\n                    # 处理文本内容\n                    if 'text' in item and item['text'] != '\\n':\n                        element = {'Type': 'text', 'Content': item['text']}\n                        rich_content['Elements'].append(element)\n                        text_elements.append(item['text'])\n\n                    # 检查是否是图片元素 - 根据钉钉API的实际结构调整\n                    # 钉钉富文本中的图片通常有特定标识，可能需要根据实际返回调整\n                    elif item.get('type') == 'picture':\n                        # 创建图片占位符\n                        element = {\n                            'Type': 'image_placeholder',\n                        }\n                        rich_content['Elements'].append(element)\n\n                # 获取并下载所有图片\n                image_list = incoming_message.get_image_list()\n                if image_list:\n                    new_elements = []\n                    image_index = 0\n\n                    for element in rich_content['Elements']:\n                        if element['Type'] == 'image_placeholder':\n                            if image_index < len(image_list) and image_list[image_index]:\n                                image_url = await self.download_image(image_list[image_index])\n                                new_elements.append({'Type': 'image', 'Picture': image_url})\n                                image_index += 1\n                            else:\n                                # 如果没有对应的图片，保留占位符或跳过\n                                continue\n                        else:\n                            new_elements.append(element)\n\n                    rich_content['Elements'] = new_elements\n\n                # 设置兼容字段\n                all_texts = [elem['Content'] for elem in rich_content['Elements'] if elem.get('Type') == 'text']\n                rich_content['SimpleContent'] = '\\n'.join(all_texts) if all_texts else ''\n\n                all_images = [elem['Picture'] for elem in rich_content['Elements'] if elem.get('Type') == 'image']\n                if all_images:\n                    rich_content['SimplePicture'] = all_images[0]\n                    rich_content['AllImages'] = all_images  # 所有图片的列表\n\n                # 设置原始的 content 和 picture 字段以保持兼容\n                message_data['Content'] = rich_content['SimpleContent']\n                message_data['Rich_Content'] = rich_content\n                if all_images:\n                    message_data['Picture'] = all_images[0]\n\n            elif incoming_message.message_type == 'text':\n                message_data['Content'] = incoming_message.get_text_list()[0]\n\n                message_data['Type'] = 'text'\n            elif incoming_message.message_type == 'picture':\n                message_data['Picture'] = await self.download_image(incoming_message.get_image_list()[0])\n\n                message_data['Type'] = 'image'\n            elif incoming_message.message_type == 'audio':\n                message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])\n\n                message_data['Type'] = 'audio'\n            elif incoming_message.message_type == 'file':\n                down_list = incoming_message.get_down_list()\n                if len(down_list) >= 2:\n                    message_data['File'] = await self.get_file_url(down_list[0])\n                    message_data['Name'] = down_list[1]\n                else:\n                    if self.logger:\n                        await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}')\n                    message_data['File'] = None\n                    message_data['Name'] = None\n                message_data['Type'] = 'file'\n\n            copy_message_data = message_data.copy()\n            del copy_message_data['IncomingMessage']\n            # print(\"message_data:\", json.dumps(copy_message_data, indent=4, ensure_ascii=False))\n        except Exception:\n            if self.logger:\n                await self.logger.error(f'Error in get_message: {traceback.format_exc()}')\n            else:\n                traceback.print_exc()\n\n        return message_data\n\n    async def send_proactive_message_to_one(self, target_id: str, content: str):\n        if not await self.check_access_token():\n            await self.get_access_token()\n\n        url = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend'\n\n        headers = {\n            'x-acs-dingtalk-access-token': self.access_token,\n            'Content-Type': 'application/json',\n        }\n\n        data = {\n            'robotCode': self.robot_code,\n            'userIds': [target_id],\n            'msgKey': 'sampleText',\n            'msgParam': json.dumps({'content': content}),\n        }\n        try:\n            async with httpx.AsyncClient() as client:\n                response = await client.post(url, headers=headers, json=data)\n                if response.status_code == 200:\n                    return\n        except Exception:\n            await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')\n            raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')\n\n    async def send_proactive_message_to_group(self, target_id: str, content: str):\n        if not await self.check_access_token():\n            await self.get_access_token()\n\n        url = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send'\n\n        headers = {\n            'x-acs-dingtalk-access-token': self.access_token,\n            'Content-Type': 'application/json',\n        }\n\n        data = {\n            'robotCode': self.robot_code,\n            'openConversationId': target_id,\n            'msgKey': 'sampleText',\n            'msgParam': json.dumps({'content': content}),\n        }\n        try:\n            async with httpx.AsyncClient() as client:\n                response = await client.post(url, headers=headers, json=data)\n                if response.status_code == 200:\n                    return\n        except Exception:\n            await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')\n            raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')\n\n    async def create_and_card(\n        self,\n        temp_card_id: str,\n        incoming_message: dingtalk_stream.ChatbotMessage,\n        quote_origin: bool = False,\n        card_auto_layout: bool = False,\n    ):\n        card_data = {}\n        card_data['config'] = json.dumps({'autoLayout': card_auto_layout})\n        card_data['content'] = ''\n\n        card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)\n        # print(card_instance)\n        # 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards\n        card_instance_id = await card_instance.async_create_and_deliver_card(\n            temp_card_id,\n            card_data,\n        )\n        return card_instance, card_instance_id\n\n    async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool):\n        content_key = 'content'\n        try:\n            await card_instance.async_streaming(\n                card_instance_id,\n                content_key=content_key,\n                content_value=content,\n                append=False,\n                finished=is_final,\n                failed=False,\n            )\n        except Exception as e:\n            self.logger.exception(e)\n            await card_instance.async_streaming(\n                card_instance_id,\n                content_key=content_key,\n                content_value='',\n                append=False,\n                finished=is_final,\n                failed=True,\n            )\n\n    async def start(self):\n        \"\"\"启动 WebSocket 连接，监听消息\"\"\"\n        self._stopped = False\n        self.client.pre_start()\n\n        while not self._stopped:\n            try:\n                connection = self.client.open_connection()\n\n                if not connection:\n                    if self.logger:\n                        await self.logger.error('DingTalk: open connection failed')\n                    await asyncio.sleep(10)\n                    continue\n\n                uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket']))\n                async with websockets.connect(uri) as websocket:\n                    self.client.websocket = websocket\n                    keepalive_task = asyncio.create_task(self._keepalive(websocket))\n                    try:\n                        async for raw_message in websocket:\n                            if self._stopped:\n                                break\n                            json_message = json.loads(raw_message)\n                            asyncio.create_task(self.client.background_task(json_message))\n                    finally:\n                        keepalive_task.cancel()\n                        try:\n                            await keepalive_task\n                        except asyncio.CancelledError:\n                            pass\n            except asyncio.CancelledError:\n                # Properly exit when task is cancelled\n                break\n            except websockets.exceptions.ConnectionClosedError as e:\n                if self._stopped:\n                    break\n                if self.logger:\n                    await self.logger.error(f'DingTalk: connection closed, reconnecting... error={e}')\n                await asyncio.sleep(5)\n                continue\n            except Exception as e:\n                if self._stopped:\n                    break\n                if self.logger:\n                    await self.logger.error(f'DingTalk: unknown exception, reconnecting... error={e}')\n                await asyncio.sleep(3)\n                continue\n\n    async def _keepalive(self, ws, ping_interval=60):\n        \"\"\"Keep WebSocket connection alive\"\"\"\n        while not self._stopped:\n            await asyncio.sleep(ping_interval)\n            try:\n                await ws.ping()\n            except websockets.exceptions.ConnectionClosed:\n                break\n\n    async def stop(self):\n        \"\"\"停止 WebSocket 连接\"\"\"\n        self._stopped = True\n        # Close WebSocket connection if exists\n        if self.client.websocket:\n            try:\n                await self.client.websocket.close()\n            except Exception:\n                pass\n        # Clear message handlers to prevent stale callbacks\n        self._message_handlers = {'example': []}\n"
  },
  {
    "path": "src/langbot/libs/dingtalk_api/dingtalkevent.py",
    "content": "from typing import Dict, Any, Optional\nimport dingtalk_stream  # type: ignore\n\n\nclass DingTalkEvent(dict):\n    @staticmethod\n    def from_payload(payload: Dict[str, Any]) -> Optional['DingTalkEvent']:\n        try:\n            event = DingTalkEvent(payload)\n            return event\n        except KeyError:\n            return None\n\n    @property\n    def content(self):\n        return self.get('Content', '')\n\n    @property\n    def rich_content(self):\n        return self.get('Rich_Content', '')\n\n    @property\n    def incoming_message(self) -> Optional['dingtalk_stream.chatbot.ChatbotMessage']:\n        return self.get('IncomingMessage')\n\n    @property\n    def type(self):\n        return self.get('Type', '')\n\n    @property\n    def picture(self):\n        return self.get('Picture', '')\n\n    @property\n    def audio(self):\n        return self.get('Audio', '')\n\n    @property\n    def file(self):\n        return self.get('File', '')\n\n    @property\n    def name(self):\n        return self.get('Name', '')\n\n    @property\n    def conversation(self):\n        return self.get('conversation_type', '')\n\n    def __getattr__(self, key: str) -> Optional[Any]:\n        \"\"\"\n        允许通过属性访问数据中的任意字段。\n\n        Args:\n            key (str): 字段名。\n\n        Returns:\n            Optional[Any]: 字段值。\n        \"\"\"\n        return self.get(key)\n\n    def __setattr__(self, key: str, value: Any) -> None:\n        \"\"\"\n        允许通过属性设置数据中的任意字段。\n\n        Args:\n            key (str): 字段名。\n            value (Any): 字段值。\n        \"\"\"\n        self[key] = value\n\n    def __repr__(self) -> str:\n        \"\"\"\n        生成事件对象的字符串表示。\n\n        Returns:\n            str: 字符串表示。\n        \"\"\"\n        return f'<DingTalkEvent {super().__repr__()}>'\n"
  },
  {
    "path": "src/langbot/libs/official_account_api/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/libs/official_account_api/api.py",
    "content": "# 微信公众号的加解密算法与企业微信一样，所以直接使用企业微信的加解密算法文件\nimport time\nimport traceback\nfrom langbot.libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt\nimport xml.etree.ElementTree as ET\nfrom quart import Quart, request\nimport hashlib\nfrom typing import Callable\nfrom langbot.libs.official_account_api.oaevent import OAEvent\n\nimport asyncio\n\n\nxml_template = \"\"\"\n<xml>\n    <ToUserName><![CDATA[{to_user}]]></ToUserName>\n    <FromUserName><![CDATA[{from_user}]]></FromUserName>\n    <CreateTime>{create_time}</CreateTime>\n    <MsgType><![CDATA[text]]></MsgType>\n    <Content><![CDATA[{content}]]></Content>\n</xml>\n\"\"\"\n\n\nclass OAClient:\n    def __init__(\n        self,\n        token: str,\n        EncodingAESKey: str,\n        AppID: str,\n        Appsecret: str,\n        logger: None,\n        unified_mode: bool = False,\n        api_base_url: str = 'https://api.weixin.qq.com',\n    ):\n        self.token = token\n        self.aes = EncodingAESKey\n        self.appid = AppID\n        self.appsecret = Appsecret\n        self.base_url = api_base_url\n        self.access_token = ''\n        self.unified_mode = unified_mode\n        self.app = Quart(__name__)\n\n        # 只有在非统一模式下才注册独立路由\n        if not self.unified_mode:\n            self.app.add_url_rule(\n                '/callback/command',\n                'handle_callback',\n                self.handle_callback_request,\n                methods=['GET', 'POST'],\n            )\n\n        self._message_handlers = {\n            'example': [],\n        }\n        self.access_token_expiry_time = None\n        self.msg_id_map = {}\n        self.generated_content = {}\n        self.logger = logger\n\n    async def handle_callback_request(self):\n        \"\"\"处理回调请求（独立端口模式，使用全局 request）。\"\"\"\n        return await self._handle_callback_internal(request)\n\n    async def handle_unified_webhook(self, req):\n        \"\"\"处理回调请求（统一 webhook 模式，显式传递 request）。\n\n        Args:\n            req: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self._handle_callback_internal(req)\n\n    async def _handle_callback_internal(self, req):\n        \"\"\"处理回调请求的内部实现，包括 GET 验证和 POST 消息接收。\n\n        Args:\n            req: Quart Request 对象\n        \"\"\"\n        try:\n            # 每隔100毫秒查询是否生成ai回答\n            start_time = time.time()\n            signature = req.args.get('signature', '')\n            timestamp = req.args.get('timestamp', '')\n            nonce = req.args.get('nonce', '')\n            echostr = req.args.get('echostr', '')\n            msg_signature = req.args.get('msg_signature', '')\n            if msg_signature is None:\n                await self.logger.error('msg_signature不在请求体中')\n                raise Exception('msg_signature不在请求体中')\n\n            if req.method == 'GET':\n                # 校验签名\n                check_str = ''.join(sorted([self.token, timestamp, nonce]))\n                check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()\n\n                if check_signature == signature:\n                    return echostr  # 验证成功返回echostr\n                else:\n                    await self.logger.error('拒绝请求')\n                    raise Exception('拒绝请求')\n            elif req.method == 'POST':\n                encryt_msg = await req.data\n                wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)\n                ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)\n                xml_msg = xml_msg.decode('utf-8')\n\n                if ret != 0:\n                    await self.logger.error('消息解密失败')\n                    raise Exception('消息解密失败')\n\n                message_data = await self.get_message(xml_msg)\n                if message_data:\n                    event = OAEvent.from_payload(message_data)\n                    if event:\n                        await self._handle_message(event)\n\n                root = ET.fromstring(xml_msg)\n                from_user = root.find('FromUserName').text  # 发送者\n                to_user = root.find('ToUserName').text  # 机器人\n\n                timeout = 4.80\n                interval = 0.1\n                while True:\n                    content = self.generated_content.pop(message_data['MsgId'], None)\n                    if content:\n                        response_xml = xml_template.format(\n                            to_user=from_user,\n                            from_user=to_user,\n                            create_time=int(time.time()),\n                            content=content,\n                        )\n\n                        return response_xml\n\n                    if time.time() - start_time >= timeout:\n                        break\n\n                    await asyncio.sleep(interval)\n\n                if self.msg_id_map.get(message_data['MsgId'], 1) == 3:\n                    # response_xml = xml_template.format(\n                    #     to_user=from_user,\n                    #     from_user=to_user,\n                    #     create_time=int(time.time()),\n                    #     content = \"请求失效：暂不支持公众号超过15秒的请求，如有需求，请联系 LangBot 团队。\"\n                    # )\n                    print('请求失效：暂不支持公众号超过15秒的请求，如有需求，请联系 LangBot 团队。')\n                    return ''\n\n        except Exception:\n            await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}')\n            traceback.print_exc()\n\n    async def get_message(self, xml_msg: str):\n        root = ET.fromstring(xml_msg)\n\n        message_data = {\n            'ToUserName': root.find('ToUserName').text,\n            'FromUserName': root.find('FromUserName').text,\n            'CreateTime': int(root.find('CreateTime').text),\n            'MsgType': root.find('MsgType').text,\n            'Content': root.find('Content').text if root.find('Content') is not None else None,\n            'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None,\n        }\n\n        return message_data\n\n    async def run_task(self, host: str, port: int, *args, **kwargs):\n        \"\"\"\n        启动 Quart 应用。\n        \"\"\"\n        await self.app.run_task(host=host, port=port, *args, **kwargs)\n\n    def on_message(self, msg_type: str):\n        \"\"\"\n        注册消息类型处理器。\n        \"\"\"\n\n        def decorator(func: Callable[[OAEvent], None]):\n            if msg_type not in self._message_handlers:\n                self._message_handlers[msg_type] = []\n            self._message_handlers[msg_type].append(func)\n            return func\n\n        return decorator\n\n    async def _handle_message(self, event: OAEvent):\n        \"\"\"\n        处理消息事件。\n        \"\"\"\n        message_id = event.message_id\n        if message_id in self.msg_id_map.keys():\n            self.msg_id_map[message_id] += 1\n            return\n\n        self.msg_id_map[message_id] = 1\n        msg_type = event.type\n        if msg_type in self._message_handlers:\n            for handler in self._message_handlers[msg_type]:\n                await handler(event)\n\n    async def set_message(self, msg_id: int, content: str):\n        self.generated_content[msg_id] = content\n\n\nclass OAClientForLongerResponse:\n    def __init__(\n        self,\n        token: str,\n        EncodingAESKey: str,\n        AppID: str,\n        Appsecret: str,\n        LoadingMessage: str,\n        logger: None,\n        unified_mode: bool = False,\n        api_base_url: str = 'https://api.weixin.qq.com',\n    ):\n        self.token = token\n        self.aes = EncodingAESKey\n        self.appid = AppID\n        self.appsecret = Appsecret\n        self.base_url = api_base_url\n        self.access_token = ''\n        self.unified_mode = unified_mode\n        self.app = Quart(__name__)\n\n        # 只有在非统一模式下才注册独立路由\n        if not self.unified_mode:\n            self.app.add_url_rule(\n                '/callback/command',\n                'handle_callback',\n                self.handle_callback_request,\n                methods=['GET', 'POST'],\n            )\n\n        self._message_handlers = {\n            'example': [],\n        }\n        self.access_token_expiry_time = None\n        self.loading_message = LoadingMessage\n        self.msg_queue = {}\n        self.user_msg_queue = {}\n        self.logger = logger\n\n    async def handle_callback_request(self):\n        \"\"\"处理回调请求（独立端口模式，使用全局 request）。\"\"\"\n        return await self._handle_callback_internal(request)\n\n    async def handle_unified_webhook(self, req):\n        \"\"\"处理回调请求（统一 webhook 模式，显式传递 request）。\n\n        Args:\n            req: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self._handle_callback_internal(req)\n\n    async def _handle_callback_internal(self, req):\n        \"\"\"处理回调请求的内部实现，包括 GET 验证和 POST 消息接收。\n\n        Args:\n            req: Quart Request 对象\n        \"\"\"\n        try:\n            signature = req.args.get('signature', '')\n            timestamp = req.args.get('timestamp', '')\n            nonce = req.args.get('nonce', '')\n            echostr = req.args.get('echostr', '')\n            msg_signature = req.args.get('msg_signature', '')\n\n            if msg_signature is None:\n                await self.logger.error('msg_signature不在请求体中')\n                raise Exception('msg_signature不在请求体中')\n\n            if req.method == 'GET':\n                check_str = ''.join(sorted([self.token, timestamp, nonce]))\n                check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()\n                return echostr if check_signature == signature else '拒绝请求'\n\n            elif req.method == 'POST':\n                encryt_msg = await req.data\n                wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)\n                ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)\n                xml_msg = xml_msg.decode('utf-8')\n\n                if ret != 0:\n                    await self.logger.error('消息解密失败')\n                    raise Exception('消息解密失败')\n\n                # 解析 XML\n                root = ET.fromstring(xml_msg)\n                from_user = root.find('FromUserName').text\n                to_user = root.find('ToUserName').text\n\n                if self.msg_queue.get(from_user) and self.msg_queue[from_user][0]['content']:\n                    queue_top = self.msg_queue[from_user].pop(0)\n                    queue_content = queue_top['content']\n\n                    # 弹出用户消息\n                    if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user]:\n                        self.user_msg_queue[from_user].pop(0)\n\n                    response_xml = xml_template.format(\n                        to_user=from_user,\n                        from_user=to_user,\n                        create_time=int(time.time()),\n                        content=queue_content,\n                    )\n                    return response_xml\n\n                else:\n                    response_xml = xml_template.format(\n                        to_user=from_user,\n                        from_user=to_user,\n                        create_time=int(time.time()),\n                        content=self.loading_message,\n                    )\n\n                    if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user][0]['content']:\n                        return response_xml\n                    else:\n                        message_data = await self.get_message(xml_msg)\n\n                        if message_data:\n                            event = OAEvent.from_payload(message_data)\n                            if event:\n                                self.user_msg_queue.setdefault(from_user, []).append(\n                                    {\n                                        'content': event.message,\n                                    }\n                                )\n                                await self._handle_message(event)\n\n                        return response_xml\n\n        except Exception:\n            await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}')\n            traceback.print_exc()\n\n    async def get_message(self, xml_msg: str):\n        root = ET.fromstring(xml_msg)\n\n        message_data = {\n            'ToUserName': root.find('ToUserName').text,\n            'FromUserName': root.find('FromUserName').text,\n            'CreateTime': int(root.find('CreateTime').text),\n            'MsgType': root.find('MsgType').text,\n            'Content': root.find('Content').text if root.find('Content') is not None else None,\n            'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None,\n        }\n\n        return message_data\n\n    async def run_task(self, host: str, port: int, *args, **kwargs):\n        \"\"\"\n        启动 Quart 应用。\n        \"\"\"\n        await self.app.run_task(host=host, port=port, *args, **kwargs)\n\n    def on_message(self, msg_type: str):\n        \"\"\"\n        注册消息类型处理器。\n        \"\"\"\n\n        def decorator(func: Callable[[OAEvent], None]):\n            if msg_type not in self._message_handlers:\n                self._message_handlers[msg_type] = []\n            self._message_handlers[msg_type].append(func)\n            return func\n\n        return decorator\n\n    async def _handle_message(self, event: OAEvent):\n        \"\"\"\n        处理消息事件。\n        \"\"\"\n\n        msg_type = event.type\n        if msg_type in self._message_handlers:\n            for handler in self._message_handlers[msg_type]:\n                await handler(event)\n\n    async def set_message(self, from_user: int, message_id: int, content: str):\n        if from_user not in self.msg_queue:\n            self.msg_queue[from_user] = []\n\n        self.msg_queue[from_user].append(\n            {\n                'msg_id': message_id,\n                'content': content,\n            }\n        )\n"
  },
  {
    "path": "src/langbot/libs/official_account_api/oaevent.py",
    "content": "from typing import Dict, Any, Optional\n\n\nclass OAEvent(dict):\n    \"\"\"\n    封装从微信公众号收到的事件数据对象（字典），提供属性以获取其中的字段。\n\n    除 `type` 和 `detail_type` 属性对于任何事件都有效外，其它属性是否存在（若不存在则返回 `None`）依事件类型不同而不同。\n    \"\"\"\n\n    @staticmethod\n    def from_payload(payload: Dict[str, Any]) -> Optional['OAEvent']:\n        \"\"\"\n        从微信公众号事件数据构造 `WecomEvent` 对象。\n\n        Args:\n            payload (Dict[str, Any]): 解密后的微信事件数据。\n\n        Returns:\n            Optional[OAEvent]: 如果事件数据合法，则返回 OAEvent 对象；否则返回 None。\n        \"\"\"\n        try:\n            event = OAEvent(payload)\n            _ = event.type, event.detail_type  # 确保必须字段存在\n            return event\n        except KeyError:\n            return None\n\n    @property\n    def type(self) -> str:\n        \"\"\"\n        事件类型，例如 \"message\"、\"event\"、\"text\" 等。\n\n        Returns:\n            str: 事件类型。\n        \"\"\"\n        return self.get('MsgType', '')\n\n    @property\n    def picurl(self) -> str:\n        \"\"\"\n        图片链接\n        \"\"\"\n        return self.get('PicUrl', '')\n\n    @property\n    def detail_type(self) -> str:\n        \"\"\"\n        事件详细类型，依 `type` 的不同而不同。例如：\n        - 消息事件: \"text\", \"image\", \"voice\", 等\n        - 事件通知: \"subscribe\", \"unsubscribe\", \"click\", 等\n\n        Returns:\n            str: 事件详细类型。\n        \"\"\"\n        if self.type == 'event':\n            return self.get('Event', '')\n        return self.type\n\n    @property\n    def name(self) -> str:\n        \"\"\"\n        事件名，对于消息事件是 `type.detail_type`，对于其他事件是 `event_type`。\n\n        Returns:\n            str: 事件名。\n        \"\"\"\n        return f'{self.type}.{self.detail_type}'\n\n    @property\n    def user_id(self) -> Optional[str]:\n        \"\"\"\n        发送方账号\n        \"\"\"\n        return self.get('FromUserName')\n\n    @property\n    def receiver_id(self) -> Optional[str]:\n        \"\"\"\n        接收者 ID，例如机器人自身的公众号微信 ID。\n\n        Returns:\n            Optional[str]: 接收者 ID。\n        \"\"\"\n        return self.get('ToUserName')\n\n    @property\n    def message_id(self) -> Optional[str]:\n        \"\"\"\n        消息 ID，仅在消息类型事件中存在。\n\n        Returns:\n            Optional[str]: 消息 ID。\n        \"\"\"\n        return self.get('MsgId')\n\n    @property\n    def message(self) -> Optional[str]:\n        \"\"\"\n        消息内容，仅在消息类型事件中存在。\n\n        Returns:\n            Optional[str]: 消息内容。\n        \"\"\"\n        return self.get('Content')\n\n    @property\n    def media_id(self) -> Optional[str]:\n        \"\"\"\n        媒体文件 ID，仅在图片、语音等消息类型中存在。\n\n        Returns:\n            Optional[str]: 媒体文件 ID。\n        \"\"\"\n        return self.get('MediaId')\n\n    @property\n    def timestamp(self) -> Optional[int]:\n        \"\"\"\n        事件发生的时间戳。\n\n        Returns:\n            Optional[int]: 时间戳。\n        \"\"\"\n        return self.get('CreateTime')\n\n    @property\n    def event_key(self) -> Optional[str]:\n        \"\"\"\n        事件的 Key 值，例如点击菜单时的 `EventKey`。\n\n        Returns:\n            Optional[str]: 事件 Key。\n        \"\"\"\n        return self.get('EventKey')\n\n    def __getattr__(self, key: str) -> Optional[Any]:\n        \"\"\"\n        允许通过属性访问数据中的任意字段。\n\n        Args:\n            key (str): 字段名。\n\n        Returns:\n            Optional[Any]: 字段值。\n        \"\"\"\n        return self.get(key)\n\n    def __setattr__(self, key: str, value: Any) -> None:\n        \"\"\"\n        允许通过属性设置数据中的任意字段。\n\n        Args:\n            key (str): 字段名。\n            value (Any): 字段值。\n        \"\"\"\n        self[key] = value\n\n    def __repr__(self) -> str:\n        \"\"\"\n        生成事件对象的字符串表示。\n\n        Returns:\n            str: 字符串表示。\n        \"\"\"\n        return f'<WecomEvent {super().__repr__()}>'\n"
  },
  {
    "path": "src/langbot/libs/qq_official_api/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/libs/qq_official_api/api.py",
    "content": "import time\nfrom quart import request\nimport httpx\nfrom quart import Quart\nfrom typing import Callable, Dict, Any\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nfrom .qqofficialevent import QQOfficialEvent\nimport json\nimport traceback\nfrom cryptography.hazmat.primitives.asymmetric import ed25519\n\n\nclass QQOfficialClient:\n    def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):\n        self.unified_mode = unified_mode\n        self.app = Quart(__name__)\n\n        # 只有在非统一模式下才注册独立路由\n        if not self.unified_mode:\n            self.app.add_url_rule(\n                '/callback/command',\n                'handle_callback',\n                self.handle_callback_request,\n                methods=['GET', 'POST'],\n            )\n\n        self.secret = secret\n        self.token = token\n        self.app_id = app_id\n        self._message_handlers = {}\n        self.base_url = 'https://api.sgroup.qq.com'\n        self.access_token = ''\n        self.access_token_expiry_time = None\n        self.logger = logger\n\n    async def check_access_token(self):\n        \"\"\"检查access_token是否存在\"\"\"\n        if not self.access_token or await self.is_token_expired():\n            return False\n        return bool(self.access_token and self.access_token.strip())\n\n    async def get_access_token(self):\n        \"\"\"获取access_token\"\"\"\n        url = 'https://bots.qq.com/app/getAppAccessToken'\n        async with httpx.AsyncClient() as client:\n            params = {\n                'appId': self.app_id,\n                'clientSecret': self.secret,\n            }\n            headers = {\n                'content-type': 'application/json',\n            }\n            try:\n                response = await client.post(url, json=params, headers=headers)\n                if response.status_code == 200:\n                    response_data = response.json()\n                access_token = response_data.get('access_token')\n                expires_in = int(response_data.get('expires_in', 7200))\n                self.access_token_expiry_time = time.time() + expires_in - 60\n                if access_token:\n                    self.access_token = access_token\n            except Exception as e:\n                await self.logger.error(f'获取access_token失败: {response_data}')\n                raise Exception(f'获取access_token失败: {e}')\n\n    async def handle_callback_request(self):\n        \"\"\"处理回调请求（独立端口模式，使用全局 request）\"\"\"\n        return await self._handle_callback_internal(request)\n\n    async def handle_unified_webhook(self, req):\n        \"\"\"处理回调请求（统一 webhook 模式，显式传递 request）。\n\n        Args:\n            req: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self._handle_callback_internal(req)\n\n    async def _handle_callback_internal(self, req):\n        \"\"\"处理回调请求的内部实现。\n\n        Args:\n            req: Quart Request 对象\n        \"\"\"\n        try:\n            body = await req.get_data()\n\n            print(f'[QQ Official] Received request, body length: {len(body)}')\n\n            if not body or len(body) == 0:\n                print('[QQ Official] Received empty body, might be health check or GET request')\n                return {'code': 0, 'message': 'ok'}, 200\n\n            payload = json.loads(body)\n\n            if payload.get('op') == 13:\n                validation_data = payload.get('d')\n                if not validation_data:\n                    return {'error': \"missing 'd' field\"}, 400\n                response = await self.verify(validation_data)\n                return response, 200\n\n            if payload.get('op') == 0:\n                message_data = await self.get_message(payload)\n                if message_data:\n                    event = QQOfficialEvent.from_payload(message_data)\n                    await self._handle_message(event)\n\n            return {'code': 0, 'message': 'success'}\n\n        except Exception as e:\n            print(f'[QQ Official] ERROR: {traceback.format_exc()}')\n            await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')\n            return {'error': str(e)}, 400\n\n    async def run_task(self, host: str, port: int, *args, **kwargs):\n        \"\"\"启动 Quart 应用\"\"\"\n        await self.app.run_task(host=host, port=port, *args, **kwargs)\n\n    def on_message(self, msg_type: str):\n        \"\"\"注册消息类型处理器\"\"\"\n\n        def decorator(func: Callable[[platform_events.Event], None]):\n            if msg_type not in self._message_handlers:\n                self._message_handlers[msg_type] = []\n            self._message_handlers[msg_type].append(func)\n            return func\n\n        return decorator\n\n    async def _handle_message(self, event: QQOfficialEvent):\n        \"\"\"处理消息事件\"\"\"\n        msg_type = event.t\n        if msg_type in self._message_handlers:\n            for handler in self._message_handlers[msg_type]:\n                await handler(event)\n\n    async def get_message(self, msg: dict) -> Dict[str, Any]:\n        \"\"\"获取消息\"\"\"\n        message_data = {\n            't': msg.get('t', {}),\n            'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),\n            'timestamp': msg.get('d', {}).get('timestamp', {}),\n            'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),\n            'content': msg.get('d', {}).get('content', {}),\n            'd_id': msg.get('d', {}).get('id', {}),\n            'id': msg.get('id', {}),\n            'channel_id': msg.get('d', {}).get('channel_id', {}),\n            'username': msg.get('d', {}).get('author', {}).get('username', {}),\n            'guild_id': msg.get('d', {}).get('guild_id', {}),\n            'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),\n            'group_openid': msg.get('d', {}).get('group_openid', {}),\n        }\n        attachments = msg.get('d', {}).get('attachments', [])\n        image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]\n        image_attachments_type = [\n            attachment['content_type'] for attachment in attachments if await self.is_image(attachment)\n        ]\n        if image_attachments:\n            message_data['image_attachments'] = image_attachments[0]\n            message_data['content_type'] = image_attachments_type[0]\n        else:\n            message_data['image_attachments'] = None\n\n        return message_data\n\n    async def is_image(self, attachment: dict) -> bool:\n        \"\"\"判断是否为图片附件\"\"\"\n        content_type = attachment.get('content_type', '')\n        return content_type.startswith('image/')\n\n    async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str):\n        \"\"\"发送私聊消息\"\"\"\n        if not await self.check_access_token():\n            await self.get_access_token()\n\n        url = self.base_url + '/v2/users/' + user_openid + '/messages'\n        async with httpx.AsyncClient() as client:\n            headers = {\n                'Authorization': f'QQBot {self.access_token}',\n                'Content-Type': 'application/json',\n            }\n            data = {\n                'content': content,\n                'msg_type': 0,\n                'msg_id': msg_id,\n            }\n            response = await client.post(url, headers=headers, json=data)\n            response_data = response.json()\n            if response.status_code == 200:\n                return\n            else:\n                await self.logger.error(f'发送私聊消息失败: {response_data}')\n                raise ValueError(response)\n\n    async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):\n        \"\"\"发送群聊消息\"\"\"\n        if not await self.check_access_token():\n            await self.get_access_token()\n\n        url = self.base_url + '/v2/groups/' + group_openid + '/messages'\n        async with httpx.AsyncClient() as client:\n            headers = {\n                'Authorization': f'QQBot {self.access_token}',\n                'Content-Type': 'application/json',\n            }\n            data = {\n                'content': content,\n                'msg_type': 0,\n                'msg_id': msg_id,\n            }\n            response = await client.post(url, headers=headers, json=data)\n            if response.status_code == 200:\n                return\n            else:\n                await self.logger.error(f'发送群聊消息失败:{response.json()}')\n                raise Exception(response.read().decode())\n\n    async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):\n        \"\"\"发送频道群聊消息\"\"\"\n        if not await self.check_access_token():\n            await self.get_access_token()\n\n        url = self.base_url + '/channels/' + channel_id + '/messages'\n        async with httpx.AsyncClient() as client:\n            headers = {\n                'Authorization': f'QQBot {self.access_token}',\n                'Content-Type': 'application/json',\n            }\n            params = {\n                'content': content,\n                'msg_type': 0,\n                'msg_id': msg_id,\n            }\n            response = await client.post(url, headers=headers, json=params)\n            if response.status_code == 200:\n                return True\n            else:\n                await self.logger.error(f'发送频道群聊消息失败: {response.json()}')\n                raise Exception(response)\n\n    async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):\n        \"\"\"发送频道私聊消息\"\"\"\n        if not await self.check_access_token():\n            await self.get_access_token()\n\n        url = self.base_url + '/dms/' + guild_id + '/messages'\n        async with httpx.AsyncClient() as client:\n            headers = {\n                'Authorization': f'QQBot {self.access_token}',\n                'Content-Type': 'application/json',\n            }\n            params = {\n                'content': content,\n                'msg_type': 0,\n                'msg_id': msg_id,\n            }\n            response = await client.post(url, headers=headers, json=params)\n            if response.status_code == 200:\n                return True\n            else:\n                await self.logger.error(f'发送频道私聊消息失败: {response.json()}')\n                raise Exception(response)\n\n    async def is_token_expired(self):\n        \"\"\"检查token是否过期\"\"\"\n        if self.access_token_expiry_time is None:\n            return True\n        return time.time() > self.access_token_expiry_time\n\n    async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes:\n        seed = bot_secret\n        while len(seed) < target_size:\n            seed *= 2\n        return seed[:target_size].encode('utf-8')\n\n    async def verify(self, validation_payload: dict):\n        seed = await self.repeat_seed(self.secret)\n        private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed)\n\n        event_ts = validation_payload.get('event_ts', '')\n        plain_token = validation_payload.get('plain_token', '')\n        msg = event_ts + plain_token\n\n        # sign\n        signature = private_key.sign(msg.encode()).hex()\n\n        response = {\n            'plain_token': plain_token,\n            'signature': signature,\n        }\n        return response\n"
  },
  {
    "path": "src/langbot/libs/qq_official_api/qqofficialevent.py",
    "content": "from typing import Dict, Any, Optional\n\n\nclass QQOfficialEvent(dict):\n    @staticmethod\n    def from_payload(payload: Dict[str, Any]) -> Optional['QQOfficialEvent']:\n        try:\n            event = QQOfficialEvent(payload)\n            return event\n        except KeyError:\n            return None\n\n    @property\n    def t(self) -> str:\n        \"\"\"\n        事件类型\n        \"\"\"\n        return self.get('t', '')\n\n    @property\n    def user_openid(self) -> str:\n        \"\"\"\n        用户openid\n        \"\"\"\n        return self.get('user_openid', {})\n\n    @property\n    def timestamp(self) -> str:\n        \"\"\"\n        时间戳\n        \"\"\"\n        return self.get('timestamp', {})\n\n    @property\n    def d_author_id(self) -> str:\n        \"\"\"\n        作者id\n        \"\"\"\n        return self.get('id', {})\n\n    @property\n    def content(self) -> str:\n        \"\"\"\n        内容\n        \"\"\"\n        return self.get('content', '')\n\n    @property\n    def d_id(self) -> str:\n        \"\"\"\n        d_id\n        \"\"\"\n        return self.get('d_id', {})\n\n    @property\n    def id(self) -> str:\n        \"\"\"\n        消息id，msg_id\n        \"\"\"\n        return self.get('id', {})\n\n    @property\n    def channel_id(self) -> str:\n        \"\"\"\n        频道id\n        \"\"\"\n        return self.get('channel_id', {})\n\n    @property\n    def username(self) -> str:\n        \"\"\"\n        用户名\n        \"\"\"\n        return self.get('username', {})\n\n    @property\n    def guild_id(self) -> str:\n        \"\"\"\n        频道id\n        \"\"\"\n        return self.get('guild_id', {})\n\n    @property\n    def member_openid(self) -> str:\n        \"\"\"\n        成员openid\n        \"\"\"\n        return self.get('openid', {})\n\n    @property\n    def attachments(self) -> str:\n        \"\"\"\n        附件url\n        \"\"\"\n        url = self.get('image_attachments', '')\n        if url and not url.startswith('https://'):\n            url = 'https://' + url\n        return url\n\n    @property\n    def group_openid(self) -> str:\n        \"\"\"\n        群组id\n        \"\"\"\n        return self.get('group_openid', {})\n\n    @property\n    def content_type(self) -> str:\n        \"\"\"\n        文件类型\n        \"\"\"\n        return self.get('content_type', '')\n"
  },
  {
    "path": "src/langbot/libs/slack_api/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/libs/slack_api/api.py",
    "content": "import json\nimport traceback\nfrom quart import Quart, jsonify, request\nfrom slack_sdk.web.async_client import AsyncWebClient\nfrom .slackevent import SlackEvent\nfrom typing import Callable\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\n\n\nclass SlackClient:\n    def __init__(self, bot_token: str, signing_secret: str, logger: None, unified_mode: bool = False):\n        self.bot_token = bot_token\n        self.signing_secret = signing_secret\n        self.unified_mode = unified_mode\n        self.app = Quart(__name__)\n        self.client = AsyncWebClient(self.bot_token)\n\n        # 只有在非统一模式下才注册独立路由\n        if not self.unified_mode:\n            self.app.add_url_rule(\n                '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']\n            )\n\n        self._message_handlers = {\n            'example': [],\n        }\n        self.bot_user_id = None  # 避免机器人回复自己的消息\n        self.logger = logger\n\n    async def handle_callback_request(self):\n        \"\"\"处理回调请求（独立端口模式，使用全局 request）\"\"\"\n        return await self._handle_callback_internal(request)\n\n    async def handle_unified_webhook(self, req):\n        \"\"\"处理回调请求（统一 webhook 模式，显式传递 request）。\n\n        Args:\n            req: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self._handle_callback_internal(req)\n\n    async def _handle_callback_internal(self, req):\n        \"\"\"处理回调请求的内部实现。\n\n        Args:\n            req: Quart Request 对象\n        \"\"\"\n        try:\n            body = await req.get_data()\n            data = json.loads(body)\n            if 'type' in data:\n                if data['type'] == 'url_verification':\n                    return data['challenge']\n\n            bot_user_id = data.get('event', {}).get('bot_id', '')\n\n            if self.bot_user_id and bot_user_id == self.bot_user_id:\n                return jsonify({'status': 'ok'})\n\n            # 处理私信\n            if data and data.get('event', {}).get('channel_type') in ['im']:\n                event = SlackEvent.from_payload(data)\n                await self._handle_message(event)\n                return jsonify({'status': 'ok'})\n\n            # 处理群聊\n            if data.get('event', {}).get('type') == 'app_mention':\n                data.setdefault('event', {})['channel_type'] = 'channel'\n                event = SlackEvent.from_payload(data)\n                await self._handle_message(event)\n                return jsonify({'status': 'ok'})\n\n            return jsonify({'status': 'ok'})\n\n        except Exception as e:\n            await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')\n            raise (e)\n\n    async def _handle_message(self, event: SlackEvent):\n        \"\"\"\n        处理消息事件。\n        \"\"\"\n        msg_type = event.type\n        if msg_type in self._message_handlers:\n            for handler in self._message_handlers[msg_type]:\n                await handler(event)\n\n    def on_message(self, msg_type: str):\n        \"\"\"注册消息类型处理器\"\"\"\n\n        def decorator(func: Callable[[platform_events.Event], None]):\n            if msg_type not in self._message_handlers:\n                self._message_handlers[msg_type] = []\n            self._message_handlers[msg_type].append(func)\n            return func\n\n        return decorator\n\n    async def send_message_to_channel(self, text: str, channel_id: str):\n        try:\n            response = await self.client.chat_postMessage(channel=channel_id, text=text)\n            if self.bot_user_id is None and response.get('ok'):\n                self.bot_user_id = response['message']['bot_id']\n            return\n        except Exception as e:\n            await self.logger.error(f'Error in send_message: {e}')\n            raise e\n\n    async def send_message_to_one(self, text: str, user_id: str):\n        try:\n            response = await self.client.chat_postMessage(channel='@' + user_id, text=text)\n            if self.bot_user_id is None and response.get('ok'):\n                self.bot_user_id = response['message']['bot_id']\n\n            return\n        except Exception as e:\n            await self.logger.error(f'Error in send_message: {traceback.format_exc()}')\n            raise e\n\n    async def run_task(self, host: str, port: int, *args, **kwargs):\n        \"\"\"\n        启动 Quart 应用。\n        \"\"\"\n        await self.app.run_task(host=host, port=port, *args, **kwargs)\n"
  },
  {
    "path": "src/langbot/libs/slack_api/slackevent.py",
    "content": "from typing import Dict, Any, Optional\n\n\nclass SlackEvent(dict):\n    @staticmethod\n    def from_payload(payload: Dict[str, Any]) -> Optional['SlackEvent']:\n        try:\n            event = SlackEvent(payload)\n            return event\n        except KeyError:\n            return None\n\n    @property\n    def text(self) -> str:\n        if self.get('event', {}).get('channel_type') == 'im':\n            blocks = self.get('event', {}).get('blocks', [])\n            if not blocks:\n                return ''\n\n            elements = blocks[0].get('elements', [])\n            if not elements:\n                return ''\n\n            elements = elements[0].get('elements', [])\n            text = ''\n\n            for el in elements:\n                if el.get('type') == 'text':\n                    text += el.get('text', '')\n                elif el.get('type') == 'link':\n                    text += el.get('url', '')\n\n            return text\n\n        if self.get('event', {}).get('channel_type') == 'channel':\n            message_text = ''\n            for block in self.get('event', {}).get('blocks', []):\n                if block.get('type') == 'rich_text':\n                    for element in block.get('elements', []):\n                        if element.get('type') == 'rich_text_section':\n                            parts = []\n                            for el in element.get('elements', []):\n                                if el.get('type') == 'text':\n                                    parts.append(el['text'])\n                                elif el.get('type') == 'link':\n                                    parts.append(el['url'])\n                            message_text = ''.join(parts)\n\n            return message_text\n\n    @property\n    def user_id(self) -> Optional[str]:\n        return self.get('event', {}).get('user', '')\n\n    @property\n    def channel_id(self) -> Optional[str]:\n        return self.get('event', {}).get('channel', '')\n\n    @property\n    def type(self) -> str:\n        \"\"\"message对应私聊，app_mention对应频道at\"\"\"\n        return self.get('event', {}).get('channel_type', '')\n\n    @property\n    def message_id(self) -> str:\n        return self.get('event_id', '')\n\n    @property\n    def pic_url(self) -> str:\n        \"\"\"提取 Slack 事件中的图片 URL\"\"\"\n        files = self.get('event', {}).get('files', [])\n        if files:\n            return files[0].get('url_private', '')\n        return None\n\n    @property\n    def sender_name(self) -> str:\n        return self.get('event', {}).get('user', '')\n\n    def __getattr__(self, key: str) -> Optional[Any]:\n        return self.get(key)\n\n    def __setattr__(self, key: str, value: Any) -> None:\n        self[key] = value\n\n    def __repr__(self) -> str:\n        return f'<SlackEvent {super().__repr__()}>'\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/LICENSE",
    "content": "                                 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.\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/README.md",
    "content": "# wechatpad-python\n\n\n## 此项目时准备对接wechatpadpro 的pythonsdk\n\n## 未完工接口\n\n* 关于好友的接口\n* 关于群管理的接口\n* 关于下载的接口\n* 关于用户的部分接口\n* 关于消息的部分接口\n* 关于支付的\n* 关于朋友圈的\n* 关于标签的\n* 关于收藏的\n\n* 暂时只写了一部分接口\n\n\n## 已完工接口\n\n1. 获取普通token\n2. 登录二维码（只是返回数据，暂时还未打印二维码）\n3. 获取登录状态\n4. 唤醒登录\n5. 退出登录\n6. 获取用户信息\n7. 获取用户二维码\n8. 上传用户头像\n9. 获取设备信息\n10. 发送文本消息\n11. 发送图片消息\n12. 发送语音消息\n13. 发送app消息\n14. 发送emoji消息\n15. 发送名片消息\n16. 撤回消息\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/__init__.py",
    "content": "from .client import WeChatPadClient as WeChatPadClient\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/api/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/libs/wechatpad_api/api/chatroom.py",
    "content": "from langbot.libs.wechatpad_api.util.http_util import post_json\n\n\nclass ChatRoomApi:\n    def __init__(self, base_url, token):\n        self.base_url = base_url\n        self.token = token\n\n    def get_chatroom_member_detail(self, chatroom_name):\n        params = {'ChatRoomName': chatroom_name}\n        url = self.base_url + '/group/GetChatroomMemberDetail'\n        return post_json(url, token=self.token, data=params)\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/api/downloadpai.py",
    "content": "from langbot.libs.wechatpad_api.util.http_util import post_json\nimport httpx\nimport base64\n\n\nclass DownloadApi:\n    def __init__(self, base_url, token):\n        self.base_url = base_url\n        self.token = token\n\n    def send_download(self, aeskey, file_type, file_url):\n        json_data = {'AesKey': aeskey, 'FileType': file_type, 'FileURL': file_url}\n        url = self.base_url + '/message/SendCdnDownload'\n        return post_json(url, token=self.token, data=json_data)\n\n    def get_msg_voice(self, buf_id, length, new_msgid):\n        json_data = {'Bufid': buf_id, 'Length': length, 'NewMsgId': new_msgid, 'ToUserName': ''}\n        url = self.base_url + '/message/GetMsgVoice'\n        return post_json(url, token=self.token, data=json_data)\n\n    async def download_url_to_base64(self, download_url):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(download_url)\n\n            if response.status_code == 200:\n                file_bytes = response.content\n                base64_str = base64.b64encode(file_bytes).decode('utf-8')  # 返回字符串格式\n                return base64_str\n            else:\n                raise Exception('获取文件失败')\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/api/friend.py",
    "content": "class FriendApi:\n    \"\"\"联系人API类，处理所有与联系人相关的操作\"\"\"\n\n    def __init__(self, base_url: str, token: str):\n        self.base_url = base_url\n        self.token = token\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/api/login.py",
    "content": "from langbot.libs.wechatpad_api.util.http_util import post_json, get_json\n\n\nclass LoginApi:\n    def __init__(self, base_url: str, token: str = None, admin_key: str = None):\n        \"\"\"\n\n        Args:\n            base_url: 原始路径\n            token: token\n            admin_key: 管理员key\n        \"\"\"\n        self.base_url = base_url\n        self.token = token\n        # self.admin_key = admin_key\n\n    def get_token(self, admin_key, day: int = 365):\n        # 获取普通token\n        url = f'{self.base_url}/admin/GenAuthKey1'\n        json_data = {'Count': 1, 'Days': day}\n        return post_json(base_url=url, token=admin_key, data=json_data)\n\n    def get_login_qr(self, Proxy: str = ''):\n        \"\"\"\n\n        Args:\n            Proxy:异地使用时代理\n\n        Returns:json数据\n\n        \"\"\"\n        \"\"\"\n        \n        {\n  \"Code\": 200,\n  \"Data\": {\n    \"Key\": \"3141312\",\n    \"QrCodeUrl\": \"https://1231x/g6bMlv2dX8zwNbqE6-Zs\",\n    \"Txt\": \"建议返回data=之后内容自定义生成二维码\",\n    \"baseResp\": {\n      \"ret\": 0,\n      \"errMsg\": {}\n    }\n  },\n  \"Text\": \"\"\n}\n        \n        \"\"\"\n        # 获取登录二维码\n        url = f'{self.base_url}/login/GetLoginQrCodeNew'\n        check = False\n        if Proxy != '':\n            check = True\n        json_data = {'Check': check, 'Proxy': Proxy}\n        return post_json(base_url=url, token=self.token, data=json_data)\n\n    def get_login_status(self):\n        # 获取登录状态\n        url = f'{self.base_url}/login/GetLoginStatus'\n        return get_json(base_url=url, token=self.token)\n\n    def logout(self):\n        # 退出登录\n        url = f'{self.base_url}/login/LogOut'\n        return post_json(base_url=url, token=self.token)\n\n    def wake_up_login(self, Proxy: str = ''):\n        # 唤醒登录\n        url = f'{self.base_url}/login/WakeUpLogin'\n        check = False\n        if Proxy != '':\n            check = True\n        json_data = {'Check': check, 'Proxy': ''}\n\n        return post_json(base_url=url, token=self.token, data=json_data)\n\n    def login(self, admin_key):\n        login_status = self.get_login_status()\n        if login_status['Code'] == 300 and login_status['Text'] == '你已退出微信':\n            print('token已经失效，重新获取')\n            token_data = self.get_token(admin_key)\n            self.token = token_data['Data'][0]\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/api/message.py",
    "content": "from langbot.libs.wechatpad_api.util.http_util import post_json\n\n\nclass MessageApi:\n    def __init__(self, base_url, token):\n        self.base_url = base_url\n        self.token = token\n\n    def post_text(self, to_wxid, content, ats: list = []):\n        \"\"\"\n\n        Args:\n            app_id: 微信id\n            to_wxid: 发送方的微信id\n            content: 内容\n            ats: at\n\n        Returns:\n\n        \"\"\"\n        url = self.base_url + '/message/SendTextMessage'\n        \"\"\"发送文字消息\"\"\"\n        json_data = {\n            'MsgItem': [\n                {'AtWxIDList': ats, 'ImageContent': '', 'MsgType': 0, 'TextContent': content, 'ToUserName': to_wxid}\n            ]\n        }\n        return post_json(base_url=url, token=self.token, data=json_data)\n\n    def post_image(self, to_wxid, img_url, ats: list = []):\n        \"\"\"发送图片消息\"\"\"\n        # 这里好像可以尝试发送多个暂时未测试\n        json_data = {\n            'MsgItem': [\n                {'AtWxIDList': ats, 'ImageContent': img_url, 'MsgType': 0, 'TextContent': '', 'ToUserName': to_wxid}\n            ]\n        }\n        url = self.base_url + '/message/SendImageMessage'\n        return post_json(base_url=url, token=self.token, data=json_data)\n\n    def post_voice(self, to_wxid, voice_data, voice_forma, voice_duration):\n        \"\"\"发送语音消息\"\"\"\n        json_data = {\n            'ToUserName': to_wxid,\n            'VoiceData': voice_data,\n            'VoiceFormat': voice_forma,\n            'VoiceSecond': voice_duration,\n        }\n        url = self.base_url + '/message/SendVoice'\n        return post_json(base_url=url, token=self.token, data=json_data)\n\n    def post_name_card(self, alias, to_wxid, nick_name, name_card_wxid, flag):\n        \"\"\"发送名片消息\"\"\"\n        param = {\n            'CardAlias': alias,\n            'CardFlag': flag,\n            'CardNickName': nick_name,\n            'CardWxId': name_card_wxid,\n            'ToUserName': to_wxid,\n        }\n        url = f'{self.base_url}/message/ShareCardMessage'\n        return post_json(base_url=url, token=self.token, data=param)\n\n    def post_emoji(self, to_wxid, emoji_md5, emoji_size: int = 0):\n        \"\"\"发送emoji消息\"\"\"\n        json_data = {'EmojiList': [{'EmojiMd5': emoji_md5, 'EmojiSize': emoji_size, 'ToUserName': to_wxid}]}\n        url = f'{self.base_url}/message/SendEmojiMessage'\n        return post_json(base_url=url, token=self.token, data=json_data)\n\n    def post_app_msg(self, to_wxid, xml_data, contenttype: int = 0):\n        \"\"\"发送appmsg消息\"\"\"\n        json_data = {'AppList': [{'ContentType': contenttype, 'ContentXML': xml_data, 'ToUserName': to_wxid}]}\n        url = f'{self.base_url}/message/SendAppMessage'\n        return post_json(base_url=url, token=self.token, data=json_data)\n\n    def revoke_msg(self, to_wxid, msg_id, new_msg_id, create_time):\n        \"\"\"撤回消息\"\"\"\n        param = {'ClientMsgId': msg_id, 'CreateTime': create_time, 'NewMsgId': new_msg_id, 'ToUserName': to_wxid}\n        url = f'{self.base_url}/message/RevokeMsg'\n        return post_json(base_url=url, token=self.token, data=param)\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/api/user.py",
    "content": "from langbot.libs.wechatpad_api.util.http_util import post_json, async_request, get_json\n\n\nclass UserApi:\n    def __init__(self, base_url, token):\n        self.base_url = base_url\n        self.token = token\n\n    def get_profile(self):\n        \"\"\"获取个人资料\"\"\"\n        url = f'{self.base_url}/user/GetProfile'\n\n        return get_json(base_url=url, token=self.token)\n\n    def get_qr_code(self, recover: bool = True, style: int = 8):\n        \"\"\"获取自己的二维码\"\"\"\n        param = {'Recover': recover, 'Style': style}\n        url = f'{self.base_url}/user/GetMyQRCode'\n        return post_json(base_url=url, token=self.token, data=param)\n\n    def get_safety_info(self):\n        \"\"\"获取设备记录\"\"\"\n        url = f'{self.base_url}/equipment/GetSafetyInfo'\n        return post_json(base_url=url, token=self.token)\n\n    async def update_head_img(self, head_img_base64):\n        \"\"\"修改头像\"\"\"\n        param = {'Base64': head_img_base64}\n        url = f'{self.base_url}/user/UploadHeadImage'\n        return await async_request(base_url=url, token_key=self.token, json=param)\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/client.py",
    "content": "from langbot.libs.wechatpad_api.api.login import LoginApi\nfrom langbot.libs.wechatpad_api.api.friend import FriendApi\nfrom langbot.libs.wechatpad_api.api.message import MessageApi\nfrom langbot.libs.wechatpad_api.api.user import UserApi\nfrom langbot.libs.wechatpad_api.api.downloadpai import DownloadApi\nfrom langbot.libs.wechatpad_api.api.chatroom import ChatRoomApi\n\n\nclass WeChatPadClient:\n    def __init__(self, base_url, token, logger=None):\n        self._login_api = LoginApi(base_url, token)\n        self._friend_api = FriendApi(base_url, token)\n        self._message_api = MessageApi(base_url, token)\n        self._user_api = UserApi(base_url, token)\n        self._download_api = DownloadApi(base_url, token)\n        self._chatroom_api = ChatRoomApi(base_url, token)\n        self.logger = logger\n\n    def get_token(self, admin_key, day: int):\n        \"\"\"获取token\"\"\"\n        return self._login_api.get_token(admin_key, day)\n\n    def get_login_qr(self, Proxy: str = ''):\n        \"\"\"登录二维码\"\"\"\n        return self._login_api.get_login_qr(Proxy=Proxy)\n\n    def awaken_login(self, Proxy: str = ''):\n        \"\"\"唤醒登录\"\"\"\n        return self._login_api.wake_up_login(Proxy=Proxy)\n\n    def log_out(self):\n        \"\"\"退出登录\"\"\"\n        return self._login_api.logout()\n\n    def get_login_status(self):\n        \"\"\"获取登录状态\"\"\"\n        return self._login_api.get_login_status()\n\n    def send_text_message(self, to_wxid, message, ats: list = []):\n        \"\"\"发送文本消息\"\"\"\n        return self._message_api.post_text(to_wxid, message, ats)\n\n    def send_image_message(self, to_wxid, img_url, ats: list = []):\n        \"\"\"发送图片消息\"\"\"\n        return self._message_api.post_image(to_wxid, img_url, ats)\n\n    def send_voice_message(self, to_wxid, voice_data, voice_forma, voice_duration):\n        \"\"\"发送音频消息\"\"\"\n        return self._message_api.post_voice(to_wxid, voice_data, voice_forma, voice_duration)\n\n    def send_app_message(self, to_wxid, app_message, type):\n        \"\"\"发送app消息\"\"\"\n        return self._message_api.post_app_msg(to_wxid, app_message, type)\n\n    def send_emoji_message(self, to_wxid, emoji_md5, emoji_size):\n        \"\"\"发送emoji消息\"\"\"\n        return self._message_api.post_emoji(to_wxid, emoji_md5, emoji_size)\n\n    def revoke_msg(self, to_wxid, msg_id, new_msg_id, create_time):\n        \"\"\"撤回消息\"\"\"\n        return self._message_api.revoke_msg(to_wxid, msg_id, new_msg_id, create_time)\n\n    def get_profile(self):\n        \"\"\"获取用户信息\"\"\"\n        return self._user_api.get_profile()\n\n    def get_qr_code(self, recover: bool = True, style: int = 8):\n        \"\"\"获取用户二维码\"\"\"\n        return self._user_api.get_qr_code(recover=recover, style=style)\n\n    def get_safety_info(self):\n        \"\"\"获取设备信息\"\"\"\n        return self._user_api.get_safety_info()\n\n    def update_head_img(self, head_img_base64):\n        \"\"\"上传用户头像\"\"\"\n        return self._user_api.update_head_img(head_img_base64)\n\n    def cdn_download(self, aeskey, file_type, file_url):\n        \"\"\"cdn下载\"\"\"\n        return self._download_api.send_download(aeskey, file_type, file_url)\n\n    def get_msg_voice(self, buf_id, length, msgid):\n        \"\"\"下载语音\"\"\"\n        return self._download_api.get_msg_voice(buf_id, length, msgid)\n\n    async def download_base64(self, url):\n        return await self._download_api.download_url_to_base64(download_url=url)\n\n    def get_chatroom_member_detail(self, chatroom_name):\n        \"\"\"查看群成员详情\"\"\"\n        return self._chatroom_api.get_chatroom_member_detail(chatroom_name)\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/util/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/libs/wechatpad_api/util/http_util.py",
    "content": "import requests\nfrom langbot.pkg.utils import httpclient\n\n\ndef post_json(base_url, token, data=None):\n    headers = {'Content-Type': 'application/json'}\n\n    url = base_url + f'?key={token}'\n\n    try:\n        response = requests.post(url, json=data, headers=headers, timeout=60)\n        response.raise_for_status()\n        result = response.json()\n\n        if result:\n            return result\n        else:\n            raise RuntimeError(response.text)\n    except Exception as e:\n        print(f'http请求失败, url={url}, exception={e}')\n        raise RuntimeError(str(e))\n\n\ndef get_json(base_url, token):\n    headers = {'Content-Type': 'application/json'}\n\n    url = base_url + f'?key={token}'\n\n    try:\n        response = requests.get(url, headers=headers, timeout=60)\n        response.raise_for_status()\n        result = response.json()\n\n        if result:\n            return result\n        else:\n            raise RuntimeError(response.text)\n    except Exception as e:\n        print(f'http请求失败, url={url}, exception={e}')\n        raise RuntimeError(str(e))\n\n\nasync def async_request(\n    base_url: str,\n    token_key: str,\n    method: str = 'POST',\n    params: dict = None,\n    # headers: dict = None,\n    data: dict = None,\n    json: dict = None,\n):\n    \"\"\"\n    通用异步请求函数\n\n    :param base_url: 请求URL\n    :param token_key: 请求token\n    :param method: HTTP方法 (GET, POST, PUT, DELETE等)\n    :param params: URL查询参数\n    # :param headers: 请求头\n    :param data: 表单数据\n    :param json: JSON数据\n    :return: 响应文本\n    \"\"\"\n    headers = {'Content-Type': 'application/json'}\n    url = f'{base_url}?key={token_key}'\n    session = httpclient.get_session()\n    async with session.request(\n        method=method, url=url, params=params, headers=headers, data=data, json=json\n    ) as response:\n        response.raise_for_status()  # 如果状态码不是200，抛出异常\n        result = await response.json()\n        # print(result)\n        return result\n        # if result.get('Code') == 200:\n        #\n        #     return await result\n        # else:\n        #     raise RuntimeError(\"请求失败\",response.text)\n"
  },
  {
    "path": "src/langbot/libs/wechatpad_api/util/terminal_printer.py",
    "content": "import qrcode\n\n\ndef print_green(text):\n    print(f'\\033[32m{text}\\033[0m')\n\n\ndef print_yellow(text):\n    print(f'\\033[33m{text}\\033[0m')\n\n\ndef print_red(text):\n    print(f'\\033[31m{text}\\033[0m')\n\n\ndef make_and_print_qr(url):\n    \"\"\"生成并打印二维码\n\n    Args:\n        url: 需要生成二维码的URL字符串\n\n    Returns:\n        None\n\n    功能:\n        1. 在终端打印二维码的ASCII图形\n        2. 同时提供在线二维码生成链接作为备选\n    \"\"\"\n    print_green('请扫描下方二维码登录')\n    qr = qrcode.QRCode()\n    qr.add_data(url)\n    qr.make()\n    qr.print_ascii(invert=True)\n    print_green(f'也可以访问下方链接获取二维码:\\nhttps://api.qrserver.com/v1/create-qr-code/?data={url}')\n"
  },
  {
    "path": "src/langbot/libs/wecom_ai_bot_api/WXBizMsgCrypt3.py",
    "content": "#!/usr/bin/env python\n# -*- encoding:utf-8 -*-\n\n\"\"\"对企业微信发送给企业后台的消息加解密示例代码.\n@copyright: Copyright (c) 1998-2014 Tencent Inc.\n\n\"\"\"\n\n# ------------------------------------------------------------------------\nimport logging\nimport base64\nimport random\nimport hashlib\nimport time\nimport struct\nfrom Crypto.Cipher import AES\nimport xml.etree.cElementTree as ET\nimport socket\nfrom langbot.libs.wecom_ai_bot_api import ierror\n\n\n\"\"\"\nCrypto.Cipher包已不再维护，开发者可以通过以下命令下载安装最新版的加解密工具包\n    pip install pycryptodome\n\"\"\"\n\n\nclass FormatException(Exception):\n    pass\n\n\ndef throw_exception(message, exception_class=FormatException):\n    \"\"\"my define raise exception function\"\"\"\n    raise exception_class(message)\n\n\nclass SHA1:\n    \"\"\"计算企业微信的消息签名接口\"\"\"\n\n    def getSHA1(self, token, timestamp, nonce, encrypt):\n        \"\"\"用SHA1算法生成安全签名\n        @param token:  票据\n        @param timestamp: 时间戳\n        @param encrypt: 密文\n        @param nonce: 随机字符串\n        @return: 安全签名\n        \"\"\"\n        try:\n            sortlist = [token, timestamp, nonce, encrypt]\n            sortlist.sort()\n            sha = hashlib.sha1()\n            sha.update(''.join(sortlist).encode())\n            return ierror.WXBizMsgCrypt_OK, sha.hexdigest()\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return ierror.WXBizMsgCrypt_ComputeSignature_Error, None\n\n\nclass XMLParse:\n    \"\"\"提供提取消息格式中的密文及生成回复消息格式的接口\"\"\"\n\n    # xml消息模板\n    AES_TEXT_RESPONSE_TEMPLATE = \"\"\"<xml>\n<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>\n<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>\n<TimeStamp>%(timestamp)s</TimeStamp>\n<Nonce><![CDATA[%(nonce)s]]></Nonce>\n</xml>\"\"\"\n\n    def extract(self, xmltext):\n        \"\"\"提取出xml数据包中的加密消息\n        @param xmltext: 待提取的xml字符串\n        @return: 提取出的加密消息字符串\n        \"\"\"\n        try:\n            xml_tree = ET.fromstring(xmltext)\n            encrypt = xml_tree.find('Encrypt')\n            return ierror.WXBizMsgCrypt_OK, encrypt.text\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return ierror.WXBizMsgCrypt_ParseXml_Error, None\n\n    def generate(self, encrypt, signature, timestamp, nonce):\n        \"\"\"生成xml消息\n        @param encrypt: 加密后的消息密文\n        @param signature: 安全签名\n        @param timestamp: 时间戳\n        @param nonce: 随机字符串\n        @return: 生成的xml字符串\n        \"\"\"\n        resp_dict = {\n            'msg_encrypt': encrypt,\n            'msg_signaturet': signature,\n            'timestamp': timestamp,\n            'nonce': nonce,\n        }\n        resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict\n        return resp_xml\n\n\nclass PKCS7Encoder:\n    \"\"\"提供基于PKCS7算法的加解密接口\"\"\"\n\n    block_size = 32\n\n    def encode(self, text):\n        \"\"\"对需要加密的明文进行填充补位\n        @param text: 需要进行填充补位操作的明文\n        @return: 补齐明文字符串\n        \"\"\"\n        text_length = len(text)\n        # 计算需要填充的位数\n        amount_to_pad = self.block_size - (text_length % self.block_size)\n        if amount_to_pad == 0:\n            amount_to_pad = self.block_size\n        # 获得补位所用的字符\n        pad = chr(amount_to_pad)\n        return text + (pad * amount_to_pad).encode()\n\n    def decode(self, decrypted):\n        \"\"\"删除解密后明文的补位字符\n        @param decrypted: 解密后的明文\n        @return: 删除补位字符后的明文\n        \"\"\"\n        pad = ord(decrypted[-1])\n        if pad < 1 or pad > 32:\n            pad = 0\n        return decrypted[:-pad]\n\n\nclass Prpcrypt(object):\n    \"\"\"提供接收和推送给企业微信消息的加解密接口\"\"\"\n\n    def __init__(self, key):\n        # self.key = base64.b64decode(key+\"=\")\n        self.key = key\n        # 设置加解密模式为AES的CBC模式\n        self.mode = AES.MODE_CBC\n\n    def encrypt(self, text, receiveid):\n        \"\"\"对明文进行加密\n        @param text: 需要加密的明文\n        @return: 加密得到的字符串\n        \"\"\"\n        # 16位随机字符串添加到明文开头\n        text = text.encode()\n        text = self.get_random_str() + struct.pack('I', socket.htonl(len(text))) + text + receiveid.encode()\n\n        # 使用自定义的填充方式对明文进行补位填充\n        pkcs7 = PKCS7Encoder()\n        text = pkcs7.encode(text)\n        # 加密\n        cryptor = AES.new(self.key, self.mode, self.key[:16])\n        try:\n            ciphertext = cryptor.encrypt(text)\n            # 使用BASE64对加密后的字符串进行编码\n            return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return ierror.WXBizMsgCrypt_EncryptAES_Error, None\n\n    def decrypt(self, text, receiveid):\n        \"\"\"对解密后的明文进行补位删除\n        @param text: 密文\n        @return: 删除填充补位后的明文\n        \"\"\"\n        try:\n            cryptor = AES.new(self.key, self.mode, self.key[:16])\n            # 使用BASE64对密文进行解码，然后AES-CBC解密\n            plain_text = cryptor.decrypt(base64.b64decode(text))\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return ierror.WXBizMsgCrypt_DecryptAES_Error, None\n        try:\n            pad = plain_text[-1]\n            # 去掉补位字符串\n            # pkcs7 = PKCS7Encoder()\n            # plain_text = pkcs7.encode(plain_text)\n            # 去除16位随机字符串\n            content = plain_text[16:-pad]\n            xml_len = socket.ntohl(struct.unpack('I', content[:4])[0])\n            xml_content = content[4 : xml_len + 4]\n            from_receiveid = content[xml_len + 4 :]\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return ierror.WXBizMsgCrypt_IllegalBuffer, None\n\n        if from_receiveid.decode('utf8') != receiveid:\n            return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None\n        return 0, xml_content\n\n    def get_random_str(self):\n        \"\"\"随机生成16位字符串\n        @return: 16位字符串\n        \"\"\"\n        return str(random.randint(1000000000000000, 9999999999999999)).encode()\n\n\nclass WXBizMsgCrypt(object):\n    # 构造函数\n    def __init__(self, sToken, sEncodingAESKey, sReceiveId):\n        try:\n            self.key = base64.b64decode(sEncodingAESKey + '=')\n            assert len(self.key) == 32\n        except Exception:\n            throw_exception('[error]: EncodingAESKey unvalid !', FormatException)\n            # return ierror.WXBizMsgCrypt_IllegalAesKey,None\n        self.m_sToken = sToken\n        self.m_sReceiveId = sReceiveId\n\n        # 验证URL\n        # @param sMsgSignature: 签名串，对应URL参数的msg_signature\n        # @param sTimeStamp: 时间戳，对应URL参数的timestamp\n        # @param sNonce: 随机串，对应URL参数的nonce\n        # @param sEchoStr: 随机串，对应URL参数的echostr\n        # @param sReplyEchoStr: 解密之后的echostr，当return返回0时有效\n        # @return：成功0，失败返回对应的错误码\n\n    def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):\n        sha1 = SHA1()\n        ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)\n        if ret != 0:\n            return ret, None\n        if not signature == sMsgSignature:\n            return ierror.WXBizMsgCrypt_ValidateSignature_Error, None\n        pc = Prpcrypt(self.key)\n        ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId)\n        return ret, sReplyEchoStr\n\n    def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):\n        # 将企业回复用户的消息加密打包\n        # @param sReplyMsg: 企业号待回复用户的消息，xml格式的字符串\n        # @param sTimeStamp: 时间戳，可以自己生成，也可以用URL参数的timestamp,如为None则自动用当前时间\n        # @param sNonce: 随机串，可以自己生成，也可以用URL参数的nonce\n        # sEncryptMsg: 加密后的可以直接回复用户的密文，包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,\n        # return：成功0，sEncryptMsg,失败返回对应的错误码None\n        pc = Prpcrypt(self.key)\n        ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)\n        encrypt = encrypt.decode('utf8')\n        if ret != 0:\n            return ret, None\n        if timestamp is None:\n            timestamp = str(int(time.time()))\n        # 生成安全签名\n        sha1 = SHA1()\n        ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)\n        if ret != 0:\n            return ret, None\n        xmlParse = XMLParse()\n        return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce)\n\n    def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):\n        # 检验消息的真实性，并且获取解密后的明文\n        # @param sMsgSignature: 签名串，对应URL参数的msg_signature\n        # @param sTimeStamp: 时间戳，对应URL参数的timestamp\n        # @param sNonce: 随机串，对应URL参数的nonce\n        # @param sPostData: 密文，对应POST请求的数据\n        #  xml_content: 解密后的原文，当return返回0时有效\n        # @return: 成功0，失败返回对应的错误码\n        # 验证安全签名\n        xmlParse = XMLParse()\n        ret, encrypt = xmlParse.extract(sPostData)\n        if ret != 0:\n            return ret, None\n        sha1 = SHA1()\n        ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)\n        if ret != 0:\n            return ret, None\n        if not signature == sMsgSignature:\n            return ierror.WXBizMsgCrypt_ValidateSignature_Error, None\n        pc = Prpcrypt(self.key)\n        ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId)\n        return ret, xml_content\n"
  },
  {
    "path": "src/langbot/libs/wecom_ai_bot_api/api.py",
    "content": "import asyncio\nimport base64\nimport json\nimport time\nimport traceback\nimport uuid\nimport xml.etree.ElementTree as ET\nfrom dataclasses import dataclass, field\nfrom typing import Any, Callable, Optional\nfrom urllib.parse import unquote\n\nimport httpx\nfrom Crypto.Cipher import AES\nfrom quart import Quart, request, Response, jsonify\n\nfrom langbot.libs.wecom_ai_bot_api import wecombotevent\nfrom langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt\nfrom langbot.pkg.platform.logger import EventLogger\n\n\n@dataclass\nclass StreamChunk:\n    \"\"\"描述单次推送给企业微信的流式片段。\"\"\"\n\n    # 需要返回给企业微信的文本内容\n    content: str\n\n    # 标记是否为最终片段，对应企业微信协议里的 finish 字段\n    is_final: bool = False\n\n    # 预留额外元信息，未来支持多模态扩展时可使用\n    meta: dict[str, Any] = field(default_factory=dict)\n\n\n@dataclass\nclass StreamSession:\n    \"\"\"维护一次企业微信流式会话的上下文。\"\"\"\n\n    # 企业微信要求的 stream_id，用于标识后续刷新请求\n    stream_id: str\n\n    # 原始消息的 msgid，便于与流水线消息对应\n    msg_id: str\n\n    # 群聊会话标识（单聊时为空）\n    chat_id: Optional[str]\n\n    # 触发消息的发送者\n    user_id: Optional[str]\n\n    # 会话创建时间\n    created_at: float = field(default_factory=time.time)\n\n    # 最近一次被访问的时间，cleanup 依据该值判断过期\n    last_access: float = field(default_factory=time.time)\n\n    # 将流水线增量结果缓存到队列，刷新请求逐条消费\n    queue: asyncio.Queue = field(default_factory=asyncio.Queue)\n\n    # 是否已经完成（收到最终片段）\n    finished: bool = False\n\n    # 缓存最近一次片段，处理重试或超时兜底\n    last_chunk: Optional[StreamChunk] = None\n\n\nclass StreamSessionManager:\n    \"\"\"管理 stream 会话的生命周期，并负责队列的生产消费。\"\"\"\n\n    def __init__(self, logger: EventLogger, ttl: int = 60) -> None:\n        self.logger = logger\n\n        self.ttl = ttl  # 超时时间（秒），超过该时间未被访问的会话会被清理由 cleanup\n        self._sessions: dict[str, StreamSession] = {}  # stream_id -> StreamSession 映射\n        self._msg_index: dict[str, str] = {}  # msgid -> stream_id 映射，便于流水线根据消息 ID 找到会话\n\n    def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:\n        if not msg_id:\n            return None\n        return self._msg_index.get(msg_id)\n\n    def get_session(self, stream_id: str) -> Optional[StreamSession]:\n        return self._sessions.get(stream_id)\n\n    def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:\n        \"\"\"根据企业微信回调创建或获取会话。\n\n        Args:\n            msg_json: 企业微信解密后的回调 JSON。\n\n        Returns:\n            Tuple[StreamSession, bool]: `StreamSession` 为会话实例，`bool` 指示是否为新建会话。\n\n        Example:\n            在首次回调中调用，得到 `is_new=True` 后再触发流水线。\n        \"\"\"\n        msg_id = msg_json.get('msgid', '')\n        if msg_id and msg_id in self._msg_index:\n            stream_id = self._msg_index[msg_id]\n            session = self._sessions.get(stream_id)\n            if session:\n                session.last_access = time.time()\n                return session, False\n\n        stream_id = str(uuid.uuid4())\n        session = StreamSession(\n            stream_id=stream_id,\n            msg_id=msg_id,\n            chat_id=msg_json.get('chatid'),\n            user_id=msg_json.get('from', {}).get('userid'),\n        )\n\n        if msg_id:\n            self._msg_index[msg_id] = stream_id\n        self._sessions[stream_id] = session\n        return session, True\n\n    async def publish(self, stream_id: str, chunk: StreamChunk) -> bool:\n        \"\"\"向 stream 队列写入新的增量片段。\n\n        Args:\n            stream_id: 企业微信分配的流式会话 ID。\n            chunk: 待发送的增量片段。\n\n        Returns:\n            bool: 当流式队列存在并成功入队时返回 True。\n\n        Example:\n            在收到模型增量后调用 `await manager.publish('sid', StreamChunk('hello'))`。\n        \"\"\"\n        session = self._sessions.get(stream_id)\n        if not session:\n            return False\n\n        session.last_access = time.time()\n        session.last_chunk = chunk\n\n        try:\n            session.queue.put_nowait(chunk)\n        except asyncio.QueueFull:\n            # 默认无界队列，此处兜底防御\n            await session.queue.put(chunk)\n\n        if chunk.is_final:\n            session.finished = True\n\n        return True\n\n    async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[StreamChunk]:\n        \"\"\"从队列中取出一个片段，若超时返回 None。\n\n        Args:\n            stream_id: 企业微信流式会话 ID。\n            timeout: 取片段的最长等待时间（秒）。\n\n        Returns:\n            Optional[StreamChunk]: 成功时返回片段，超时或会话不存在时返回 None。\n\n        Example:\n            企业微信刷新到达时调用，若队列有数据则立即返回 `StreamChunk`。\n        \"\"\"\n        session = self._sessions.get(stream_id)\n        if not session:\n            return None\n\n        session.last_access = time.time()\n\n        try:\n            chunk = await asyncio.wait_for(session.queue.get(), timeout)\n            session.last_access = time.time()\n            if chunk.is_final:\n                session.finished = True\n            return chunk\n        except asyncio.TimeoutError:\n            if session.finished and session.last_chunk:\n                return session.last_chunk\n            return None\n\n    def mark_finished(self, stream_id: str) -> None:\n        session = self._sessions.get(stream_id)\n        if session:\n            session.finished = True\n            session.last_access = time.time()\n\n    def cleanup(self) -> None:\n        \"\"\"定期清理过期会话，防止队列与映射无上限累积。\"\"\"\n        now = time.time()\n        expired: list[str] = []\n        for stream_id, session in self._sessions.items():\n            if now - session.last_access > self.ttl:\n                expired.append(stream_id)\n\n        for stream_id in expired:\n            session = self._sessions.pop(stream_id, None)\n            if not session:\n                continue\n            msg_id = session.msg_id\n            if msg_id and self._msg_index.get(msg_id) == stream_id:\n                self._msg_index.pop(msg_id, None)\n\n\nasync def download_encrypted_file(download_url: str, encoding_aes_key: str, logger: EventLogger) -> Optional[str]:\n    \"\"\"Download an AES-encrypted file from WeChat Work and return as data URI.\n\n    Args:\n        download_url: The encrypted file download URL.\n        encoding_aes_key: The AES key used for decryption (base64-encoded, without trailing '=').\n        logger: Logger instance.\n\n    Returns:\n        A data URI string (e.g. 'data:image/jpeg;base64,...') or None on failure.\n    \"\"\"\n    if not download_url:\n        return None\n    async with httpx.AsyncClient() as client:\n        response = await client.get(download_url)\n        if response.status_code != 200:\n            await logger.error(f'failed to get file: {response.text}')\n            return None\n        encrypted_bytes = response.content\n\n    aes_key = base64.b64decode(encoding_aes_key + '=')\n    iv = aes_key[:16]\n\n    cipher = AES.new(aes_key, AES.MODE_CBC, iv)\n    decrypted = cipher.decrypt(encrypted_bytes)\n\n    pad_len = decrypted[-1]\n    decrypted = decrypted[:-pad_len]\n\n    if decrypted.startswith(b'\\xff\\xd8'):\n        mime_type = 'image/jpeg'\n    elif decrypted.startswith(b'\\x89PNG'):\n        mime_type = 'image/png'\n    elif decrypted.startswith((b'GIF87a', b'GIF89a')):\n        mime_type = 'image/gif'\n    elif decrypted.startswith(b'BM'):\n        mime_type = 'image/bmp'\n    elif decrypted.startswith(b'II*\\x00') or decrypted.startswith(b'MM\\x00*'):\n        mime_type = 'image/tiff'\n    else:\n        mime_type = 'application/octet-stream'\n\n    base64_str = base64.b64encode(decrypted).decode('utf-8')\n    return f'data:{mime_type};base64,{base64_str}'\n\n\nasync def parse_wecom_bot_message(\n    msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger\n) -> dict[str, Any]:\n    \"\"\"Parse a decrypted WeChat Work AI Bot message JSON into a unified message dict.\n\n    This is the shared message parsing logic used by both webhook and WebSocket modes.\n\n    Args:\n        msg_json: The decrypted message JSON from WeChat Work.\n        encoding_aes_key: AES key for file decryption.\n        logger: Logger instance.\n\n    Returns:\n        A dict suitable for constructing a WecomBotEvent.\n    \"\"\"\n    message_data: dict[str, Any] = {}\n\n    msg_type = msg_json.get('msgtype', '')\n    if msg_type:\n        message_data['msgtype'] = msg_type\n\n    if msg_json.get('chattype', '') == 'single':\n        message_data['type'] = 'single'\n    elif msg_json.get('chattype', '') == 'group':\n        message_data['type'] = 'group'\n\n    max_inline_file_size = 5 * 1024 * 1024\n\n    async def _safe_download(url: str):\n        if not url:\n            return None\n        return await download_encrypted_file(url, encoding_aes_key, logger)\n\n    if msg_type == 'text':\n        message_data['content'] = msg_json.get('text', {}).get('content')\n    elif msg_type == 'markdown':\n        message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(\n            'content', ''\n        )\n    elif msg_type == 'image':\n        picurl = msg_json.get('image', {}).get('url', '')\n        base64_data = await _safe_download(picurl)\n        if base64_data:\n            message_data['picurl'] = base64_data\n            message_data['images'] = [base64_data]\n    elif msg_type == 'voice':\n        voice_info = msg_json.get('voice', {}) or {}\n        download_url = voice_info.get('url')\n        message_data['voice'] = {\n            'url': download_url,\n            'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),\n            'filesize': voice_info.get('filesize') or voice_info.get('size'),\n            'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),\n        }\n        if voice_info.get('content'):\n            message_data['content'] = voice_info.get('content')\n        if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:\n            voice_base64 = await _safe_download(download_url)\n            if voice_base64:\n                message_data['voice']['base64'] = voice_base64\n    elif msg_type == 'video':\n        video_info = msg_json.get('video', {}) or {}\n        download_url = video_info.get('url')\n        video_data = {\n            'url': download_url,\n            'filesize': video_info.get('filesize') or video_info.get('size'),\n            'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),\n            'md5sum': video_info.get('md5sum') or video_info.get('md5'),\n            'filename': video_info.get('filename') or video_info.get('name'),\n        }\n        if (video_data.get('filesize') or 0) <= max_inline_file_size:\n            video_base64 = await _safe_download(download_url)\n            if video_base64:\n                video_data['base64'] = video_base64\n        message_data['video'] = video_data\n    elif msg_type == 'file':\n        file_info = msg_json.get('file', {}) or {}\n        download_url = file_info.get('url') or file_info.get('fileurl')\n        file_data = {\n            'filename': file_info.get('filename') or file_info.get('name'),\n            'filesize': file_info.get('filesize') or file_info.get('size'),\n            'md5sum': file_info.get('md5sum') or file_info.get('md5'),\n            'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),\n            'download_url': download_url,\n            'extra': file_info,\n        }\n        if (file_data.get('filesize') or 0) <= max_inline_file_size:\n            file_base64 = await _safe_download(download_url)\n            if file_base64:\n                file_data['base64'] = file_base64\n        message_data['file'] = file_data\n    elif msg_type == 'link':\n        message_data['link'] = msg_json.get('link', {})\n        if not message_data.get('content'):\n            title = message_data['link'].get('title', '')\n            desc = message_data['link'].get('description') or message_data['link'].get('digest', '')\n            message_data['content'] = '\\n'.join(filter(None, [title, desc]))\n    elif msg_type == 'mixed':\n        items = msg_json.get('mixed', {}).get('msg_item', [])\n        texts = []\n        images = []\n        files = []\n        voices = []\n        videos = []\n        links = []\n        for item in items:\n            item_type = item.get('msgtype')\n            if item_type == 'text':\n                texts.append(item.get('text', {}).get('content', ''))\n            elif item_type == 'image':\n                img_url = item.get('image', {}).get('url')\n                base64_data = await _safe_download(img_url)\n                if base64_data:\n                    images.append(base64_data)\n            elif item_type == 'file':\n                file_info = item.get('file', {}) or {}\n                download_url = file_info.get('url') or file_info.get('fileurl')\n                file_data = {\n                    'filename': file_info.get('filename') or file_info.get('name'),\n                    'filesize': file_info.get('filesize') or file_info.get('size'),\n                    'md5sum': file_info.get('md5sum') or file_info.get('md5'),\n                    'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),\n                    'download_url': download_url,\n                    'extra': file_info,\n                }\n                if (file_data.get('filesize') or 0) <= max_inline_file_size:\n                    file_base64 = await _safe_download(download_url)\n                    if file_base64:\n                        file_data['base64'] = file_base64\n                files.append(file_data)\n            elif item_type == 'voice':\n                voice_info = item.get('voice', {}) or {}\n                download_url = voice_info.get('url')\n                voice_data = {\n                    'url': download_url,\n                    'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),\n                    'filesize': voice_info.get('filesize') or voice_info.get('size'),\n                    'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),\n                }\n                if voice_info.get('content'):\n                    texts.append(voice_info.get('content'))\n                if (voice_data.get('filesize') or 0) <= max_inline_file_size:\n                    voice_base64 = await _safe_download(download_url)\n                    if voice_base64:\n                        voice_data['base64'] = voice_base64\n                voices.append(voice_data)\n            elif item_type == 'video':\n                video_info = item.get('video', {}) or {}\n                download_url = video_info.get('url')\n                video_data = {\n                    'url': download_url,\n                    'filesize': video_info.get('filesize') or video_info.get('size'),\n                    'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),\n                    'md5sum': video_info.get('md5sum') or video_info.get('md5'),\n                    'filename': video_info.get('filename') or video_info.get('name'),\n                }\n                if (video_data.get('filesize') or 0) <= max_inline_file_size:\n                    video_base64 = await _safe_download(download_url)\n                    if video_base64:\n                        video_data['base64'] = video_base64\n                videos.append(video_data)\n            elif item_type == 'link':\n                links.append(item.get('link', {}))\n\n        if texts:\n            message_data['content'] = ' '.join(texts)\n        if images:\n            message_data['images'] = images\n            message_data['picurl'] = images[0]\n        if files:\n            message_data['files'] = files\n            message_data['file'] = files[0]\n        if voices:\n            message_data['voices'] = voices\n            message_data['voice'] = voices[0]\n        if videos:\n            message_data['videos'] = videos\n            message_data['video'] = videos[0]\n        if links:\n            message_data['link'] = links[0]\n        if items:\n            message_data['attachments'] = items\n    else:\n        message_data['raw_msg'] = msg_json\n\n    from_info = msg_json.get('from', {})\n    message_data['userid'] = from_info.get('userid', '')\n    message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')\n\n    if msg_json.get('chattype', '') == 'group':\n        message_data['chatid'] = msg_json.get('chatid', '')\n        message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')\n\n    message_data['msgid'] = msg_json.get('msgid', '')\n\n    if msg_json.get('aibotid'):\n        message_data['aibotid'] = msg_json.get('aibotid', '')\n\n    return message_data\n\n\nclass WecomBotClient:\n    def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):\n        \"\"\"企业微信智能机器人客户端。\n\n        Args:\n            Token: 企业微信回调验证使用的 token。\n            EnCodingAESKey: 企业微信消息加解密密钥。\n            Corpid: 企业 ID。\n            logger: 日志记录器。\n            unified_mode: 是否使用统一 webhook 模式（默认 False）。\n\n        Example:\n            >>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger)\n        \"\"\"\n\n        self.Token = Token\n        self.EnCodingAESKey = EnCodingAESKey\n        self.Corpid = Corpid\n        self.ReceiveId = ''\n        self.unified_mode = unified_mode\n        self.app = Quart(__name__)\n\n        # 只有在非统一模式下才注册独立路由\n        if not self.unified_mode:\n            self.app.add_url_rule(\n                '/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET']\n            )\n\n        self._message_handlers = {\n            'example': [],\n        }\n        self.logger = logger\n        self.generated_content: dict[str, str] = {}\n        self.msg_id_map: dict[str, int] = {}\n        self.stream_sessions = StreamSessionManager(logger=logger)\n        self.stream_poll_timeout = 0.5\n\n    @staticmethod\n    def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:\n        \"\"\"按照企业微信协议拼装返回报文。\n\n        Args:\n            stream_id: 企业微信会话 ID。\n            content: 推送的文本内容。\n            finish: 是否为最终片段。\n\n        Returns:\n            dict[str, Any]: 可直接加密返回的 payload。\n\n        Example:\n            组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。\n        \"\"\"\n        return {\n            'msgtype': 'stream',\n            'stream': {\n                'id': stream_id,\n                'finish': finish,\n                'content': content,\n            },\n        }\n\n    async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:\n        \"\"\"对响应进行加密封装并返回给企业微信。\n\n        Args:\n            payload: 待加密的响应内容。\n            nonce: 企业微信回调参数中的 nonce。\n\n        Returns:\n            Tuple[Response, int]: Quart Response 对象及状态码。\n\n        Example:\n            在首包或刷新场景中调用以生成加密响应。\n        \"\"\"\n        reply_plain_str = json.dumps(payload, ensure_ascii=False)\n        reply_timestamp = str(int(time.time()))\n        ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp)\n        if ret != 0:\n            await self.logger.error(f'加密失败: {ret}')\n            return jsonify({'error': 'encrypt_failed'}), 500\n\n        root = ET.fromstring(encrypt_text)\n        encrypt = root.find('Encrypt').text\n        resp = {\n            'encrypt': encrypt,\n        }\n        return jsonify(resp), 200\n\n    async def _dispatch_event(self, event: wecombotevent.WecomBotEvent) -> None:\n        \"\"\"异步触发流水线处理，避免阻塞首包响应。\n\n        Args:\n            event: 由企业微信消息转换的内部事件对象。\n        \"\"\"\n        try:\n            await self._handle_message(event)\n        except Exception:\n            await self.logger.error(traceback.format_exc())\n\n    async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:\n        \"\"\"处理企业微信首次推送的消息，返回 stream_id 并开启流水线。\n\n        Args:\n            msg_json: 解密后的企业微信消息 JSON。\n            nonce: 企业微信回调参数 nonce。\n\n        Returns:\n            Tuple[Response, int]: Quart Response 及状态码。\n\n        Example:\n            首次回调时调用，立即返回带 `stream_id` 的响应。\n        \"\"\"\n        session, is_new = self.stream_sessions.create_or_get(msg_json)\n\n        message_data = await self.get_message(msg_json)\n        if message_data:\n            message_data['stream_id'] = session.stream_id\n            try:\n                event = wecombotevent.WecomBotEvent(message_data)\n            except Exception:\n                await self.logger.error(traceback.format_exc())\n            else:\n                if is_new:\n                    asyncio.create_task(self._dispatch_event(event))\n\n        payload = self._build_stream_payload(session.stream_id, '', False)\n        return await self._encrypt_and_reply(payload, nonce)\n\n    async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:\n        \"\"\"处理企业微信的流式刷新请求，按需返回增量片段。\n\n        Args:\n            msg_json: 解密后的企业微信刷新请求。\n            nonce: 企业微信回调参数 nonce。\n\n        Returns:\n            Tuple[Response, int]: Quart Response 及状态码。\n\n        Example:\n            在刷新请求中调用，按需返回增量片段。\n        \"\"\"\n        stream_info = msg_json.get('stream', {})\n        stream_id = stream_info.get('id', '')\n        if not stream_id:\n            await self.logger.error('刷新请求缺少 stream.id')\n            return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce)\n\n        session = self.stream_sessions.get_session(stream_id)\n        chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout)\n\n        if not chunk:\n            cached_content = None\n            if session and session.msg_id:\n                cached_content = self.generated_content.pop(session.msg_id, None)\n            if cached_content is not None:\n                chunk = StreamChunk(content=cached_content, is_final=True)\n            else:\n                payload = self._build_stream_payload(stream_id, '', False)\n                return await self._encrypt_and_reply(payload, nonce)\n\n        payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final)\n        if chunk.is_final:\n            self.stream_sessions.mark_finished(stream_id)\n        return await self._encrypt_and_reply(payload, nonce)\n\n    async def handle_callback_request(self):\n        \"\"\"企业微信回调入口（独立端口模式，使用全局 request）。\n\n        Returns:\n            Quart Response: 根据请求类型返回验证、首包或刷新结果。\n\n        Example:\n            作为 Quart 路由处理函数直接注册并使用。\n        \"\"\"\n        return await self._handle_callback_internal(request)\n\n    async def handle_unified_webhook(self, req):\n        \"\"\"处理回调请求（统一 webhook 模式，显式传递 request）。\n\n        Args:\n            req: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self._handle_callback_internal(req)\n\n    async def _handle_callback_internal(self, req):\n        \"\"\"处理回调请求的内部实现，包括 GET 验证和 POST 消息接收。\n\n        Args:\n            req: Quart Request 对象\n        \"\"\"\n        try:\n            self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')\n\n            if req.method == 'GET':\n                return await self._handle_get_callback(req)\n\n            if req.method == 'POST':\n                return await self._handle_post_callback(req)\n\n            return Response('', status=405)\n\n        except Exception:\n            await self.logger.error(traceback.format_exc())\n            return Response('Internal Server Error', status=500)\n\n    async def _handle_get_callback(self, req) -> tuple[Response, int] | Response:\n        \"\"\"处理企业微信的 GET 验证请求。\"\"\"\n\n        msg_signature = unquote(req.args.get('msg_signature', ''))\n        timestamp = unquote(req.args.get('timestamp', ''))\n        nonce = unquote(req.args.get('nonce', ''))\n        echostr = unquote(req.args.get('echostr', ''))\n\n        if not all([msg_signature, timestamp, nonce, echostr]):\n            await self.logger.error('请求参数缺失')\n            return Response('缺少参数', status=400)\n\n        ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)\n        if ret != 0:\n            await self.logger.error('验证URL失败')\n            return Response('验证失败', status=403)\n\n        return Response(decrypted_str, mimetype='text/plain')\n\n    async def _handle_post_callback(self, req) -> tuple[Response, int] | Response:\n        \"\"\"处理企业微信的 POST 回调请求。\"\"\"\n\n        self.stream_sessions.cleanup()\n\n        msg_signature = unquote(req.args.get('msg_signature', ''))\n        timestamp = unquote(req.args.get('timestamp', ''))\n        nonce = unquote(req.args.get('nonce', ''))\n\n        encrypted_json = await req.get_json()\n        encrypted_msg = (encrypted_json or {}).get('encrypt', '')\n        if not encrypted_msg:\n            await self.logger.error(\"请求体中缺少 'encrypt' 字段\")\n            return Response('Bad Request', status=400)\n\n        xml_post_data = f'<xml><Encrypt><![CDATA[{encrypted_msg}]]></Encrypt></xml>'\n        ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce)\n        if ret != 0:\n            await self.logger.error('解密失败')\n            return Response('解密失败', status=400)\n\n        msg_json = json.loads(decrypted_xml)\n\n        if msg_json.get('msgtype') == 'stream':\n            return await self._handle_post_followup_response(msg_json, nonce)\n\n        return await self._handle_post_initial_response(msg_json, nonce)\n\n    async def get_message(self, msg_json):\n        return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)\n\n    async def _handle_message(self, event: wecombotevent.WecomBotEvent):\n        \"\"\"\n        处理消息事件。\n        \"\"\"\n        try:\n            message_id = event.message_id\n            if message_id in self.msg_id_map.keys():\n                self.msg_id_map[message_id] += 1\n                return\n            self.msg_id_map[message_id] = 1\n            msg_type = event.type\n            if msg_type in self._message_handlers:\n                for handler in self._message_handlers[msg_type]:\n                    await handler(event)\n        except Exception:\n            print(traceback.format_exc())\n\n    async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:\n        \"\"\"将流水线片段推送到 stream 会话。\n\n        Args:\n            msg_id: 原始企业微信消息 ID。\n            content: 模型产生的片段内容。\n            is_final: 是否为最终片段。\n\n        Returns:\n            bool: 当成功写入流式队列时返回 True。\n\n        Example:\n            在流水线 `reply_message_chunk` 中调用，将增量推送至企业微信。\n        \"\"\"\n        # 根据 msg_id 找到对应 stream 会话，如果不存在说明当前消息非流式\n        stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id)\n        if not stream_id:\n            return False\n\n        chunk = StreamChunk(content=content, is_final=is_final)\n        await self.stream_sessions.publish(stream_id, chunk)\n        if is_final:\n            self.stream_sessions.mark_finished(stream_id)\n        return True\n\n    async def set_message(self, msg_id: str, content: str):\n        \"\"\"兼容旧逻辑：若无法流式返回则缓存最终结果。\n\n        Args:\n            msg_id: 企业微信消息 ID。\n            content: 最终回复的文本内容。\n\n        Example:\n            在非流式场景下缓存最终结果以备刷新时返回。\n        \"\"\"\n        handled = await self.push_stream_chunk(msg_id, content, is_final=True)\n        if not handled:\n            self.generated_content[msg_id] = content\n\n    def on_message(self, msg_type: str):\n        def decorator(func: Callable[[wecombotevent.WecomBotEvent], None]):\n            if msg_type not in self._message_handlers:\n                self._message_handlers[msg_type] = []\n            self._message_handlers[msg_type].append(func)\n            return func\n\n        return decorator\n\n    async def download_url_to_base64(self, download_url, encoding_aes_key):\n        return await download_encrypted_file(download_url, encoding_aes_key, self.logger)\n\n    async def run_task(self, host: str, port: int, *args, **kwargs):\n        \"\"\"\n        启动 Quart 应用。\n        \"\"\"\n        await self.app.run_task(host=host, port=port, *args, **kwargs)\n"
  },
  {
    "path": "src/langbot/libs/wecom_ai_bot_api/ierror.py",
    "content": "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\n#########################################################################\n# Author: jonyqin\n# Created Time: Thu 11 Sep 2014 01:53:58 PM CST\n# File Name: ierror.py\n# Description:定义错误码含义\n#########################################################################\nWXBizMsgCrypt_OK = 0\nWXBizMsgCrypt_ValidateSignature_Error = -40001\nWXBizMsgCrypt_ParseXml_Error = -40002\nWXBizMsgCrypt_ComputeSignature_Error = -40003\nWXBizMsgCrypt_IllegalAesKey = -40004\nWXBizMsgCrypt_ValidateCorpid_Error = -40005\nWXBizMsgCrypt_EncryptAES_Error = -40006\nWXBizMsgCrypt_DecryptAES_Error = -40007\nWXBizMsgCrypt_IllegalBuffer = -40008\nWXBizMsgCrypt_EncodeBase64_Error = -40009\nWXBizMsgCrypt_DecodeBase64_Error = -40010\nWXBizMsgCrypt_GenReturnXml_Error = -40011\n"
  },
  {
    "path": "src/langbot/libs/wecom_ai_bot_api/wecombotevent.py",
    "content": "from typing import Dict, Any, Optional\n\n\nclass WecomBotEvent(dict):\n    @staticmethod\n    def from_payload(payload: Dict[str, Any]) -> Optional['WecomBotEvent']:\n        try:\n            event = WecomBotEvent(payload)\n            return event\n        except KeyError:\n            return None\n\n    @property\n    def type(self) -> str:\n        \"\"\"\n        事件类型\n        \"\"\"\n        return self.get('type', '')\n\n    @property\n    def msgtype(self) -> str:\n        \"\"\"\n        消息 msgtype\n        \"\"\"\n        return self.get('msgtype', '')\n\n    @property\n    def userid(self) -> str:\n        \"\"\"\n        用户id\n        \"\"\"\n        return self.get('from', {}).get('userid', '') or self.get('userid', '')\n\n    @property\n    def username(self) -> str:\n        \"\"\"\n        用户名称\n        \"\"\"\n        return (\n            self.get('username', '')\n            or self.get('from', {}).get('alias', '')\n            or self.get('from', {}).get('name', '')\n            or self.userid\n        )\n\n    @property\n    def chatname(self) -> str:\n        \"\"\"\n        群组名称\n        \"\"\"\n        return self.get('chatname', '') or str(self.chatid)\n\n    @property\n    def content(self) -> str:\n        \"\"\"\n        内容\n        \"\"\"\n        return self.get('content', '')\n\n    @property\n    def picurl(self) -> str:\n        \"\"\"\n        图片url\n        \"\"\"\n        return self.get('picurl', '')\n\n    @property\n    def images(self):\n        \"\"\"\n        图片列表（兼容 mixed）\n        \"\"\"\n        return self.get('images', [])\n\n    @property\n    def file(self):\n        \"\"\"\n        文件信息\n        \"\"\"\n        return self.get('file', {})\n\n    @property\n    def voice(self):\n        \"\"\"\n        语音信息\n        \"\"\"\n        return self.get('voice', {})\n\n    @property\n    def video(self):\n        \"\"\"\n        视频信息\n        \"\"\"\n        return self.get('video', {})\n\n    @property\n    def link(self):\n        \"\"\"\n        链接消息信息\n        \"\"\"\n        return self.get('link', {})\n\n    @property\n    def location(self):\n        \"\"\"\n        位置信息\n        \"\"\"\n        return self.get('location', {})\n\n    @property\n    def attachments(self):\n        \"\"\"\n        原始 mixed 中的附件项\n        \"\"\"\n        return self.get('attachments', [])\n\n    @property\n    def chatid(self) -> str:\n        \"\"\"\n        群组id\n        \"\"\"\n        return self.get('chatid', {})\n\n    @property\n    def message_id(self) -> str:\n        \"\"\"\n        消息id\n        \"\"\"\n        return self.get('msgid', '')\n\n    @property\n    def ai_bot_id(self) -> str:\n        \"\"\"\n        AI Bot ID\n        \"\"\"\n        return self.get('aibotid', '')\n"
  },
  {
    "path": "src/langbot/libs/wecom_ai_bot_api/ws_client.py",
    "content": "\"\"\"WeChat Work AI Bot WebSocket long connection client.\n\nImplements the WebSocket protocol for receiving messages and sending replies\nvia a persistent connection to wss://openws.work.weixin.qq.com, as an\nalternative to the HTTP callback (webhook) mode.\n\nProtocol reference: https://developer.work.weixin.qq.com/document/path/101463\nOfficial Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport secrets\nimport time\nimport traceback\nfrom typing import Any, Callable, Optional\n\nimport aiohttp\n\nfrom langbot.libs.wecom_ai_bot_api import wecombotevent\nfrom langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message\nfrom langbot.pkg.platform.logger import EventLogger\n\nDEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'\n\n# WebSocket frame command constants\nCMD_SUBSCRIBE = 'aibot_subscribe'\nCMD_HEARTBEAT = 'ping'\nCMD_MSG_CALLBACK = 'aibot_msg_callback'\nCMD_EVENT_CALLBACK = 'aibot_event_callback'\nCMD_RESPOND_MSG = 'aibot_respond_msg'\nCMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'\nCMD_RESPOND_UPDATE = 'aibot_respond_update_msg'\nCMD_SEND_MSG = 'aibot_send_msg'\n\n\ndef _generate_req_id(prefix: str) -> str:\n    \"\"\"Generate a unique request ID in the format: {prefix}_{timestamp}_{random}.\"\"\"\n    ts = int(time.time() * 1000)\n    rand = secrets.token_hex(4)\n    return f'{prefix}_{ts}_{rand}'\n\n\nclass WecomBotWsClient:\n    \"\"\"WeChat Work AI Bot WebSocket long connection client.\n\n    Provides message receiving, streaming reply, proactive message sending,\n    and event callback handling over a persistent WebSocket connection.\n    \"\"\"\n\n    def __init__(\n        self,\n        bot_id: str,\n        secret: str,\n        logger: EventLogger,\n        encoding_aes_key: str = '',\n        ws_url: str = DEFAULT_WS_URL,\n        heartbeat_interval: float = 30.0,\n        max_reconnect_attempts: int = -1,\n        reconnect_base_delay: float = 1.0,\n        reconnect_max_delay: float = 30.0,\n    ):\n        self.bot_id = bot_id\n        self.secret = secret\n        self.logger = logger\n        self.encoding_aes_key = encoding_aes_key\n        self.ws_url = ws_url\n        self.heartbeat_interval = heartbeat_interval\n        self.max_reconnect_attempts = max_reconnect_attempts\n        self.reconnect_base_delay = reconnect_base_delay\n        self.reconnect_max_delay = reconnect_max_delay\n\n        self._ws: Optional[aiohttp.ClientWebSocketResponse] = None\n        self._session: Optional[aiohttp.ClientSession] = None\n        self._running = False\n        self._heartbeat_task: Optional[asyncio.Task] = None\n        self._missed_pong_count = 0\n        self._max_missed_pong = 2\n        self._reconnect_attempts = 0\n\n        # Message handler registry (same pattern as WecomBotClient)\n        self._message_handlers: dict[str, list[Callable]] = {}\n        # Message deduplication\n        self._msg_id_map: dict[str, int] = {}\n\n        # Pending ACK futures: req_id -> Future[dict]\n        self._pending_acks: dict[str, asyncio.Future] = {}\n        # Per-req_id serial reply queues\n        self._reply_queues: dict[str, asyncio.Queue] = {}\n        self._reply_workers: dict[str, asyncio.Task] = {}\n        self._reply_ack_timeout = 5.0\n\n        # Stream ID tracking for WebSocket mode\n        self._stream_ids: dict[str, str] = {}  # msg_id -> req_id|stream_id\n        # Dedup: skip sending when content hasn't changed\n        self._stream_last_content: dict[str, str] = {}  # msg_id -> last content sent\n\n    # ── Public API ──────────────────────────────────────────────────\n\n    async def connect(self):\n        \"\"\"Connect to WebSocket server with automatic reconnection.\n\n        This method blocks until disconnect() is called or max reconnect\n        attempts are exhausted.\n        \"\"\"\n        self._running = True\n        self._reconnect_attempts = 0\n\n        while self._running:\n            try:\n                await self._connect_once()\n            except Exception:\n                if not self._running:\n                    break\n                await self.logger.error(f'WebSocket connection error: {traceback.format_exc()}')\n\n            if not self._running:\n                break\n\n            # Reconnect with exponential backoff\n            if self.max_reconnect_attempts != -1 and self._reconnect_attempts >= self.max_reconnect_attempts:\n                await self.logger.error(f'Max reconnect attempts reached ({self.max_reconnect_attempts}), giving up')\n                break\n\n            self._reconnect_attempts += 1\n            delay = min(\n                self.reconnect_base_delay * (2 ** (self._reconnect_attempts - 1)),\n                self.reconnect_max_delay,\n            )\n            await self.logger.info(f'Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempts})...')\n            await asyncio.sleep(delay)\n\n    async def disconnect(self):\n        \"\"\"Gracefully disconnect from the WebSocket server.\"\"\"\n        self._running = False\n        if self._heartbeat_task and not self._heartbeat_task.done():\n            self._heartbeat_task.cancel()\n        for task in self._reply_workers.values():\n            if not task.done():\n                task.cancel()\n        if self._ws and not self._ws.closed:\n            await self._ws.close()\n        self._ws = None\n        if self._session and not self._session.closed:\n            await self._session.close()\n        self._session = None\n\n    def on_message(self, msg_type: str) -> Callable:\n        \"\"\"Decorator to register a message handler.\n\n        Same interface as WecomBotClient.on_message for compatibility.\n\n        Args:\n            msg_type: 'single', 'group', or specific message type.\n        \"\"\"\n\n        def decorator(func: Callable[[wecombotevent.WecomBotEvent], Any]):\n            if msg_type not in self._message_handlers:\n                self._message_handlers[msg_type] = []\n            self._message_handlers[msg_type].append(func)\n            return func\n\n        return decorator\n\n    async def reply_stream(\n        self,\n        req_id: str,\n        stream_id: str,\n        content: str,\n        finish: bool = False,\n    ) -> Optional[dict]:\n        \"\"\"Send a streaming reply frame.\n\n        Args:\n            req_id: The req_id from the original message frame (must be passed through).\n            stream_id: The stream ID for this streaming session.\n            content: The content to send (supports Markdown).\n            finish: Whether this is the final chunk.\n\n        Returns:\n            The ACK frame dict, or None on failure.\n        \"\"\"\n        body = {\n            'msgtype': 'stream',\n            'stream': {\n                'id': stream_id,\n                'finish': finish,\n                'content': content,\n            },\n        }\n        return await self._send_reply(req_id, body)\n\n    async def reply_text(self, req_id: str, content: str) -> Optional[dict]:\n        \"\"\"Send a non-streaming text reply.\n\n        Args:\n            req_id: The req_id from the original message frame.\n            content: The text content to reply.\n\n        Returns:\n            The ACK frame dict, or None on failure.\n        \"\"\"\n        body = {\n            'msgtype': 'markdown',\n            'markdown': {\n                'content': content,\n            },\n        }\n        return await self._send_reply(req_id, body)\n\n    async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:\n        \"\"\"Proactively send a message to a specified chat.\n\n        Args:\n            chat_id: The chat ID (userid for single chat, chatid for group chat).\n            content: The message content.\n            msgtype: Message type, 'markdown' by default.\n\n        Returns:\n            The ACK frame dict, or None on failure.\n        \"\"\"\n        req_id = _generate_req_id(CMD_SEND_MSG)\n        body: dict[str, Any] = {\n            'chatid': chat_id,\n            'msgtype': msgtype,\n        }\n        if msgtype == 'markdown':\n            body['markdown'] = {'content': content}\n        elif msgtype == 'text':\n            body['text'] = {'content': content}\n        return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)\n\n    async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:\n        \"\"\"Push a streaming chunk for a given message ID.\n\n        Compatible interface with WecomBotClient.push_stream_chunk.\n\n        Args:\n            msg_id: The original message ID.\n            content: The cumulative content from the pipeline.\n            is_final: Whether this is the final chunk.\n\n        Returns:\n            True if the stream session exists and chunk was sent.\n        \"\"\"\n        key = self._stream_ids.get(msg_id)\n        if not key:\n            return False\n        req_id, stream_id = key.split('|', 1)\n        try:\n            # Skip sending if content hasn't changed (e.g. during tool call argument streaming)\n            if not is_final and content == self._stream_last_content.get(msg_id):\n                return True\n            await self.reply_stream(req_id, stream_id, content, finish=is_final)\n            self._stream_last_content[msg_id] = content\n            if is_final:\n                self._stream_ids.pop(msg_id, None)\n                self._stream_last_content.pop(msg_id, None)\n            return True\n        except Exception:\n            await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')\n            return False\n\n    async def set_message(self, msg_id: str, content: str):\n        \"\"\"Fallback: send content as a final stream chunk or direct reply.\n\n        Compatible interface with WecomBotClient.set_message.\n        \"\"\"\n        handled = await self.push_stream_chunk(msg_id, content, is_final=True)\n        if not handled:\n            await self.logger.warning(f'No active stream for msg_id={msg_id}, message dropped')\n\n    # ── Connection lifecycle ────────────────────────────────────────\n\n    async def _connect_once(self):\n        \"\"\"Establish a single WebSocket connection, authenticate, and listen.\"\"\"\n        await self.logger.info(f'Connecting to {self.ws_url}...')\n\n        self._session = aiohttp.ClientSession()\n        try:\n            self._ws = await self._session.ws_connect(self.ws_url)\n            self._missed_pong_count = 0\n            self._reconnect_attempts = 0\n            await self.logger.info('WebSocket connected, sending auth...')\n\n            await self._send_auth()\n\n            # Wait for auth response\n            auth_ok = await self._wait_for_auth()\n            if not auth_ok:\n                await self.logger.error('Authentication failed')\n                return\n\n            await self.logger.info('Authenticated successfully')\n\n            # Start heartbeat\n            self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())\n\n            try:\n                await self._listen_loop()\n            finally:\n                if self._heartbeat_task and not self._heartbeat_task.done():\n                    self._heartbeat_task.cancel()\n                self._clear_pending_acks('Connection closed')\n        finally:\n            if self._ws and not self._ws.closed:\n                await self._ws.close()\n            self._ws = None\n            if self._session and not self._session.closed:\n                await self._session.close()\n            self._session = None\n\n    async def _send_auth(self):\n        \"\"\"Send the authentication frame.\"\"\"\n        frame = {\n            'cmd': CMD_SUBSCRIBE,\n            'headers': {'req_id': _generate_req_id(CMD_SUBSCRIBE)},\n            'body': {\n                'bot_id': self.bot_id,\n                'secret': self.secret,\n            },\n        }\n        await self._send_frame(frame)\n\n    async def _wait_for_auth(self) -> bool:\n        \"\"\"Wait for and validate the authentication response.\"\"\"\n        try:\n            msg = await asyncio.wait_for(self._ws.receive(), timeout=10.0)\n            if msg.type in (aiohttp.WSMsgType.TEXT,):\n                frame = json.loads(msg.data)\n                req_id = frame.get('headers', {}).get('req_id', '')\n                if req_id.startswith(CMD_SUBSCRIBE) and frame.get('errcode') == 0:\n                    return True\n                await self.logger.error(f'Auth response: errcode={frame.get(\"errcode\")}, errmsg={frame.get(\"errmsg\")}')\n                return False\n            elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):\n                await self.logger.error(f'WebSocket closed during auth: {msg.type}')\n                return False\n            await self.logger.error(f'Unexpected message type during auth: {msg.type}')\n            return False\n        except asyncio.TimeoutError:\n            await self.logger.error('Auth response timeout')\n            return False\n\n    async def _heartbeat_loop(self):\n        \"\"\"Periodically send heartbeat pings.\"\"\"\n        try:\n            while self._running and self._ws and not self._ws.closed:\n                await asyncio.sleep(self.heartbeat_interval)\n                if not self._running or not self._ws or self._ws.closed:\n                    break\n\n                if self._missed_pong_count >= self._max_missed_pong:\n                    await self.logger.warning(\n                        f'No heartbeat ack for {self._missed_pong_count} consecutive pings, connection considered dead'\n                    )\n                    await self._ws.close()\n                    break\n\n                self._missed_pong_count += 1\n                frame = {\n                    'cmd': CMD_HEARTBEAT,\n                    'headers': {'req_id': _generate_req_id(CMD_HEARTBEAT)},\n                }\n                try:\n                    await self._send_frame(frame)\n                except Exception:\n                    break\n        except asyncio.CancelledError:\n            pass\n\n    async def _listen_loop(self):\n        \"\"\"Listen for incoming WebSocket frames and dispatch them.\"\"\"\n        async for msg in self._ws:\n            if not self._running:\n                break\n            if msg.type == aiohttp.WSMsgType.TEXT:\n                try:\n                    frame = json.loads(msg.data)\n                    await self._handle_frame(frame)\n                except json.JSONDecodeError:\n                    await self.logger.error(f'Failed to parse WebSocket message: {str(msg.data)[:200]}')\n                except Exception:\n                    await self.logger.error(f'Error handling frame: {traceback.format_exc()}')\n            elif msg.type == aiohttp.WSMsgType.BINARY:\n                try:\n                    frame = json.loads(msg.data)\n                    await self._handle_frame(frame)\n                except Exception:\n                    await self.logger.error(f'Error handling binary frame: {traceback.format_exc()}')\n            elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):\n                await self.logger.warning(f'WebSocket connection closed: {msg.type}')\n                break\n\n    # ── Frame handling ──────────────────────────────────────────────\n\n    async def _handle_frame(self, frame: dict):\n        \"\"\"Route an incoming frame to the appropriate handler.\"\"\"\n        cmd = frame.get('cmd', '')\n\n        # Message push\n        if cmd == CMD_MSG_CALLBACK:\n            asyncio.create_task(self._handle_message_callback(frame))\n            return\n\n        # Event push\n        if cmd == CMD_EVENT_CALLBACK:\n            asyncio.create_task(self._handle_event_callback(frame))\n            return\n\n        # No cmd → response/ACK frame, dispatch by req_id prefix\n        req_id = frame.get('headers', {}).get('req_id', '')\n\n        # Check pending ACKs first\n        if req_id in self._pending_acks:\n            future = self._pending_acks.pop(req_id)\n            if not future.done():\n                future.set_result(frame)\n            return\n\n        # Heartbeat response\n        if req_id.startswith(CMD_HEARTBEAT):\n            if frame.get('errcode') == 0:\n                self._missed_pong_count = 0\n            return\n\n        # Unknown frame\n        await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')\n\n    async def _handle_message_callback(self, frame: dict):\n        \"\"\"Handle an incoming message callback frame.\"\"\"\n        try:\n            body = frame.get('body', {})\n            req_id = frame.get('headers', {}).get('req_id', '')\n\n            # Parse message using shared logic\n            message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)\n            if not message_data:\n                return\n\n            # Generate stream_id for this message and store the mapping\n            stream_id = _generate_req_id('stream')\n            msg_id = message_data.get('msgid', '')\n            if msg_id:\n                self._stream_ids[msg_id] = f'{req_id}|{stream_id}'\n            message_data['stream_id'] = stream_id\n            message_data['req_id'] = req_id\n\n            event = wecombotevent.WecomBotEvent(message_data)\n            await self._dispatch_event(event)\n        except Exception:\n            await self.logger.error(f'Error in message callback: {traceback.format_exc()}')\n\n    async def _handle_event_callback(self, frame: dict):\n        \"\"\"Handle an incoming event callback frame (enter_chat, template_card_event, etc.).\"\"\"\n        try:\n            body = frame.get('body', {})\n            req_id = frame.get('headers', {}).get('req_id', '')\n\n            event_info = body.get('event', {})\n            event_type = event_info.get('eventtype', '')\n\n            message_data = {\n                'msgtype': 'event',\n                'type': body.get('chattype', 'single'),\n                'event': event_info,\n                'eventtype': event_type,\n                'msgid': body.get('msgid', ''),\n                'aibotid': body.get('aibotid', ''),\n                'req_id': req_id,\n            }\n\n            from_info = body.get('from', {})\n            message_data['userid'] = from_info.get('userid', '')\n            message_data['username'] = from_info.get('alias', '') or from_info.get('userid', '')\n\n            if body.get('chatid'):\n                message_data['chatid'] = body.get('chatid', '')\n\n            event = wecombotevent.WecomBotEvent(message_data)\n\n            # Dispatch to event-specific handlers\n            if event_type in self._message_handlers:\n                for handler in self._message_handlers[event_type]:\n                    await handler(event)\n\n            # Also dispatch to generic 'event' handlers\n            if 'event' in self._message_handlers:\n                for handler in self._message_handlers['event']:\n                    await handler(event)\n\n        except Exception:\n            await self.logger.error(f'Error in event callback: {traceback.format_exc()}')\n\n    async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):\n        \"\"\"Dispatch a message event to registered handlers with deduplication.\"\"\"\n        try:\n            message_id = event.message_id\n            if message_id in self._msg_id_map:\n                self._msg_id_map[message_id] += 1\n                return\n            self._msg_id_map[message_id] = 1\n\n            msg_type = event.type\n            if msg_type in self._message_handlers:\n                for handler in self._message_handlers[msg_type]:\n                    await handler(event)\n        except Exception:\n            await self.logger.error(f'Error dispatching event: {traceback.format_exc()}')\n\n    # ── Reply sending with serial queue ─────────────────────────────\n\n    async def _send_reply(\n        self,\n        req_id: str,\n        body: dict,\n        cmd: str = CMD_RESPOND_MSG,\n    ) -> Optional[dict]:\n        \"\"\"Send a reply frame and wait for ACK.\n\n        Replies with the same req_id are serialized to maintain ordering.\n        \"\"\"\n        if not self._ws or self._ws.closed:\n            return None\n\n        frame = {\n            'cmd': cmd,\n            'headers': {'req_id': req_id},\n            'body': body,\n        }\n\n        # Ensure serial delivery per req_id\n        if req_id not in self._reply_queues:\n            self._reply_queues[req_id] = asyncio.Queue()\n            self._reply_workers[req_id] = asyncio.create_task(self._reply_queue_worker(req_id))\n\n        future: asyncio.Future = asyncio.get_event_loop().create_future()\n        await self._reply_queues[req_id].put((frame, future))\n        return await future\n\n    async def _reply_queue_worker(self, req_id: str):\n        \"\"\"Process reply queue items serially for a given req_id.\"\"\"\n        queue = self._reply_queues[req_id]\n        try:\n            while self._running:\n                try:\n                    frame, future = await asyncio.wait_for(queue.get(), timeout=60.0)\n                except asyncio.TimeoutError:\n                    # Queue idle, clean up worker\n                    break\n\n                try:\n                    ack = await self._send_and_wait_ack(frame)\n                    if not future.done():\n                        future.set_result(ack)\n                except Exception as e:\n                    if not future.done():\n                        future.set_exception(e)\n        except asyncio.CancelledError:\n            pass\n        finally:\n            self._reply_queues.pop(req_id, None)\n            self._reply_workers.pop(req_id, None)\n\n    async def _send_and_wait_ack(self, frame: dict) -> Optional[dict]:\n        \"\"\"Send a frame and wait for the corresponding ACK.\"\"\"\n        req_id = frame['headers']['req_id']\n        ack_future: asyncio.Future = asyncio.get_event_loop().create_future()\n        self._pending_acks[req_id] = ack_future\n\n        try:\n            await self._send_frame(frame)\n            result = await asyncio.wait_for(ack_future, timeout=self._reply_ack_timeout)\n            if result.get('errcode', 0) != 0:\n                await self.logger.warning(\n                    f'Reply ACK error: errcode={result.get(\"errcode\")}, errmsg={result.get(\"errmsg\")}'\n                )\n            return result\n        except asyncio.TimeoutError:\n            self._pending_acks.pop(req_id, None)\n            await self.logger.warning(f'Reply ACK timeout ({self._reply_ack_timeout}s) for req_id={req_id}')\n            return None\n\n    async def _send_frame(self, frame: dict):\n        \"\"\"Send a JSON frame over the WebSocket connection.\"\"\"\n        if self._ws and not self._ws.closed:\n            await self._ws.send_str(json.dumps(frame, ensure_ascii=False))\n\n    def _clear_pending_acks(self, reason: str):\n        \"\"\"Reject all pending ACK futures on disconnection.\"\"\"\n        for req_id, future in self._pending_acks.items():\n            if not future.done():\n                future.set_exception(ConnectionError(reason))\n        self._pending_acks.clear()\n"
  },
  {
    "path": "src/langbot/libs/wecom_api/WXBizMsgCrypt3.py",
    "content": "#!/usr/bin/env python\n# -*- encoding:utf-8 -*-\n\n\"\"\"对企业微信发送给企业后台的消息加解密示例代码.\n@copyright: Copyright (c) 1998-2014 Tencent Inc.\n\n\"\"\"\n\n# ------------------------------------------------------------------------\nimport logging\nimport base64\nimport random\nimport hashlib\nimport time\nimport struct\nfrom Crypto.Cipher import AES\nimport xml.etree.cElementTree as ET\nimport socket\n\nfrom . import ierror\n\n\n\"\"\"\nCrypto.Cipher包已不再维护，开发者可以通过以下命令下载安装最新版的加解密工具包\n    pip install pycryptodome\n\"\"\"\n\n\nclass FormatException(Exception):\n    pass\n\n\ndef throw_exception(message, exception_class=FormatException):\n    \"\"\"my define raise exception function\"\"\"\n    raise exception_class(message)\n\n\nclass SHA1:\n    \"\"\"计算企业微信的消息签名接口\"\"\"\n\n    def getSHA1(self, token, timestamp, nonce, encrypt):\n        \"\"\"用SHA1算法生成安全签名\n        @param token:  票据\n        @param timestamp: 时间戳\n        @param encrypt: 密文\n        @param nonce: 随机字符串\n        @return: 安全签名\n        \"\"\"\n        try:\n            sortlist = [token, timestamp, nonce, encrypt]\n            sortlist.sort()\n            sha = hashlib.sha1()\n            sha.update(''.join(sortlist).encode())\n            return ierror.WXBizMsgCrypt_OK, sha.hexdigest()\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return ierror.WXBizMsgCrypt_ComputeSignature_Error, None\n\n\nclass XMLParse:\n    \"\"\"提供提取消息格式中的密文及生成回复消息格式的接口\"\"\"\n\n    # xml消息模板\n    AES_TEXT_RESPONSE_TEMPLATE = \"\"\"<xml>\n<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>\n<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>\n<TimeStamp>%(timestamp)s</TimeStamp>\n<Nonce><![CDATA[%(nonce)s]]></Nonce>\n</xml>\"\"\"\n\n    def extract(self, xmltext):\n        \"\"\"提取出xml数据包中的加密消息\n        @param xmltext: 待提取的xml字符串\n        @return: 提取出的加密消息字符串\n        \"\"\"\n        try:\n            xml_tree = ET.fromstring(xmltext)\n            encrypt = xml_tree.find('Encrypt')\n            return ierror.WXBizMsgCrypt_OK, encrypt.text\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return ierror.WXBizMsgCrypt_ParseXml_Error, None\n\n    def generate(self, encrypt, signature, timestamp, nonce):\n        \"\"\"生成xml消息\n        @param encrypt: 加密后的消息密文\n        @param signature: 安全签名\n        @param timestamp: 时间戳\n        @param nonce: 随机字符串\n        @return: 生成的xml字符串\n        \"\"\"\n        resp_dict = {\n            'msg_encrypt': encrypt,\n            'msg_signaturet': signature,\n            'timestamp': timestamp,\n            'nonce': nonce,\n        }\n        resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict\n        return resp_xml\n\n\nclass PKCS7Encoder:\n    \"\"\"提供基于PKCS7算法的加解密接口\"\"\"\n\n    block_size = 32\n\n    def encode(self, text):\n        \"\"\"对需要加密的明文进行填充补位\n        @param text: 需要进行填充补位操作的明文\n        @return: 补齐明文字符串\n        \"\"\"\n        text_length = len(text)\n        # 计算需要填充的位数\n        amount_to_pad = self.block_size - (text_length % self.block_size)\n        if amount_to_pad == 0:\n            amount_to_pad = self.block_size\n        # 获得补位所用的字符\n        pad = chr(amount_to_pad)\n        return text + (pad * amount_to_pad).encode()\n\n    def decode(self, decrypted):\n        \"\"\"删除解密后明文的补位字符\n        @param decrypted: 解密后的明文\n        @return: 删除补位字符后的明文\n        \"\"\"\n        pad = ord(decrypted[-1])\n        if pad < 1 or pad > 32:\n            pad = 0\n        return decrypted[:-pad]\n\n\nclass Prpcrypt(object):\n    \"\"\"提供接收和推送给企业微信消息的加解密接口\"\"\"\n\n    def __init__(self, key):\n        # self.key = base64.b64decode(key+\"=\")\n        self.key = key\n        # 设置加解密模式为AES的CBC模式\n        self.mode = AES.MODE_CBC\n\n    def encrypt(self, text, receiveid):\n        \"\"\"对明文进行加密\n        @param text: 需要加密的明文\n        @return: 加密得到的字符串\n        \"\"\"\n        # 16位随机字符串添加到明文开头\n        text = text.encode()\n        text = self.get_random_str() + struct.pack('I', socket.htonl(len(text))) + text + receiveid.encode()\n\n        # 使用自定义的填充方式对明文进行补位填充\n        pkcs7 = PKCS7Encoder()\n        text = pkcs7.encode(text)\n        # 加密\n        cryptor = AES.new(self.key, self.mode, self.key[:16])\n        try:\n            ciphertext = cryptor.encrypt(text)\n            # 使用BASE64对加密后的字符串进行编码\n            return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return ierror.WXBizMsgCrypt_EncryptAES_Error, None\n\n    def decrypt(self, text, receiveid):\n        \"\"\"对解密后的明文进行补位删除\n        @param text: 密文\n        @return: 删除填充补位后的明文\n        \"\"\"\n        try:\n            cryptor = AES.new(self.key, self.mode, self.key[:16])\n            # 使用BASE64对密文进行解码，然后AES-CBC解密\n            plain_text = cryptor.decrypt(base64.b64decode(text))\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return ierror.WXBizMsgCrypt_DecryptAES_Error, None\n        try:\n            pad = plain_text[-1]\n            # 去掉补位字符串\n            # pkcs7 = PKCS7Encoder()\n            # plain_text = pkcs7.encode(plain_text)\n            # 去除16位随机字符串\n            content = plain_text[16:-pad]\n            xml_len = socket.ntohl(struct.unpack('I', content[:4])[0])\n            xml_content = content[4 : xml_len + 4]\n            from_receiveid = content[xml_len + 4 :]\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return ierror.WXBizMsgCrypt_IllegalBuffer, None\n\n        if from_receiveid.decode('utf8') != receiveid:\n            return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None\n        return 0, xml_content\n\n    def get_random_str(self):\n        \"\"\"随机生成16位字符串\n        @return: 16位字符串\n        \"\"\"\n        return str(random.randint(1000000000000000, 9999999999999999)).encode()\n\n\nclass WXBizMsgCrypt(object):\n    # 构造函数\n    def __init__(self, sToken, sEncodingAESKey, sReceiveId):\n        try:\n            self.key = base64.b64decode(sEncodingAESKey + '=')\n            assert len(self.key) == 32\n        except Exception:\n            throw_exception('[error]: EncodingAESKey unvalid !', FormatException)\n            # return ierror.WXBizMsgCrypt_IllegalAesKey,None\n        self.m_sToken = sToken\n        self.m_sReceiveId = sReceiveId\n\n        # 验证URL\n        # @param sMsgSignature: 签名串，对应URL参数的msg_signature\n        # @param sTimeStamp: 时间戳，对应URL参数的timestamp\n        # @param sNonce: 随机串，对应URL参数的nonce\n        # @param sEchoStr: 随机串，对应URL参数的echostr\n        # @param sReplyEchoStr: 解密之后的echostr，当return返回0时有效\n        # @return：成功0，失败返回对应的错误码\n\n    def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):\n        sha1 = SHA1()\n        ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)\n        if ret != 0:\n            return ret, None\n        if not signature == sMsgSignature:\n            return ierror.WXBizMsgCrypt_ValidateSignature_Error, None\n        pc = Prpcrypt(self.key)\n        ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId)\n        return ret, sReplyEchoStr\n\n    def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):\n        # 将企业回复用户的消息加密打包\n        # @param sReplyMsg: 企业号待回复用户的消息，xml格式的字符串\n        # @param sTimeStamp: 时间戳，可以自己生成，也可以用URL参数的timestamp,如为None则自动用当前时间\n        # @param sNonce: 随机串，可以自己生成，也可以用URL参数的nonce\n        # sEncryptMsg: 加密后的可以直接回复用户的密文，包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,\n        # return：成功0，sEncryptMsg,失败返回对应的错误码None\n        pc = Prpcrypt(self.key)\n        ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)\n        encrypt = encrypt.decode('utf8')\n        if ret != 0:\n            return ret, None\n        if timestamp is None:\n            timestamp = str(int(time.time()))\n        # 生成安全签名\n        sha1 = SHA1()\n        ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)\n        if ret != 0:\n            return ret, None\n        xmlParse = XMLParse()\n        return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce)\n\n    def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):\n        # 检验消息的真实性，并且获取解密后的明文\n        # @param sMsgSignature: 签名串，对应URL参数的msg_signature\n        # @param sTimeStamp: 时间戳，对应URL参数的timestamp\n        # @param sNonce: 随机串，对应URL参数的nonce\n        # @param sPostData: 密文，对应POST请求的数据\n        #  xml_content: 解密后的原文，当return返回0时有效\n        # @return: 成功0，失败返回对应的错误码\n        # 验证安全签名\n        xmlParse = XMLParse()\n        ret, encrypt = xmlParse.extract(sPostData)\n        if ret != 0:\n            return ret, None\n        sha1 = SHA1()\n        ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)\n        if ret != 0:\n            return ret, None\n        if not signature == sMsgSignature:\n            return ierror.WXBizMsgCrypt_ValidateSignature_Error, None\n        pc = Prpcrypt(self.key)\n        ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId)\n        return ret, xml_content\n"
  },
  {
    "path": "src/langbot/libs/wecom_api/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/libs/wecom_api/api.py",
    "content": "from quart import request\nfrom .WXBizMsgCrypt3 import WXBizMsgCrypt\nimport base64\nimport binascii\nimport httpx\nimport traceback\nfrom quart import Quart\nimport xml.etree.ElementTree as ET\nfrom typing import Callable, Dict, Any\nfrom .wecomevent import WecomEvent\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport aiofiles\n\n\nclass WecomClient:\n    def __init__(\n        self,\n        corpid: str,\n        secret: str,\n        token: str,\n        EncodingAESKey: str,\n        contacts_secret: str,\n        logger: None,\n        unified_mode: bool = False,\n        api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin',\n    ):\n        self.corpid = corpid\n        self.secret = secret\n        self.access_token_for_contacts = ''\n        self.token = token\n        self.aes = EncodingAESKey\n        self.base_url = api_base_url\n        self.access_token = ''\n        self.secret_for_contacts = contacts_secret\n        self.logger = logger\n        self.unified_mode = unified_mode\n        self.app = Quart(__name__)\n\n        # 只有在非统一模式下才注册独立路由\n        if not self.unified_mode:\n            self.app.add_url_rule(\n                '/callback/command',\n                'handle_callback',\n                self.handle_callback_request,\n                methods=['GET', 'POST'],\n            )\n\n        self._message_handlers = {\n            'example': [],\n        }\n\n    # access——token操作\n    async def check_access_token(self):\n        return bool(self.access_token and self.access_token.strip())\n\n    async def check_access_token_for_contacts(self):\n        return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())\n\n    async def get_access_token(self, secret):\n        url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'\n        async with httpx.AsyncClient() as client:\n            response = await client.get(url)\n            data = response.json()\n            if 'access_token' in data:\n                return data['access_token']\n            else:\n                await self.logger.error(f'获取accesstoken失败:{response.json()}')\n                raise Exception(f'未获取access token: {data}')\n\n    async def get_users(self):\n        if not self.check_access_token_for_contacts():\n            self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)\n\n        url = self.base_url + '/user/list_id?access_token=' + self.access_token_for_contacts\n        async with httpx.AsyncClient() as client:\n            params = {\n                'cursor': '',\n                'limit': 10000,\n            }\n            response = await client.post(url, json=params)\n            data = response.json()\n            if data['errcode'] == 0:\n                dept_users = data['dept_user']\n                userid = []\n                for user in dept_users:\n                    userid.append(user['userid'])\n                return userid\n            else:\n                raise Exception('未获取用户')\n\n    async def send_to_all(self, content: str, agent_id: int):\n        if not self.check_access_token_for_contacts():\n            self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)\n\n            url = self.base_url + '/message/send?access_token=' + self.access_token_for_contacts\n            user_ids = await self.get_users()\n            user_ids_string = '|'.join(user_ids)\n            async with httpx.AsyncClient() as client:\n                params = {\n                    'touser': user_ids_string,\n                    'msgtype': 'text',\n                    'agentid': agent_id,\n                    'text': {\n                        'content': content,\n                    },\n                    'safe': 0,\n                    'enable_id_trans': 0,\n                    'enable_duplicate_check': 0,\n                    'duplicate_check_interval': 1800,\n                }\n                response = await client.post(url, json=params)\n                data = response.json()\n                if data['errcode'] != 0:\n                    raise Exception('Failed to send message: ' + str(data))\n\n    async def send_image(self, user_id: str, agent_id: int, media_id: str):\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n\n        url = self.base_url + '/message/send?access_token=' + self.access_token\n        async with httpx.AsyncClient() as client:\n            params = {\n                'touser': user_id,\n                'msgtype': 'image',\n                'agentid': agent_id,\n                'image': {\n                    'media_id': media_id,\n                },\n                'safe': 0,\n                'enable_id_trans': 0,\n                'enable_duplicate_check': 0,\n                'duplicate_check_interval': 1800,\n            }\n            response = await client.post(url, json=params)\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                return await self.send_image(user_id, agent_id, media_id)\n            if data['errcode'] != 0:\n                await self.logger.error(f'发送图片失败:{data}')\n                raise Exception('Failed to send image: ' + str(data))\n\n    async def send_voice(self, user_id: str, agent_id: int, media_id: str):\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n        url = self.base_url + '/message/send?access_token=' + self.access_token\n        async with httpx.AsyncClient() as client:\n            params = {\n                'touser': user_id,\n                'msgtype': 'voice',\n                'agentid': agent_id,\n                'voice': {\n                    'media_id': media_id,\n                },\n                'safe': 0,\n                'enable_id_trans': 0,\n                'enable_duplicate_check': 0,\n                'duplicate_check_interval': 1800,\n            }\n            response = await client.post(url, json=params)\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                return await self.send_voice(user_id, agent_id, media_id)\n            if data['errcode'] != 0:\n                await self.logger.error(f'发送语音失败:{data}')\n                raise Exception('Failed to send voice: ' + str(data))\n\n    async def send_file(self, user_id: str, agent_id: int, media_id: str):\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n        url = self.base_url + '/message/send?access_token=' + self.access_token\n        async with httpx.AsyncClient() as client:\n            params = {\n                'touser': user_id,\n                'msgtype': 'file',\n                'agentid': agent_id,\n                'file': {\n                    'media_id': media_id,\n                },\n                'safe': 0,\n                'enable_id_trans': 0,\n                'enable_duplicate_check': 0,\n                'duplicate_check_interval': 1800,\n            }\n            response = await client.post(url, json=params)\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                return await self.send_file(user_id, agent_id, media_id)\n            if data['errcode'] != 0:\n                await self.logger.error(f'发送文件失败:{data}')\n                raise Exception('Failed to send file: ' + str(data))\n\n    async def send_private_msg(self, user_id: str, agent_id: int, content: str):\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n\n        url = self.base_url + '/message/send?access_token=' + self.access_token\n        async with httpx.AsyncClient(timeout=None) as client:\n            params = {\n                'touser': user_id,\n                'msgtype': 'text',\n                'agentid': agent_id,\n                'text': {\n                    'content': content,\n                },\n                'safe': 0,\n                'enable_id_trans': 0,\n                'enable_duplicate_check': 0,\n                'duplicate_check_interval': 1800,\n            }\n            response = await client.post(url, json=params)\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                return await self.send_private_msg(user_id, agent_id, content)\n            if data['errcode'] != 0:\n                await self.logger.error(f'发送消息失败:{data}')\n                raise Exception('Failed to send message: ' + str(data))\n\n    async def handle_callback_request(self):\n        \"\"\"处理回调请求（独立端口模式，使用全局 request）。\"\"\"\n        return await self._handle_callback_internal(request)\n\n    async def handle_unified_webhook(self, req):\n        \"\"\"处理回调请求（统一 webhook 模式，显式传递 request）。\n\n        Args:\n            req: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self._handle_callback_internal(req)\n\n    async def _handle_callback_internal(self, req):\n        \"\"\"\n        处理回调请求的内部实现，包括 GET 验证和 POST 消息接收。\n\n        Args:\n            req: Quart Request 对象\n        \"\"\"\n        try:\n            msg_signature = req.args.get('msg_signature')\n            timestamp = req.args.get('timestamp')\n            nonce = req.args.get('nonce')\n\n            wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)\n            if req.method == 'GET':\n                echostr = req.args.get('echostr')\n                ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)\n                if ret != 0:\n                    await self.logger.error('验证失败')\n                    raise Exception(f'验证失败，错误码: {ret}')\n                return reply_echo_str\n\n            elif req.method == 'POST':\n                encrypt_msg = await req.data\n                ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)\n                if ret != 0:\n                    await self.logger.error('消息解密失败')\n                    raise Exception(f'消息解密失败，错误码: {ret}')\n\n                # 解析消息并处理\n                message_data = await self.get_message(xml_msg)\n                if message_data:\n                    event = WecomEvent.from_payload(message_data)  # 转换为 WecomEvent 对象\n                    if event:\n                        await self._handle_message(event)\n\n                return 'success'\n        except Exception as e:\n            await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')\n            return f'Error processing request: {str(e)}', 400\n\n    async def run_task(self, host: str, port: int, *args, **kwargs):\n        \"\"\"\n        启动 Quart 应用。\n        \"\"\"\n        await self.app.run_task(host=host, port=port, *args, **kwargs)\n\n    def on_message(self, msg_type: str):\n        \"\"\"\n        注册消息类型处理器。\n        \"\"\"\n\n        def decorator(func: Callable[[WecomEvent], None]):\n            if msg_type not in self._message_handlers:\n                self._message_handlers[msg_type] = []\n            self._message_handlers[msg_type].append(func)\n            return func\n\n        return decorator\n\n    async def _handle_message(self, event: WecomEvent):\n        \"\"\"\n        处理消息事件。\n        \"\"\"\n        msg_type = event.type\n        if msg_type in self._message_handlers:\n            for handler in self._message_handlers[msg_type]:\n                await handler(event)\n\n    async def get_message(self, xml_msg: str) -> Dict[str, Any]:\n        \"\"\"\n        解析微信返回的 XML 消息并转换为字典。\n        \"\"\"\n        root = ET.fromstring(xml_msg)\n        message_data = {\n            'ToUserName': root.find('ToUserName').text,\n            'FromUserName': root.find('FromUserName').text,\n            'CreateTime': int(root.find('CreateTime').text),\n            'MsgType': root.find('MsgType').text,\n            'Content': root.find('Content').text if root.find('Content') is not None else None,\n            'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None,\n            'AgentID': int(root.find('AgentID').text) if root.find('AgentID') is not None else None,\n        }\n        if message_data['MsgType'] == 'image':\n            message_data['MediaId'] = root.find('MediaId').text if root.find('MediaId') is not None else None\n            message_data['PicUrl'] = root.find('PicUrl').text if root.find('PicUrl') is not None else None\n\n        return message_data\n\n    @staticmethod\n    async def get_image_type(image_bytes: bytes) -> str:\n        \"\"\"\n        通过图片的magic numbers判断图片类型\n        \"\"\"\n        magic_numbers = {\n            b'\\xff\\xd8\\xff': 'jpg',\n            b'\\x89\\x50\\x4e\\x47': 'png',\n            b'\\x47\\x49\\x46': 'gif',\n            b'\\x42\\x4d': 'bmp',\n            b'\\x00\\x00\\x01\\x00': 'ico',\n        }\n\n        for magic, ext in magic_numbers.items():\n            if image_bytes.startswith(magic):\n                return ext\n        return 'jpg'  # 默认返回jpg\n\n    async def upload_image_to_work(self, image: platform_message.Image):\n        \"\"\"\n        获取 media_id\n        \"\"\"\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n\n        url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'\n        file_bytes = None\n        file_name = 'uploaded_file.txt'\n\n        # 获取文件的二进制数据\n        if image.path:\n            async with aiofiles.open(image.path, 'rb') as f:\n                file_bytes = await f.read()\n                file_name = image.path.split('/')[-1]\n        elif image.url:\n            file_bytes = await self.download_media_to_bytes(image.url)\n            file_name = image.url.split('/')[-1]\n        elif image.base64:\n            try:\n                base64_data = image.base64\n                if ',' in base64_data:\n                    base64_data = base64_data.split(',', 1)[1]\n                padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0\n                padded_base64 = base64_data + '=' * padding\n                file_bytes = base64.b64decode(padded_base64)\n            except binascii.Error as e:\n                raise ValueError(f'Invalid base64 string: {str(e)}')\n        else:\n            await self.logger.error('Image对象出错')\n            raise ValueError('image对象出错')\n\n        # 设置 multipart/form-data 格式的文件\n        boundary = '-------------------------acebdf13572468'\n        headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}\n        body = (\n            (\n                f'--{boundary}\\r\\n'\n                f'Content-Disposition: form-data; name=\"media\"; filename=\"{file_name}\"; filelength={len(file_bytes)}\\r\\n'\n                f'Content-Type: application/octet-stream\\r\\n\\r\\n'\n            ).encode('utf-8')\n            + file_bytes\n            + f'\\r\\n--{boundary}--\\r\\n'.encode('utf-8')\n        )\n\n        # 上传文件\n        async with httpx.AsyncClient() as client:\n            response = await client.post(url, headers=headers, content=body)\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                media_id = await self.upload_image_to_work(image)\n            if data.get('errcode', 0) != 0:\n                await self.logger.error(f'上传图片失败:{data}')\n                raise Exception('failed to upload file')\n\n            media_id = data.get('media_id')\n            return media_id\n\n    async def upload_voice_to_work(self, voice: platform_message.Voice):\n        \"\"\"\n        上传语音文件到企业微信\n        \"\"\"\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n        url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'\n        file_bytes = None\n        file_name = 'voice.mp3'\n\n        if voice.path:\n            async with aiofiles.open(voice.path, 'rb') as f:\n                file_bytes = await f.read()\n                file_name = voice.path.split('/')[-1]\n        elif voice.url:\n            file_bytes = await self.download_media_to_bytes(voice.url)\n            file_name = voice.url.split('/')[-1]\n        elif voice.base64:\n            try:\n                base64_data = voice.base64\n                if ',' in base64_data:\n                    base64_data = base64_data.split(',', 1)[1]\n                padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0\n                padded_base64 = base64_data + '=' * padding\n                file_bytes = base64.b64decode(padded_base64)\n            except binascii.Error as e:\n                raise ValueError(f'Invalid base64 string: {str(e)}')\n        else:\n            await self.logger.error('Voice对象出错')\n            raise ValueError('voice对象出错')\n\n        boundary = '-------------------------acebdf13572468'\n        headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}\n        body = (\n            (\n                f'--{boundary}\\r\\n'\n                f'Content-Disposition: form-data; name=\"media\"; filename=\"{file_name}\"; filelength={len(file_bytes)}\\r\\n'\n                f'Content-Type: application/octet-stream\\r\\n\\r\\n'\n            ).encode('utf-8')\n            + file_bytes\n            + f'\\r\\n--{boundary}--\\r\\n'.encode('utf-8')\n        )\n\n        # print(body)\n        async with httpx.AsyncClient() as client:\n            response = await client.post(url, headers=headers, content=body)\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                media_id = await self.upload_voice_to_work(voice)\n            if data.get('errcode', 0) != 0:\n                await self.logger.error(f'上传语音文件失败:{data}')\n                raise Exception('failed to upload file')\n            media_id = data.get('media_id')\n            return media_id\n\n    async def upload_file_to_work(self, file: platform_message.File):\n        \"\"\"\n        上传文件到企业微信\n        \"\"\"\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n        url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'\n        file_bytes = None\n        file_name = 'file.txt'\n        if file.path:\n            async with aiofiles.open(file.path, 'rb') as f:\n                file_bytes = await f.read()\n                file_name = file.path.split('/')[-1]\n        elif file.url:\n            file_bytes = await self.download_media_to_bytes(file.url)\n            file_name = file.url.split('/')[-1]\n        elif file.base64:\n            try:\n                base64_data = file.base64\n                if ',' in base64_data:\n                    base64_data = base64_data.split(',', 1)[1]\n                padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0\n                padded_base64 = base64_data + '=' * padding\n                file_bytes = base64.b64decode(padded_base64)\n            except binascii.Error as e:\n                raise ValueError(f'Invalid base64 string: {str(e)}')\n        else:\n            await self.logger.error('File对象出错')\n            raise ValueError('file对象出错')\n        boundary = '-------------------------acebdf13572468'\n        headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}\n        body = (\n            (\n                f'--{boundary}\\r\\n'\n                f'Content-Disposition: form-data; name=\"media\"; filename=\"{file_name}\"; filelength={len(file_bytes)}\\r\\n'\n                f'Content-Type: application/octet-stream\\r\\n\\r\\n'\n            ).encode('utf-8')\n            + file_bytes\n            + f'\\r\\n--{boundary}--\\r\\n'.encode('utf-8')\n        )\n        async with httpx.AsyncClient() as client:\n            response = await client.post(url, headers=headers, content=body)\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                media_id = await self.upload_file_to_work(file)\n            if data.get('errcode', 0) != 0:\n                await self.logger.error(f'上传文件失败:{data}')\n                raise Exception('failed to upload file')\n            media_id = data.get('media_id')\n            return media_id\n\n    async def download_media_to_bytes(self, url: str) -> bytes:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(url)\n            response.raise_for_status()\n            return response.content\n\n    # 进行media_id的获取\n    async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File):\n        if isinstance(media, platform_message.Image):\n            media_id = await self.upload_image_to_work(image=media)\n        elif isinstance(media, platform_message.Voice):\n            media_id = await self.upload_voice_to_work(voice=media)\n        elif isinstance(media, platform_message.File):\n            media_id = await self.upload_file_to_work(file=media)\n        else:\n            raise ValueError('Unsupported media type')\n        return media_id\n"
  },
  {
    "path": "src/langbot/libs/wecom_api/ierror.py",
    "content": "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\n#########################################################################\n# Author: jonyqin\n# Created Time: Thu 11 Sep 2014 01:53:58 PM CST\n# File Name: ierror.py\n# Description:定义错误码含义\n#########################################################################\nWXBizMsgCrypt_OK = 0\nWXBizMsgCrypt_ValidateSignature_Error = -40001\nWXBizMsgCrypt_ParseXml_Error = -40002\nWXBizMsgCrypt_ComputeSignature_Error = -40003\nWXBizMsgCrypt_IllegalAesKey = -40004\nWXBizMsgCrypt_ValidateCorpid_Error = -40005\nWXBizMsgCrypt_EncryptAES_Error = -40006\nWXBizMsgCrypt_DecryptAES_Error = -40007\nWXBizMsgCrypt_IllegalBuffer = -40008\nWXBizMsgCrypt_EncodeBase64_Error = -40009\nWXBizMsgCrypt_DecodeBase64_Error = -40010\nWXBizMsgCrypt_GenReturnXml_Error = -40011\n"
  },
  {
    "path": "src/langbot/libs/wecom_api/wecomevent.py",
    "content": "from typing import Dict, Any, Optional\n\n\nclass WecomEvent(dict):\n    \"\"\"\n    封装从企业微信收到的事件数据对象（字典），提供属性以获取其中的字段。\n\n    除 `type` 和 `detail_type` 属性对于任何事件都有效外，其它属性是否存在（若不存在则返回 `None`）依事件类型不同而不同。\n    \"\"\"\n\n    @staticmethod\n    def from_payload(payload: Dict[str, Any]) -> Optional['WecomEvent']:\n        \"\"\"\n        从企业微信事件数据构造 `WecomEvent` 对象。\n\n        Args:\n            payload (Dict[str, Any]): 解密后的企业微信事件数据。\n\n        Returns:\n            Optional[WecomEvent]: 如果事件数据合法，则返回 WecomEvent 对象；否则返回 None。\n        \"\"\"\n        try:\n            event = WecomEvent(payload)\n            _ = event.type, event.detail_type  # 确保必须字段存在\n            return event\n        except KeyError:\n            return None\n\n    @property\n    def type(self) -> str:\n        \"\"\"\n        事件类型，例如 \"message\"、\"event\"、\"text\" 等。\n\n        Returns:\n            str: 事件类型。\n        \"\"\"\n        return self.get('MsgType', '')\n\n    @property\n    def picurl(self) -> str:\n        \"\"\"\n        图片链接\n        \"\"\"\n        return self.get('PicUrl')\n\n    @property\n    def detail_type(self) -> str:\n        \"\"\"\n        事件详细类型，依 `type` 的不同而不同。例如：\n        - 消息事件: \"text\", \"image\", \"voice\", 等\n        - 事件通知: \"subscribe\", \"unsubscribe\", \"click\", 等\n\n        Returns:\n            str: 事件详细类型。\n        \"\"\"\n        if self.type == 'event':\n            return self.get('Event', '')\n        return self.type\n\n    @property\n    def name(self) -> str:\n        \"\"\"\n        事件名，对于消息事件是 `type.detail_type`，对于其他事件是 `event_type`。\n\n        Returns:\n            str: 事件名。\n        \"\"\"\n        return f'{self.type}.{self.detail_type}'\n\n    @property\n    def user_id(self) -> Optional[str]:\n        \"\"\"\n        用户 ID，例如消息的发送者或事件的触发者。\n\n        Returns:\n            Optional[str]: 用户 ID。\n        \"\"\"\n        return self.get('FromUserName')\n\n    @property\n    def agent_id(self) -> Optional[int]:\n        \"\"\"\n        机器人 ID，仅在消息类型事件中存在。\n\n        Returns:\n            Optional[int]: 机器人 ID。\n        \"\"\"\n        return self.get('AgentID')\n\n    @property\n    def receiver_id(self) -> Optional[str]:\n        \"\"\"\n        接收者 ID，例如机器人自身的企业微信 ID。\n\n        Returns:\n            Optional[str]: 接收者 ID。\n        \"\"\"\n        return self.get('ToUserName')\n\n    @property\n    def message_id(self) -> Optional[str]:\n        \"\"\"\n        消息 ID，仅在消息类型事件中存在。\n\n        Returns:\n            Optional[str]: 消息 ID。\n        \"\"\"\n        return self.get('MsgId')\n\n    @property\n    def message(self) -> Optional[str]:\n        \"\"\"\n        消息内容，仅在消息类型事件中存在。\n\n        Returns:\n            Optional[str]: 消息内容。\n        \"\"\"\n        return self.get('Content')\n\n    @property\n    def media_id(self) -> Optional[str]:\n        \"\"\"\n        媒体文件 ID，仅在图片、语音等消息类型中存在。\n\n        Returns:\n            Optional[str]: 媒体文件 ID。\n        \"\"\"\n        return self.get('MediaId')\n\n    @property\n    def timestamp(self) -> Optional[int]:\n        \"\"\"\n        事件发生的时间戳。\n\n        Returns:\n            Optional[int]: 时间戳。\n        \"\"\"\n        return self.get('CreateTime')\n\n    @property\n    def event_key(self) -> Optional[str]:\n        \"\"\"\n        事件的 Key 值，例如点击菜单时的 `EventKey`。\n\n        Returns:\n            Optional[str]: 事件 Key。\n        \"\"\"\n        return self.get('EventKey')\n\n    def __getattr__(self, key: str) -> Optional[Any]:\n        \"\"\"\n        允许通过属性访问数据中的任意字段。\n\n        Args:\n            key (str): 字段名。\n\n        Returns:\n            Optional[Any]: 字段值。\n        \"\"\"\n        return self.get(key)\n\n    def __setattr__(self, key: str, value: Any) -> None:\n        \"\"\"\n        允许通过属性设置数据中的任意字段。\n\n        Args:\n            key (str): 字段名。\n            value (Any): 字段值。\n        \"\"\"\n        self[key] = value\n\n    def __repr__(self) -> str:\n        \"\"\"\n        生成事件对象的字符串表示。\n\n        Returns:\n            str: 字符串表示。\n        \"\"\"\n        return f'<WecomEvent {super().__repr__()}>'\n"
  },
  {
    "path": "src/langbot/libs/wecom_customer_service_api/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/libs/wecom_customer_service_api/api.py",
    "content": "from quart import request\nfrom ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt\nimport base64\nimport binascii\nimport httpx\nimport traceback\nfrom quart import Quart\nimport xml.etree.ElementTree as ET\nfrom typing import Callable\nfrom .wecomcsevent import WecomCSEvent\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport aiofiles\nimport time\n\n\nclass WecomCSClient:\n    def __init__(\n        self,\n        corpid: str,\n        secret: str,\n        token: str,\n        EncodingAESKey: str,\n        logger: None,\n        unified_mode: bool = False,\n        api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin',\n    ):\n        self.corpid = corpid\n        self.secret = secret\n        self.access_token_for_contacts = ''\n        self.token = token\n        self.aes = EncodingAESKey\n        self.base_url = api_base_url\n        self.access_token = ''\n        self.logger = logger\n        self.unified_mode = unified_mode\n        self.app = Quart(__name__)\n\n        # Customer info cache: {external_userid: (info_dict, timestamp)}\n        self._customer_cache: dict[str, tuple[dict, float]] = {}\n        self._cache_ttl = 60  # Cache TTL in seconds (1 minute)\n\n        # 只有在非统一模式下才注册独立路由\n        if not self.unified_mode:\n            self.app.add_url_rule(\n                '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']\n            )\n\n        self._message_handlers = {\n            'example': [],\n        }\n\n    async def get_pic_url(self, media_id: str):\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n\n        url = f'{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}'\n\n        async with httpx.AsyncClient() as client:\n            response = await client.get(url)\n            if response.headers.get('Content-Type', '').startswith('application/json'):\n                data = response.json()\n                if data.get('errcode') in [40014, 42001]:\n                    self.access_token = await self.get_access_token(self.secret)\n                    return await self.get_pic_url(media_id)\n                else:\n                    raise Exception('Failed to get image: ' + str(data))\n\n            # 否则是图片，转成 base64\n            image_bytes = response.content\n            content_type = response.headers.get('Content-Type', '')\n            base64_str = base64.b64encode(image_bytes).decode('utf-8')\n            base64_str = f'data:{content_type};base64,{base64_str}'\n            return base64_str\n\n    # access——token操作\n    async def check_access_token(self):\n        return bool(self.access_token and self.access_token.strip())\n\n    async def check_access_token_for_contacts(self):\n        return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())\n\n    async def get_access_token(self, secret):\n        url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'\n        async with httpx.AsyncClient() as client:\n            response = await client.get(url)\n            data = response.json()\n            if 'access_token' in data:\n                return data['access_token']\n            else:\n                raise Exception(f'未获取access token: {data}')\n\n    async def get_detailed_message_list(self, xml_msg: str):\n        # 在本方法中解析消息，并且获得消息的具体内容\n        if isinstance(xml_msg, bytes):\n            xml_msg = xml_msg.decode('utf-8')\n        root = ET.fromstring(xml_msg)\n        token = root.find('Token').text\n        open_kfid = root.find('OpenKfId').text\n\n        # if open_kfid in self.openkfid_list:\n        #     return None\n        # else:\n        #     self.openkfid_list.append(open_kfid)\n\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n\n        url = self.base_url + '/kf/sync_msg?access_token=' + self.access_token\n        async with httpx.AsyncClient() as client:\n            params = {\n                'token': token,\n                'voice_format': 0,\n                'open_kfid': open_kfid,\n            }\n            response = await client.post(url, json=params)\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                return await self.get_detailed_message_list(xml_msg)\n            if data['errcode'] != 0:\n                raise Exception('Failed to get message')\n\n            last_msg_data = data['msg_list'][-1]\n            open_kfid = last_msg_data.get('open_kfid')\n            # 进行获取图片操作\n            if last_msg_data.get('msgtype') == 'image':\n                media_id = last_msg_data.get('image').get('media_id')\n                picurl = await self.get_pic_url(media_id)\n                last_msg_data['picurl'] = picurl\n            # await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer)\n            return last_msg_data\n\n    async def change_service_status(self, userid: str, openkfid: str, servicer: str):\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n        url = self.base_url + '/kf/service_state/get?access_token=' + self.access_token\n        async with httpx.AsyncClient() as client:\n            params = {\n                'open_kfid': openkfid,\n                'external_userid': userid,\n                'service_state': 1,\n                'servicer_userid': servicer,\n            }\n            response = await client.post(url, json=params)\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                return await self.change_service_status(userid, openkfid)\n            if data['errcode'] != 0:\n                raise Exception('Failed to change service status: ' + str(data))\n\n    async def send_image(self, user_id: str, agent_id: int, media_id: str):\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n        url = self.base_url + '/media/upload?access_token=' + self.access_token\n        async with httpx.AsyncClient() as client:\n            params = {\n                'touser': user_id,\n                'toparty': '',\n                'totag': '',\n                'agentid': agent_id,\n                'msgtype': 'image',\n                'image': {\n                    'media_id': media_id,\n                },\n                'safe': 0,\n                'enable_id_trans': 0,\n                'enable_duplicate_check': 0,\n                'duplicate_check_interval': 1800,\n            }\n            try:\n                response = await client.post(url, json=params)\n                data = response.json()\n            except Exception as e:\n                raise Exception('Failed to send image: ' + str(e))\n\n            # 企业微信错误码40014和42001，代表accesstoken问题\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                return await self.send_image(user_id, agent_id, media_id)\n\n            if data['errcode'] != 0:\n                raise Exception('Failed to send image: ' + str(data))\n\n    async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, content: str):\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n\n        url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'\n\n        payload = {\n            'touser': external_userid,\n            'open_kfid': open_kfid,\n            'msgid': msgid,\n            'msgtype': 'text',\n            'text': {\n                'content': content,\n            },\n        }\n\n        async with httpx.AsyncClient() as client:\n            response = await client.post(url, json=payload)\n\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                return await self.send_text_msg(open_kfid, external_userid, msgid, content)\n            if data['errcode'] != 0:\n                await self.logger.error(f'发送消息失败：{data}')\n                raise Exception('Failed to send message')\n            return data\n\n    async def handle_callback_request(self):\n        \"\"\"处理回调请求（独立端口模式，使用全局 request）。\"\"\"\n        return await self._handle_callback_internal(request)\n\n    async def handle_unified_webhook(self, req):\n        \"\"\"处理回调请求（统一 webhook 模式，显式传递 request）。\n\n        Args:\n            req: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self._handle_callback_internal(req)\n\n    async def _handle_callback_internal(self, req):\n        \"\"\"\n        处理回调请求的内部实现，包括 GET 验证和 POST 消息接收。\n\n        Args:\n            req: Quart Request 对象\n        \"\"\"\n        try:\n            msg_signature = req.args.get('msg_signature')\n            timestamp = req.args.get('timestamp')\n            nonce = req.args.get('nonce')\n            try:\n                wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)\n            except Exception as e:\n                raise Exception(f'初始化失败，错误码: {e}')\n\n            if req.method == 'GET':\n                echostr = req.args.get('echostr')\n                ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)\n                if ret != 0:\n                    raise Exception(f'验证失败，错误码: {ret}')\n                return reply_echo_str\n\n            elif req.method == 'POST':\n                encrypt_msg = await req.data\n                ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)\n                if ret != 0:\n                    raise Exception(f'消息解密失败，错误码: {ret}')\n\n                # 解析消息并处理\n                message_data = await self.get_detailed_message_list(xml_msg)\n                if message_data is not None:\n                    event = WecomCSEvent.from_payload(message_data)\n                    if event:\n                        await self._handle_message(event)\n\n                return 'success'\n        except Exception as e:\n            if self.logger:\n                await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')\n            else:\n                traceback.print_exc()\n            return f'Error processing request: {str(e)}', 400\n\n    async def run_task(self, host: str, port: int, *args, **kwargs):\n        \"\"\"\n        启动 Quart 应用。\n        \"\"\"\n        await self.app.run_task(host=host, port=port, *args, **kwargs)\n\n    def on_message(self, msg_type: str):\n        \"\"\"\n        注册消息类型处理器。\n        \"\"\"\n\n        def decorator(func: Callable[[WecomCSEvent], None]):\n            if msg_type not in self._message_handlers:\n                self._message_handlers[msg_type] = []\n            self._message_handlers[msg_type].append(func)\n            return func\n\n        return decorator\n\n    async def _handle_message(self, event: WecomCSEvent):\n        \"\"\"\n        处理消息事件。\n        \"\"\"\n        msg_type = event.type\n        if msg_type in self._message_handlers:\n            for handler in self._message_handlers[msg_type]:\n                await handler(event)\n\n    @staticmethod\n    async def get_image_type(image_bytes: bytes) -> str:\n        \"\"\"\n        通过图片的magic numbers判断图片类型\n        \"\"\"\n        magic_numbers = {\n            b'\\xff\\xd8\\xff': 'jpg',\n            b'\\x89\\x50\\x4e\\x47': 'png',\n            b'\\x47\\x49\\x46': 'gif',\n            b'\\x42\\x4d': 'bmp',\n            b'\\x00\\x00\\x01\\x00': 'ico',\n        }\n\n        for magic, ext in magic_numbers.items():\n            if image_bytes.startswith(magic):\n                return ext\n        return 'jpg'  # 默认返回jpg\n\n    async def upload_to_work(self, image: platform_message.Image):\n        \"\"\"\n        获取 media_id\n        \"\"\"\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n\n        url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'\n        file_bytes = None\n        file_name = 'uploaded_file.txt'\n\n        # 获取文件的二进制数据\n        if image.path:\n            async with aiofiles.open(image.path, 'rb') as f:\n                file_bytes = await f.read()\n                file_name = image.path.split('/')[-1]\n        elif image.url:\n            file_bytes = await self.download_image_to_bytes(image.url)\n            file_name = image.url.split('/')[-1]\n        elif image.base64:\n            try:\n                base64_data = image.base64\n                if ',' in base64_data:\n                    base64_data = base64_data.split(',', 1)[1]\n                padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0\n                padded_base64 = base64_data + '=' * padding\n                file_bytes = base64.b64decode(padded_base64)\n            except binascii.Error as e:\n                raise ValueError(f'Invalid base64 string: {str(e)}')\n        else:\n            raise ValueError('image对象出错')\n\n        # 设置 multipart/form-data 格式的文件\n        boundary = '-------------------------acebdf13572468'\n        headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}\n        body = (\n            (\n                f'--{boundary}\\r\\n'\n                f'Content-Disposition: form-data; name=\"media\"; filename=\"{file_name}\"; filelength={len(file_bytes)}\\r\\n'\n                f'Content-Type: application/octet-stream\\r\\n\\r\\n'\n            ).encode('utf-8')\n            + file_bytes\n            + f'\\r\\n--{boundary}--\\r\\n'.encode('utf-8')\n        )\n\n        # 上传文件\n        async with httpx.AsyncClient() as client:\n            response = await client.post(url, headers=headers, content=body)\n            data = response.json()\n            if data['errcode'] == 40014 or data['errcode'] == 42001:\n                self.access_token = await self.get_access_token(self.secret)\n                media_id = await self.upload_to_work(image)\n            if data.get('errcode', 0) != 0:\n                raise Exception('failed to upload file')\n\n            media_id = data.get('media_id')\n            return media_id\n\n    async def download_image_to_bytes(self, url: str) -> bytes:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(url)\n            response.raise_for_status()\n            return response.content\n\n    # 进行media_id的获取\n    async def get_media_id(self, image: platform_message.Image):\n        media_id = await self.upload_to_work(image=image)\n        return media_id\n\n    async def get_customer_info(self, external_userid: str) -> dict | None:\n        \"\"\"\n        Get customer information by external_userid with caching.\n\n        Uses a 1-minute cache to avoid repeated API calls for the same user.\n\n        Args:\n            external_userid: The external user ID of the customer.\n\n        Returns:\n            Customer info dict with 'nickname', 'avatar', etc., or None if not found.\n        \"\"\"\n        # Check cache first\n        current_time = time.time()\n        if external_userid in self._customer_cache:\n            cached_info, cached_time = self._customer_cache[external_userid]\n            if current_time - cached_time < self._cache_ttl:\n                return cached_info\n\n        # Cache miss or expired, fetch from API\n        if not await self.check_access_token():\n            self.access_token = await self.get_access_token(self.secret)\n\n        url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}'\n\n        payload = {\n            'external_userid_list': [external_userid],\n        }\n\n        async with httpx.AsyncClient() as client:\n            response = await client.post(url, json=payload)\n            data = response.json()\n\n            if data.get('errcode') in [40014, 42001]:\n                self.access_token = await self.get_access_token(self.secret)\n                return await self.get_customer_info(external_userid)\n\n            if data.get('errcode', 0) != 0:\n                if self.logger:\n                    await self.logger.warning(f'Failed to get customer info: {data}')\n                return None\n\n            customer_list = data.get('customer_list', [])\n            if customer_list:\n                customer_info = customer_list[0]\n                # Store in cache\n                self._customer_cache[external_userid] = (customer_info, current_time)\n                return customer_info\n            return None\n"
  },
  {
    "path": "src/langbot/libs/wecom_customer_service_api/wecomcsevent.py",
    "content": "from typing import Dict, Any, Optional\n\n\nclass WecomCSEvent(dict):\n    \"\"\"\n    封装从企业微信收到的事件数据对象（字典），提供属性以获取其中的字段。\n\n    除 `type` 和 `detail_type` 属性对于任何事件都有效外，其它属性是否存在（若不存在则返回 `None`）依事件类型不同而不同。\n    \"\"\"\n\n    @staticmethod\n    def from_payload(payload: Dict[str, Any]) -> Optional['WecomCSEvent']:\n        \"\"\"\n        从企业微信(客服会话)事件数据构造 `WecomEvent` 对象。\n\n        Args:\n            payload (Dict[str, Any]): 解密后的企业微信事件数据。\n\n        Returns:\n            Optional[WecomEvent]: 如果事件数据合法，则返回 WecomEvent 对象；否则返回 None。\n        \"\"\"\n        try:\n            event = WecomCSEvent(payload)\n            _ = (event.type,)\n            return event\n        except KeyError:\n            return None\n\n    @property\n    def type(self) -> str:\n        \"\"\"\n        事件类型，例如 \"message\"、\"event\"、\"text\" 等。\n\n        Returns:\n            str: 事件类型。\n        \"\"\"\n        return self.get('msgtype', '')\n\n    @property\n    def user_id(self) -> Optional[str]:\n        \"\"\"\n        用户 ID，例如消息的发送者或事件的触发者。\n\n        Returns:\n            Optional[str]: 用户 ID。\n        \"\"\"\n        return self.get('external_userid')\n\n    @property\n    def receiver_id(self) -> Optional[str]:\n        \"\"\"\n        接收者 ID，例如机器人自身的企业微信 ID。\n\n        Returns:\n            Optional[str]: 接收者 ID。\n        \"\"\"\n        return self.get('open_kfid', '')\n\n    @property\n    def picurl(self) -> Optional[str]:\n        \"\"\"\n        图片 URL，仅在图片消息中存在。\n        base64格式\n        Returns:\n            Optional[str]: 图片 URL。\n        \"\"\"\n\n        return self.get('picurl', '')\n\n    @property\n    def message_id(self) -> Optional[str]:\n        \"\"\"\n        消息 ID，仅在消息类型事件中存在。\n\n        Returns:\n            Optional[str]: 消息 ID。\n        \"\"\"\n        return self.get('msgid')\n\n    @property\n    def message(self) -> Optional[str]:\n        \"\"\"\n        消息内容，仅在消息类型事件中存在。\n\n        Returns:\n            Optional[str]: 消息内容。\n        \"\"\"\n        if self.get('msgtype') == 'text':\n            return self.get('text').get('content', '')\n        else:\n            return None\n\n    @property\n    def timestamp(self) -> Optional[int]:\n        \"\"\"\n        事件发生的时间戳。\n\n        Returns:\n            Optional[int]: 时间戳。\n        \"\"\"\n        return self.get('send_time')\n\n    def __getattr__(self, key: str) -> Optional[Any]:\n        \"\"\"\n        允许通过属性访问数据中的任意字段。\n\n        Args:\n            key (str): 字段名。\n\n        Returns:\n            Optional[Any]: 字段值。\n        \"\"\"\n        return self.get(key)\n\n    def __setattr__(self, key: str, value: Any) -> None:\n        \"\"\"\n        允许通过属性设置数据中的任意字段。\n\n        Args:\n            key (str): 字段名。\n            value (Any): 字段值。\n        \"\"\"\n        self[key] = value\n\n    def __repr__(self) -> str:\n        \"\"\"\n        生成事件对象的字符串表示。\n\n        Returns:\n            str: 字符串表示。\n        \"\"\"\n        return f'<WecomEvent {super().__repr__()}>'\n"
  },
  {
    "path": "src/langbot/pkg/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/http/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/http/controller/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/http/controller/group.py",
    "content": "from __future__ import annotations\n\nimport abc\nimport typing\nimport enum\nimport quart\nimport traceback\nfrom quart.typing import RouteCallable\n\nfrom ....core import app\n\n# Maximum file upload size limit (10MB)\nMAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB\n\n\npreregistered_groups: list[type[RouterGroup]] = []\n\"\"\"Pre-registered list of RouterGroup\"\"\"\n\n\ndef group_class(name: str, path: str) -> typing.Callable[[typing.Type[RouterGroup]], typing.Type[RouterGroup]]:\n    \"\"\"注册一个 RouterGroup\"\"\"\n\n    def decorator(cls: typing.Type[RouterGroup]) -> typing.Type[RouterGroup]:\n        cls.name = name\n        cls.path = path\n        preregistered_groups.append(cls)\n        return cls\n\n    return decorator\n\n\nclass AuthType(enum.Enum):\n    \"\"\"Authentication type\"\"\"\n\n    NONE = 'none'\n    USER_TOKEN = 'user-token'\n    API_KEY = 'api-key'\n    USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'\n\n\nclass RouterGroup(abc.ABC):\n    name: str\n\n    path: str\n\n    ap: app.Application\n\n    quart_app: quart.Quart\n\n    def __init__(self, ap: app.Application, quart_app: quart.Quart) -> None:\n        self.ap = ap\n        self.quart_app = quart_app\n\n    @abc.abstractmethod\n    async def initialize(self) -> None:\n        pass\n\n    def route(\n        self,\n        rule: str,\n        auth_type: AuthType = AuthType.USER_TOKEN,\n        **options: typing.Any,\n    ) -> typing.Callable[[RouteCallable], RouteCallable]:  # decorator\n        \"\"\"Register a route\"\"\"\n\n        def decorator(f: RouteCallable) -> RouteCallable:\n            nonlocal rule\n            rule = self.path + rule\n\n            async def handler_error(*args, **kwargs):\n                if auth_type == AuthType.USER_TOKEN:\n                    # get token from Authorization header\n                    token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')\n\n                    if not token:\n                        return self.http_status(401, -1, 'No valid user token provided')\n\n                    try:\n                        user_email = await self.ap.user_service.verify_jwt_token(token)\n\n                        # check if this account exists\n                        user = await self.ap.user_service.get_user_by_email(user_email)\n                        if not user:\n                            return self.http_status(401, -1, 'User not found')\n\n                        # check if f accepts user_email parameter\n                        if 'user_email' in f.__code__.co_varnames:\n                            kwargs['user_email'] = user_email\n                    except Exception as e:\n                        return self.http_status(401, -1, str(e))\n\n                elif auth_type == AuthType.API_KEY:\n                    # get API key from Authorization header or X-API-Key header\n                    api_key = quart.request.headers.get('X-API-Key', '')\n                    if not api_key:\n                        auth_header = quart.request.headers.get('Authorization', '')\n                        if auth_header.startswith('Bearer '):\n                            api_key = auth_header.replace('Bearer ', '')\n\n                    if not api_key:\n                        return self.http_status(401, -1, 'No valid API key provided')\n\n                    try:\n                        is_valid = await self.ap.apikey_service.verify_api_key(api_key)\n                        if not is_valid:\n                            return self.http_status(401, -1, 'Invalid API key')\n                    except Exception as e:\n                        return self.http_status(401, -1, str(e))\n\n                elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:\n                    # Try API key first (check X-API-Key header)\n                    api_key = quart.request.headers.get('X-API-Key', '')\n\n                    if api_key:\n                        # API key authentication\n                        try:\n                            is_valid = await self.ap.apikey_service.verify_api_key(api_key)\n                            if not is_valid:\n                                return self.http_status(401, -1, 'Invalid API key')\n                        except Exception as e:\n                            return self.http_status(401, -1, str(e))\n                    else:\n                        # Try user token authentication (Authorization header)\n                        token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')\n\n                        if not token:\n                            return self.http_status(\n                                401, -1, 'No valid authentication provided (user token or API key required)'\n                            )\n\n                        try:\n                            user_email = await self.ap.user_service.verify_jwt_token(token)\n\n                            # check if this account exists\n                            user = await self.ap.user_service.get_user_by_email(user_email)\n                            if not user:\n                                return self.http_status(401, -1, 'User not found')\n\n                            # check if f accepts user_email parameter\n                            if 'user_email' in f.__code__.co_varnames:\n                                kwargs['user_email'] = user_email\n                        except Exception:\n                            # If user token fails, maybe it's an API key in Authorization header\n                            try:\n                                is_valid = await self.ap.apikey_service.verify_api_key(token)\n                                if not is_valid:\n                                    return self.http_status(401, -1, 'Invalid authentication credentials')\n                            except Exception as e:\n                                return self.http_status(401, -1, str(e))\n\n                try:\n                    return await f(*args, **kwargs)\n\n                except Exception as e:  # 自动 500\n                    traceback.print_exc()\n                    # return self.http_status(500, -2, str(e))\n                    return self.http_status(500, -2, str(e))\n\n            new_f = handler_error\n            new_f.__name__ = (self.name + rule).replace('/', '__')\n            new_f.__doc__ = f.__doc__\n\n            self.quart_app.route(rule, **options)(new_f)\n            return f\n\n        return decorator\n\n    def success(self, data: typing.Any = None) -> quart.Response:\n        \"\"\"Return a 200 response\"\"\"\n        return quart.jsonify(\n            {\n                'code': 0,\n                'msg': 'ok',\n                'data': data,\n            }\n        )\n\n    def fail(self, code: int, msg: str) -> quart.Response:\n        \"\"\"Return an error response\"\"\"\n\n        return quart.jsonify(\n            {\n                'code': code,\n                'msg': msg,\n            }\n        )\n\n    def http_status(self, status: int, code: int, msg: str) -> typing.Tuple[quart.Response, int]:\n        \"\"\"返回一个指定状态码的响应\"\"\"\n        return (self.fail(code, msg), status)\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/apikeys.py",
    "content": "import quart\n\nfrom .. import group\n\n\n@group.group_class('apikeys', '/api/v1/apikeys')\nclass ApiKeysRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET', 'POST'])\n        async def _() -> str:\n            if quart.request.method == 'GET':\n                keys = await self.ap.apikey_service.get_api_keys()\n                return self.success(data={'keys': keys})\n            elif quart.request.method == 'POST':\n                json_data = await quart.request.json\n                name = json_data.get('name', '')\n                description = json_data.get('description', '')\n\n                if not name:\n                    return self.http_status(400, -1, 'Name is required')\n\n                key = await self.ap.apikey_service.create_api_key(name, description)\n                return self.success(data={'key': key})\n\n        @self.route('/<int:key_id>', methods=['GET', 'PUT', 'DELETE'])\n        async def _(key_id: int) -> str:\n            if quart.request.method == 'GET':\n                key = await self.ap.apikey_service.get_api_key(key_id)\n                if key is None:\n                    return self.http_status(404, -1, 'API key not found')\n                return self.success(data={'key': key})\n\n            elif quart.request.method == 'PUT':\n                json_data = await quart.request.json\n                name = json_data.get('name')\n                description = json_data.get('description')\n\n                await self.ap.apikey_service.update_api_key(key_id, name, description)\n                return self.success()\n\n            elif quart.request.method == 'DELETE':\n                await self.ap.apikey_service.delete_api_key(key_id)\n                return self.success()\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/files.py",
    "content": "from __future__ import annotations\n\nimport quart\nimport mimetypes\nimport uuid\nimport asyncio\n\nimport quart.datastructures\n\nfrom .. import group\n\n\n@group.group_class('files', '/api/v1/files')\nclass FilesRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)\n        async def _(image_key: str) -> quart.Response:\n            if '/' in image_key or '\\\\' in image_key:\n                return quart.Response(status=404)\n\n            if not await self.ap.storage_mgr.storage_provider.exists(image_key):\n                return quart.Response(status=404)\n\n            image_bytes = await self.ap.storage_mgr.storage_provider.load(image_key)\n            mime_type = mimetypes.guess_type(image_key)[0]\n            if mime_type is None:\n                mime_type = 'image/jpeg'\n\n            return quart.Response(image_bytes, mimetype=mime_type)\n\n        @self.route('/images', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def upload_image() -> quart.Response:\n            request = quart.request\n\n            # Check file size limit before reading the file\n            content_length = request.content_length\n            if content_length and content_length > group.MAX_FILE_SIZE:\n                return self.fail(400, 'Image size exceeds 10MB limit.')\n\n            # get file bytes from 'file'\n            files = await request.files\n            if 'file' not in files:\n                return self.fail(400, 'No image file provided')\n\n            file = files['file']\n            assert isinstance(file, quart.datastructures.FileStorage)\n\n            file_bytes = await asyncio.to_thread(file.stream.read)\n\n            # Double-check actual file size after reading\n            if len(file_bytes) > group.MAX_FILE_SIZE:\n                return self.fail(400, 'Image size exceeds 10MB limit.')\n\n            # Validate image file extension\n            allowed_extensions = {'jpg', 'jpeg', 'png', 'gif', 'webp'}\n            if '.' in file.filename:\n                file_name, extension = file.filename.rsplit('.', 1)\n                extension = extension.lower()\n            else:\n                return self.fail(400, 'Invalid image file: no file extension')\n\n            if extension not in allowed_extensions:\n                return self.fail(400, f'Invalid image format. Allowed formats: {\", \".join(allowed_extensions)}')\n\n            # check if file name contains '/' or '\\'\n            if '/' in file_name or '\\\\' in file_name:\n                return self.fail(400, 'File name contains invalid characters')\n\n            file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension\n\n            # save file to storage\n            await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)\n            return self.success(\n                data={\n                    'file_key': file_key,\n                }\n            )\n\n        @self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def upload_document() -> quart.Response:\n            request = quart.request\n\n            # Check file size limit before reading the file\n            content_length = request.content_length\n            if content_length and content_length > group.MAX_FILE_SIZE:\n                return self.fail(400, 'File size exceeds 10MB limit. Please split large files into smaller parts.')\n\n            # get file bytes from 'file'\n            files = await request.files\n            if 'file' not in files:\n                return self.fail(400, 'No file provided in request')\n\n            file = files['file']\n            assert isinstance(file, quart.datastructures.FileStorage)\n\n            file_bytes = await asyncio.to_thread(file.stream.read)\n\n            # Double-check actual file size after reading\n            if len(file_bytes) > group.MAX_FILE_SIZE:\n                return self.fail(400, 'File size exceeds 10MB limit. Please split large files into smaller parts.')\n\n            # Split filename and extension properly\n            if '.' in file.filename:\n                file_name, extension = file.filename.rsplit('.', 1)\n            else:\n                file_name = file.filename\n                extension = ''\n\n            # check if file name contains '/' or '\\'\n            if '/' in file_name or '\\\\' in file_name:\n                return self.fail(400, 'File name contains invalid characters')\n\n            file_key = file_name + '_' + str(uuid.uuid4())[:8]\n            if extension:\n                file_key += '.' + extension\n\n            # save file to storage\n            await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)\n            return self.success(\n                data={\n                    'file_id': file_key,\n                }\n            )\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/knowledge/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/knowledge/base.py",
    "content": "import quart\nfrom ... import group\n\n\n@group.group_class('knowledge_base', '/api/v1/knowledge/bases')\nclass KnowledgeBaseRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['POST', 'GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def handle_knowledge_bases() -> quart.Response:\n            if quart.request.method == 'GET':\n                knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases()\n                return self.success(data={'bases': knowledge_bases})\n\n            elif quart.request.method == 'POST':\n                json_data = await quart.request.json\n                try:\n                    knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(json_data)\n                except ValueError as e:\n                    return self.http_status(400, -1, str(e))\n                return self.success(data={'uuid': knowledge_base_uuid})\n\n            return self.http_status(405, -1, 'Method not allowed')\n\n        @self.route(\n            '/<knowledge_base_uuid>',\n            methods=['GET', 'DELETE', 'PUT'],\n            auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,\n        )\n        async def handle_specific_knowledge_base(knowledge_base_uuid: str) -> quart.Response:\n            if quart.request.method == 'GET':\n                knowledge_base = await self.ap.knowledge_service.get_knowledge_base(knowledge_base_uuid)\n\n                if knowledge_base is None:\n                    return self.http_status(404, -1, 'knowledge base not found')\n\n                return self.success(\n                    data={\n                        'base': knowledge_base,\n                    }\n                )\n\n            elif quart.request.method == 'PUT':\n                json_data = await quart.request.json\n                await self.ap.knowledge_service.update_knowledge_base(knowledge_base_uuid, json_data)\n                return self.success(data={'uuid': knowledge_base_uuid})\n\n            elif quart.request.method == 'DELETE':\n                await self.ap.knowledge_service.delete_knowledge_base(knowledge_base_uuid)\n                return self.success({})\n\n        @self.route(\n            '/<knowledge_base_uuid>/files',\n            methods=['GET', 'POST'],\n            auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,\n        )\n        async def get_knowledge_base_files(knowledge_base_uuid: str) -> str:\n            if quart.request.method == 'GET':\n                files = await self.ap.knowledge_service.get_files_by_knowledge_base(knowledge_base_uuid)\n                return self.success(\n                    data={\n                        'files': files,\n                    }\n                )\n\n            elif quart.request.method == 'POST':\n                json_data = await quart.request.json\n                file_id = json_data.get('file_id')\n                if not file_id:\n                    return self.http_status(400, -1, 'File ID is required')\n\n                parser_plugin_id = json_data.get('parser_plugin_id')\n\n                # 调用服务层方法将文件与知识库关联\n                task_id = await self.ap.knowledge_service.store_file(\n                    knowledge_base_uuid, file_id, parser_plugin_id=parser_plugin_id\n                )\n                return self.success(\n                    {\n                        'task_id': task_id,\n                    }\n                )\n\n        @self.route(\n            '/<knowledge_base_uuid>/files/<file_id>',\n            methods=['DELETE'],\n            auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,\n        )\n        async def delete_specific_file_in_kb(file_id: str, knowledge_base_uuid: str) -> str:\n            await self.ap.knowledge_service.delete_file(knowledge_base_uuid, file_id)\n            return self.success({})\n\n        @self.route(\n            '/<knowledge_base_uuid>/retrieve',\n            methods=['POST'],\n            auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,\n        )\n        async def retrieve_knowledge_base(knowledge_base_uuid: str) -> str:\n            json_data = await quart.request.json\n            query = json_data.get('query')\n\n            if not query or not query.strip():\n                return self.http_status(400, -1, 'Query is required and cannot be empty')\n\n            # Extract retrieval_settings to allow dynamic control over Knowledge Engine behavior (e.g. top_k, filters)\n            retrieval_settings = json_data.get('retrieval_settings', {})\n            results = await self.ap.knowledge_service.retrieve_knowledge_base(\n                knowledge_base_uuid, query, retrieval_settings\n            )\n            return self.success(data={'results': results})\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/knowledge/engines.py",
    "content": "import quart\nfrom urllib.parse import unquote\nfrom ... import group\n\n\n@group.group_class('knowledge_engines', '/api/v1/knowledge/engines')\nclass KnowledgeEnginesRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def list_knowledge_engines() -> quart.Response:\n            \"\"\"List all available Knowledge Engines from plugins.\n\n            Returns a list of Knowledge Engines with their capabilities and configuration schemas.\n            This is used by the frontend to render the knowledge base creation wizard.\n            \"\"\"\n            engines = await self.ap.knowledge_service.list_knowledge_engines()\n            return self.success(data={'engines': engines})\n\n        @self.route(\n            '/<path:plugin_id>/creation-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY\n        )\n        async def get_engine_creation_schema(plugin_id: str) -> quart.Response:\n            \"\"\"Get creation settings schema for a specific Knowledge Engine.\n\n            plugin_id is in 'author/name' format, captured via <path:> converter.\n            \"\"\"\n            plugin_id = unquote(plugin_id)\n            if '/' not in plugin_id:\n                return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.')\n            schema = await self.ap.knowledge_service.get_engine_creation_schema(plugin_id)\n            return self.success(data={'schema': schema})\n\n        @self.route(\n            '/<path:plugin_id>/retrieval-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY\n        )\n        async def get_engine_retrieval_schema(plugin_id: str) -> quart.Response:\n            \"\"\"Get retrieval settings schema for a specific Knowledge Engine.\n\n            plugin_id is in 'author/name' format, captured via <path:> converter.\n            \"\"\"\n            plugin_id = unquote(plugin_id)\n            if '/' not in plugin_id:\n                return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.')\n            schema = await self.ap.knowledge_service.get_engine_retrieval_schema(plugin_id)\n            return self.success(data={'schema': schema})\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/knowledge/migration.py",
    "content": "import asyncio\nimport json\n\nimport httpx\nimport quart\nimport sqlalchemy\n\nfrom ... import group\nfrom ......core import taskmgr\nfrom ......entity.persistence import metadata as persistence_metadata\nfrom langbot_plugin.runtime.plugin.mgr import PluginInstallSource\n\nLANGRAG_PLUGIN_AUTHOR = 'langbot-team'\nLANGRAG_PLUGIN_NAME = 'LangRAG'\nLANGRAG_PLUGIN_ID = f'{LANGRAG_PLUGIN_AUTHOR}/{LANGRAG_PLUGIN_NAME}'\nDEFAULT_SPACE_URL = 'https://space.langbot.app'\n\n# Old Retriever plugin_name -> New Connector plugin_name\nEXTERNAL_PLUGIN_NAME_MAPPING = {\n    'DifyDatasetsRetriever': 'DifyDatasetsConnector',\n    'RAGFlowRetriever': 'RAGFlowConnector',\n    'FastGPTRetriever': 'FastGPTConnector',\n}\n\n# Per-plugin: which old retriever_config fields belong to creation_settings.\n# Remaining fields go to retrieval_settings.\n# None means ALL fields go to creation_settings (no retrieval_schema).\nEXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = {\n    'langbot-team/DifyDatasetsConnector': {'api_base_url', 'dify_apikey', 'dataset_id'},\n    'langbot-team/RAGFlowConnector': {'api_base_url', 'api_key', 'dataset_ids'},\n    'langbot-team/FastGPTConnector': None,  # all fields -> creation_settings\n}\n\n\n@group.group_class('knowledge/migration', '/api/v1/knowledge/migration')\nclass KnowledgeMigrationRouterGroup(group.RouterGroup):\n    async def _get_migration_flag(self) -> bool:\n        \"\"\"Check if rag_plugin_migration_needed flag is set.\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_metadata.Metadata).where(\n                persistence_metadata.Metadata.key == 'rag_plugin_migration_needed'\n            )\n        )\n        row = result.first()\n        return row is not None and row.value == 'true'\n\n    async def _set_migration_flag(self, value: str):\n        \"\"\"Set rag_plugin_migration_needed flag.\"\"\"\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_metadata.Metadata)\n            .where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed')\n            .values(value=value)\n        )\n\n    async def _table_exists(self, table_name: str) -> bool:\n        \"\"\"Check if a table exists.\"\"\"\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\n                    'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'\n                ).bindparams(table_name=table_name)\n            )\n            return result.scalar()\n        else:\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\"SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;\").bindparams(\n                    table_name=table_name\n                )\n            )\n            return result.first() is not None\n\n    async def _install_plugin_from_marketplace(\n        self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str\n    ) -> None:\n        \"\"\"Install a single plugin from the marketplace.\"\"\"\n        p_author, p_name = plugin_id.split('/', 1)\n        self.ap.logger.info(f'RAG migration: installing plugin {plugin_id} from marketplace...')\n        task_context.trace(f'Installing plugin {plugin_id} from marketplace...')\n\n        async with httpx.AsyncClient(trust_env=True, timeout=15) as client:\n            resp = await client.get(f'{space_url}/api/v1/marketplace/plugins/{p_author}/{p_name}')\n            resp.raise_for_status()\n            p_data = resp.json().get('data', {}).get('plugin', {})\n            p_version = p_data.get('latest_version')\n            if not p_version:\n                raise Exception(f'Could not determine latest version for {plugin_id}')\n\n        await self.ap.plugin_connector.install_plugin(\n            PluginInstallSource.MARKETPLACE,\n            {\n                'plugin_author': p_author,\n                'plugin_name': p_name,\n                'plugin_version': p_version,\n            },\n            task_context=task_context,\n        )\n        self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.')\n\n    async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True):\n        \"\"\"Execute RAG migration: install required plugins and restore backup data.\"\"\"\n        warnings = []\n\n        # Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs)\n        needed_plugins: dict[str, str] = {\n            LANGRAG_PLUGIN_ID: LANGRAG_PLUGIN_NAME,\n        }\n\n        has_external = await self._table_exists('external_knowledge_bases')\n        if has_external:\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('SELECT DISTINCT plugin_author, plugin_name FROM external_knowledge_bases;')\n            )\n            for row in result.fetchall():\n                plugin_author = row[0] or ''\n                plugin_name = row[1] or ''\n                mapped_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)\n                plugin_id = f'{plugin_author}/{mapped_name}'\n                if plugin_id not in needed_plugins:\n                    needed_plugins[plugin_id] = mapped_name\n\n        self.ap.logger.info(f'RAG migration: plugins needed: {list(needed_plugins.keys())}')\n\n        if install_plugin:\n            # Step 1: Install all required plugins from marketplace\n            task_context.trace('Installing required plugins...', action='install-plugin')\n            space_url = self.ap.instance_config.data.get('space', {}).get('url', DEFAULT_SPACE_URL).rstrip('/')\n\n            for plugin_id in needed_plugins:\n                try:\n                    await self._install_plugin_from_marketplace(plugin_id, task_context, space_url)\n                except Exception as e:\n                    self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}')\n                    task_context.trace(f'Plugin install note ({plugin_id}): {e}')\n\n            # Step 2: Wait for all plugins to become available as knowledge engines\n            task_context.trace(\n                f'Waiting for plugins to become available: {list(needed_plugins.keys())}...',\n                action='wait-plugin',\n            )\n            max_retries = 30\n            engine_id_set: set[str] = set()\n            for i in range(max_retries):\n                try:\n                    engines = await self.ap.plugin_connector.list_knowledge_engines()\n                    engine_id_set = {e.get('plugin_id') for e in engines}\n                except Exception:\n                    pass\n                if all(pid in engine_id_set for pid in needed_plugins):\n                    self.ap.logger.info(f'RAG migration: all plugins ready: {engine_id_set}')\n                    task_context.trace('All required plugins are ready.')\n                    break\n                if i == max_retries - 1:\n                    still_missing = [pid for pid in needed_plugins if pid not in engine_id_set]\n                    warning = f'Plugin(s) {still_missing} did not become available after {max_retries} retries'\n                    self.ap.logger.warning(f'RAG migration: {warning}')\n                    warnings.append(warning)\n                    task_context.trace(warning)\n                await asyncio.sleep(2)\n        else:\n            try:\n                engines = await self.ap.plugin_connector.list_knowledge_engines()\n                engine_id_set = {e.get('plugin_id') for e in engines}\n            except Exception:\n                engine_id_set = set()\n\n        # Step 3: Restore internal knowledge bases from backup\n        task_context.trace('Restoring internal knowledge bases...', action='restore-internal')\n        if await self._table_exists('knowledge_bases_backup'):\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('SELECT * FROM knowledge_bases_backup;')\n            )\n            rows = result.fetchall()\n            columns = result.keys()\n\n            for row in rows:\n                row_dict = dict(zip(columns, row))\n                kb_uuid = row_dict.get('uuid')\n                name = row_dict.get('name', 'Untitled')\n                description = row_dict.get('description', '')\n                emoji = row_dict.get('emoji', '\\U0001f4da')\n                embedding_model_uuid = row_dict.get('embedding_model_uuid', '')\n                top_k = row_dict.get('top_k', 5)\n                created_at = row_dict.get('created_at')\n                updated_at = row_dict.get('updated_at')\n\n                creation_settings = json.dumps({'embedding_model_uuid': embedding_model_uuid})\n                retrieval_settings = json.dumps({'top_k': top_k})\n\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'INSERT INTO knowledge_bases '\n                        '(uuid, name, description, emoji, created_at, updated_at, '\n                        'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '\n                        'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '\n                        ':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'\n                    ).bindparams(\n                        uuid=kb_uuid,\n                        name=name,\n                        description=description,\n                        emoji=emoji,\n                        created_at=created_at,\n                        updated_at=updated_at,\n                        plugin_id=LANGRAG_PLUGIN_ID,\n                        collection_id=kb_uuid,\n                        creation_settings=creation_settings,\n                        retrieval_settings=retrieval_settings,\n                    )\n                )\n\n                try:\n                    config = {'embedding_model_uuid': embedding_model_uuid}\n                    await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config)\n                    task_context.trace(f'Restored internal KB: {name} ({kb_uuid})')\n                except Exception as e:\n                    warning = f'Failed to notify plugin for KB {name} ({kb_uuid}): {e}'\n                    warnings.append(warning)\n                    task_context.trace(warning)\n\n            await self.ap.rag_mgr.load_knowledge_bases_from_db()\n\n        # Step 4: Restore external knowledge bases\n        task_context.trace('Restoring external knowledge bases...', action='restore-external')\n        if has_external:\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('SELECT * FROM external_knowledge_bases;')\n            )\n            rows = result.fetchall()\n            columns = result.keys()\n\n            self.ap.logger.info(\n                f'RAG migration: {len(rows)} external KB(s) to restore. Available engines: {engine_id_set}'\n            )\n            task_context.trace(f'Found {len(rows)} external KB(s). Available engines: {engine_id_set}')\n\n            for row in rows:\n                row_dict = dict(zip(columns, row))\n                kb_uuid = row_dict.get('uuid')\n                name = row_dict.get('name', 'Untitled')\n                description = row_dict.get('description', '')\n                emoji = row_dict.get('emoji', '\\U0001f517')\n                plugin_author = row_dict.get('plugin_author', '')\n                plugin_name = row_dict.get('plugin_name', '')\n                retriever_config = row_dict.get('retriever_config', {})\n                created_at = row_dict.get('created_at')\n\n                mapped_plugin_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)\n                external_plugin_id = f'{plugin_author}/{mapped_plugin_name}'\n\n                self.ap.logger.info(\n                    f'RAG migration: processing external KB \"{name}\" ({kb_uuid}), '\n                    f'plugin: {plugin_author}/{plugin_name} -> {external_plugin_id}'\n                )\n\n                if isinstance(retriever_config, str):\n                    try:\n                        retriever_config = json.loads(retriever_config)\n                    except (json.JSONDecodeError, TypeError):\n                        retriever_config = {}\n\n                creation_fields = EXTERNAL_PLUGIN_CREATION_FIELDS.get(external_plugin_id)\n                if creation_fields is None:\n                    creation_settings_dict = retriever_config\n                    retrieval_settings_dict = {}\n                else:\n                    creation_settings_dict = {k: v for k, v in retriever_config.items() if k in creation_fields}\n                    retrieval_settings_dict = {k: v for k, v in retriever_config.items() if k not in creation_fields}\n\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'INSERT INTO knowledge_bases '\n                        '(uuid, name, description, emoji, created_at, updated_at, '\n                        'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '\n                        'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '\n                        ':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'\n                    ).bindparams(\n                        uuid=kb_uuid,\n                        name=name,\n                        description=description,\n                        emoji=emoji,\n                        created_at=created_at,\n                        updated_at=created_at,\n                        plugin_id=external_plugin_id,\n                        collection_id=kb_uuid,\n                        creation_settings=json.dumps(creation_settings_dict),\n                        retrieval_settings=json.dumps(retrieval_settings_dict),\n                    )\n                )\n\n                if external_plugin_id not in engine_id_set:\n                    warning = (\n                        f'External KB \"{name}\" ({kb_uuid}) record saved, but plugin {external_plugin_id} '\n                        f'is not installed yet. Install the connector plugin to use it.'\n                    )\n                    warnings.append(warning)\n                    task_context.trace(warning)\n                else:\n                    try:\n                        await self.ap.plugin_connector.rag_on_kb_create(\n                            external_plugin_id, kb_uuid, creation_settings_dict\n                        )\n                        task_context.trace(f'Restored external KB: {name} ({kb_uuid})')\n                    except Exception as e:\n                        warning = f'Failed to notify plugin for external KB {name} ({kb_uuid}): {e}'\n                        warnings.append(warning)\n                        task_context.trace(warning)\n\n            await self.ap.rag_mgr.load_knowledge_bases_from_db()\n\n        # Step 5: Clear migration flag\n        await self._set_migration_flag('false')\n        task_context.trace('RAG migration completed.', action='done')\n\n        if warnings:\n            task_context.trace(f'Completed with {len(warnings)} warning(s).')\n\n    async def initialize(self) -> None:\n        @self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def _() -> str:\n            needed = await self._get_migration_flag()\n\n            internal_kb_count = 0\n            external_kb_count = 0\n\n            if needed:\n                if await self._table_exists('knowledge_bases_backup'):\n                    result = await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases_backup;')\n                    )\n                    internal_kb_count = result.scalar() or 0\n\n                if await self._table_exists('external_knowledge_bases'):\n                    result = await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')\n                    )\n                    external_kb_count = result.scalar() or 0\n\n            return self.success(\n                data={\n                    'needed': needed,\n                    'internal_kb_count': internal_kb_count,\n                    'external_kb_count': external_kb_count,\n                }\n            )\n\n        @self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)\n        async def _() -> str:\n            needed = await self._get_migration_flag()\n            if not needed:\n                return self.http_status(400, -1, 'RAG migration is not needed')\n\n            data = await quart.request.get_json(silent=True) or {}\n            install_plugin = data.get('install_plugin', True)\n\n            ctx = taskmgr.TaskContext.new()\n            wrapper = self.ap.task_mgr.create_user_task(\n                self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin),\n                kind='rag-migration',\n                name='rag-migration-execute',\n                label='Migrating knowledge bases to plugin architecture',\n                context=ctx,\n            )\n\n            return self.success(data={'task_id': wrapper.id})\n\n        @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)\n        async def _() -> str:\n            needed = await self._get_migration_flag()\n            if not needed:\n                return self.http_status(400, -1, 'RAG migration is not needed')\n\n            await self._set_migration_flag('false')\n            return self.success()\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py",
    "content": "import quart\nfrom ... import group\n\n\n@group.group_class('parsers', '/api/v1/knowledge/parsers')\nclass ParsersRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def list_parsers() -> quart.Response:\n            \"\"\"List all available parsers from plugins.\n\n            Optional query parameter `mime_type` to filter parsers by supported MIME type.\n            \"\"\"\n            mime_type = quart.request.args.get('mime_type')\n            parsers = await self.ap.knowledge_service.list_parsers(mime_type)\n            return self.success(data={'parsers': parsers})\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/logs.py",
    "content": "from __future__ import annotations\n\n\nimport quart\n\nfrom .. import group\n\n\n@group.group_class('logs', '/api/v1/logs')\nclass LogsRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def _() -> str:\n            start_page_number = int(quart.request.args.get('start_page_number', 0))\n            start_offset = int(quart.request.args.get('start_offset', 0))\n\n            logs_str, end_page_number, end_offset = self.ap.log_cache.get_log_by_pointer(\n                start_page_number=start_page_number, start_offset=start_offset\n            )\n\n            return self.success(\n                data={\n                    'logs': logs_str,\n                    'end_page_number': end_page_number,\n                    'end_offset': end_offset,\n                }\n            )\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/monitoring.py",
    "content": "from __future__ import annotations\n\nimport datetime\nimport quart\n\nfrom .. import group\n\n\ndef parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None:\n    \"\"\"Parse ISO 8601 datetime string, handling 'Z' suffix for UTC timezone\"\"\"\n    if not datetime_str:\n        return None\n    # Replace 'Z' with '+00:00' for Python 3.10 compatibility\n    if datetime_str.endswith('Z'):\n        datetime_str = datetime_str[:-1] + '+00:00'\n    dt = datetime.datetime.fromisoformat(datetime_str)\n    # Convert to UTC and remove timezone info to match database storage (which stores UTC as naive datetime)\n    if dt.tzinfo is not None:\n        # Convert to UTC and remove timezone info\n        dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)\n    return dt\n\n\n@group.group_class('monitoring', '/api/v1/monitoring')\nclass MonitoringRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('/overview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def get_overview() -> str:\n            \"\"\"Get overview metrics\"\"\"\n            # Parse query parameters\n            bot_ids = quart.request.args.getlist('botId')\n            pipeline_ids = quart.request.args.getlist('pipelineId')\n            start_time_str = quart.request.args.get('startTime')\n            end_time_str = quart.request.args.get('endTime')\n\n            # Parse datetime\n            start_time = parse_iso_datetime(start_time_str)\n            end_time = parse_iso_datetime(end_time_str)\n\n            metrics = await self.ap.monitoring_service.get_overview_metrics(\n                bot_ids=bot_ids if bot_ids else None,\n                pipeline_ids=pipeline_ids if pipeline_ids else None,\n                start_time=start_time,\n                end_time=end_time,\n            )\n\n            return self.success(data=metrics)\n\n        @self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def get_messages() -> str:\n            \"\"\"Get message logs\"\"\"\n            # Parse query parameters\n            bot_ids = quart.request.args.getlist('botId')\n            pipeline_ids = quart.request.args.getlist('pipelineId')\n            session_ids = quart.request.args.getlist('sessionId')\n            start_time_str = quart.request.args.get('startTime')\n            end_time_str = quart.request.args.get('endTime')\n            limit = int(quart.request.args.get('limit', 100))\n            offset = int(quart.request.args.get('offset', 0))\n\n            # Parse datetime\n            start_time = parse_iso_datetime(start_time_str)\n            end_time = parse_iso_datetime(end_time_str)\n\n            messages, total = await self.ap.monitoring_service.get_messages(\n                bot_ids=bot_ids if bot_ids else None,\n                pipeline_ids=pipeline_ids if pipeline_ids else None,\n                session_ids=session_ids if session_ids else None,\n                start_time=start_time,\n                end_time=end_time,\n                limit=limit,\n                offset=offset,\n            )\n\n            return self.success(\n                data={\n                    'messages': messages,\n                    'total': total,\n                    'limit': limit,\n                    'offset': offset,\n                }\n            )\n\n        @self.route('/llm-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def get_llm_calls() -> str:\n            \"\"\"Get LLM call records\"\"\"\n            # Parse query parameters\n            bot_ids = quart.request.args.getlist('botId')\n            pipeline_ids = quart.request.args.getlist('pipelineId')\n            start_time_str = quart.request.args.get('startTime')\n            end_time_str = quart.request.args.get('endTime')\n            limit = int(quart.request.args.get('limit', 100))\n            offset = int(quart.request.args.get('offset', 0))\n\n            # Parse datetime\n            start_time = parse_iso_datetime(start_time_str)\n            end_time = parse_iso_datetime(end_time_str)\n\n            llm_calls, total = await self.ap.monitoring_service.get_llm_calls(\n                bot_ids=bot_ids if bot_ids else None,\n                pipeline_ids=pipeline_ids if pipeline_ids else None,\n                start_time=start_time,\n                end_time=end_time,\n                limit=limit,\n                offset=offset,\n            )\n\n            return self.success(\n                data={\n                    'llm_calls': llm_calls,\n                    'total': total,\n                    'limit': limit,\n                    'offset': offset,\n                }\n            )\n\n        @self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def get_embedding_calls() -> str:\n            \"\"\"Get embedding call records\"\"\"\n            # Parse query parameters\n            start_time_str = quart.request.args.get('startTime')\n            end_time_str = quart.request.args.get('endTime')\n            knowledge_base_id = quart.request.args.get('knowledgeBaseId')\n            limit = int(quart.request.args.get('limit', 100))\n            offset = int(quart.request.args.get('offset', 0))\n\n            # Parse datetime\n            start_time = parse_iso_datetime(start_time_str)\n            end_time = parse_iso_datetime(end_time_str)\n\n            embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls(\n                start_time=start_time,\n                end_time=end_time,\n                knowledge_base_id=knowledge_base_id if knowledge_base_id else None,\n                limit=limit,\n                offset=offset,\n            )\n\n            return self.success(\n                data={\n                    'embedding_calls': embedding_calls,\n                    'total': total,\n                    'limit': limit,\n                    'offset': offset,\n                }\n            )\n\n        @self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def get_sessions() -> str:\n            \"\"\"Get session information\"\"\"\n            # Parse query parameters\n            bot_ids = quart.request.args.getlist('botId')\n            pipeline_ids = quart.request.args.getlist('pipelineId')\n            start_time_str = quart.request.args.get('startTime')\n            end_time_str = quart.request.args.get('endTime')\n            is_active_str = quart.request.args.get('isActive')\n            limit = int(quart.request.args.get('limit', 100))\n            offset = int(quart.request.args.get('offset', 0))\n\n            # Parse datetime\n            start_time = parse_iso_datetime(start_time_str)\n            end_time = parse_iso_datetime(end_time_str)\n\n            # Parse is_active\n            is_active = None\n            if is_active_str:\n                is_active = is_active_str.lower() == 'true'\n\n            sessions, total = await self.ap.monitoring_service.get_sessions(\n                bot_ids=bot_ids if bot_ids else None,\n                pipeline_ids=pipeline_ids if pipeline_ids else None,\n                start_time=start_time,\n                end_time=end_time,\n                is_active=is_active,\n                limit=limit,\n                offset=offset,\n            )\n\n            return self.success(\n                data={\n                    'sessions': sessions,\n                    'total': total,\n                    'limit': limit,\n                    'offset': offset,\n                }\n            )\n\n        @self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def get_errors() -> str:\n            \"\"\"Get error logs\"\"\"\n            # Parse query parameters\n            bot_ids = quart.request.args.getlist('botId')\n            pipeline_ids = quart.request.args.getlist('pipelineId')\n            start_time_str = quart.request.args.get('startTime')\n            end_time_str = quart.request.args.get('endTime')\n            limit = int(quart.request.args.get('limit', 100))\n            offset = int(quart.request.args.get('offset', 0))\n\n            # Parse datetime\n            start_time = parse_iso_datetime(start_time_str)\n            end_time = parse_iso_datetime(end_time_str)\n\n            errors, total = await self.ap.monitoring_service.get_errors(\n                bot_ids=bot_ids if bot_ids else None,\n                pipeline_ids=pipeline_ids if pipeline_ids else None,\n                start_time=start_time,\n                end_time=end_time,\n                limit=limit,\n                offset=offset,\n            )\n\n            return self.success(\n                data={\n                    'errors': errors,\n                    'total': total,\n                    'limit': limit,\n                    'offset': offset,\n                }\n            )\n\n        @self.route('/data', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def get_all_data() -> str:\n            \"\"\"Get all monitoring data in a single request\"\"\"\n            # Parse query parameters\n            bot_ids = quart.request.args.getlist('botId')\n            pipeline_ids = quart.request.args.getlist('pipelineId')\n            start_time_str = quart.request.args.get('startTime')\n            end_time_str = quart.request.args.get('endTime')\n            limit = int(quart.request.args.get('limit', 50))\n\n            # Parse datetime\n            start_time = parse_iso_datetime(start_time_str)\n            end_time = parse_iso_datetime(end_time_str)\n\n            # Get overview metrics\n            overview = await self.ap.monitoring_service.get_overview_metrics(\n                bot_ids=bot_ids if bot_ids else None,\n                pipeline_ids=pipeline_ids if pipeline_ids else None,\n                start_time=start_time,\n                end_time=end_time,\n            )\n\n            # Get messages\n            messages, messages_total = await self.ap.monitoring_service.get_messages(\n                bot_ids=bot_ids if bot_ids else None,\n                pipeline_ids=pipeline_ids if pipeline_ids else None,\n                start_time=start_time,\n                end_time=end_time,\n                limit=limit,\n                offset=0,\n            )\n\n            # Get LLM calls\n            llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls(\n                bot_ids=bot_ids if bot_ids else None,\n                pipeline_ids=pipeline_ids if pipeline_ids else None,\n                start_time=start_time,\n                end_time=end_time,\n                limit=limit,\n                offset=0,\n            )\n\n            # Get sessions\n            sessions, sessions_total = await self.ap.monitoring_service.get_sessions(\n                bot_ids=bot_ids if bot_ids else None,\n                pipeline_ids=pipeline_ids if pipeline_ids else None,\n                start_time=start_time,\n                end_time=end_time,\n                is_active=None,\n                limit=limit,\n                offset=0,\n            )\n\n            # Get errors\n            errors, errors_total = await self.ap.monitoring_service.get_errors(\n                bot_ids=bot_ids if bot_ids else None,\n                pipeline_ids=pipeline_ids if pipeline_ids else None,\n                start_time=start_time,\n                end_time=end_time,\n                limit=limit,\n                offset=0,\n            )\n\n            # Get embedding calls\n            embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls(\n                start_time=start_time,\n                end_time=end_time,\n                limit=limit,\n                offset=0,\n            )\n\n            return self.success(\n                data={\n                    'overview': overview,\n                    'messages': messages,\n                    'llmCalls': llm_calls,\n                    'embeddingCalls': embedding_calls,\n                    'sessions': sessions,\n                    'errors': errors,\n                    'totalCount': {\n                        'messages': messages_total,\n                        'llmCalls': llm_calls_total,\n                        'embeddingCalls': embedding_calls_total,\n                        'sessions': sessions_total,\n                        'errors': errors_total,\n                    },\n                }\n            )\n\n        @self.route('/sessions/<session_id>/analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def get_session_analysis(session_id: str) -> str:\n            \"\"\"Get detailed analysis for a specific session\"\"\"\n            analysis = await self.ap.monitoring_service.get_session_analysis(session_id)\n\n            # Always return success with the analysis data\n            # The frontend will handle the 'found: false' case\n            return self.success(data=analysis)\n\n        @self.route('/messages/<message_id>/details', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def get_message_details(message_id: str) -> str:\n            \"\"\"Get detailed information for a specific message\"\"\"\n            details = await self.ap.monitoring_service.get_message_details(message_id)\n\n            if not details.get('found'):\n                return self.error(message=f'Message {message_id} not found', code=404)\n\n            return self.success(data=details)\n\n        @self.route('/export', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def export_data() -> tuple[str, int]:\n            \"\"\"Export monitoring data as CSV\"\"\"\n            # Parse query parameters\n            export_type = quart.request.args.get('type', 'messages')\n            bot_ids = quart.request.args.getlist('botId')\n            pipeline_ids = quart.request.args.getlist('pipelineId')\n            start_time_str = quart.request.args.get('startTime')\n            end_time_str = quart.request.args.get('endTime')\n            limit = int(quart.request.args.get('limit', 100000))\n\n            # Parse datetime\n            start_time = parse_iso_datetime(start_time_str)\n            end_time = parse_iso_datetime(end_time_str)\n\n            # Get data based on export type\n            if export_type == 'messages':\n                data = await self.ap.monitoring_service.export_messages(\n                    bot_ids=bot_ids if bot_ids else None,\n                    pipeline_ids=pipeline_ids if pipeline_ids else None,\n                    start_time=start_time,\n                    end_time=end_time,\n                    limit=limit,\n                )\n                headers = [\n                    'id',\n                    'timestamp',\n                    'bot_id',\n                    'bot_name',\n                    'pipeline_id',\n                    'pipeline_name',\n                    'runner_name',\n                    'message_content',\n                    'message_text',\n                    'session_id',\n                    'status',\n                    'level',\n                    'platform',\n                    'user_id',\n                ]\n            elif export_type == 'llm-calls':\n                data = await self.ap.monitoring_service.export_llm_calls(\n                    bot_ids=bot_ids if bot_ids else None,\n                    pipeline_ids=pipeline_ids if pipeline_ids else None,\n                    start_time=start_time,\n                    end_time=end_time,\n                    limit=limit,\n                )\n                headers = [\n                    'id',\n                    'timestamp',\n                    'model_name',\n                    'input_tokens',\n                    'output_tokens',\n                    'total_tokens',\n                    'duration_ms',\n                    'cost',\n                    'status',\n                    'bot_id',\n                    'bot_name',\n                    'pipeline_id',\n                    'pipeline_name',\n                    'session_id',\n                    'message_id',\n                    'error_message',\n                ]\n            elif export_type == 'embedding-calls':\n                data = await self.ap.monitoring_service.export_embedding_calls(\n                    start_time=start_time,\n                    end_time=end_time,\n                    limit=limit,\n                )\n                headers = [\n                    'id',\n                    'timestamp',\n                    'model_name',\n                    'prompt_tokens',\n                    'total_tokens',\n                    'duration_ms',\n                    'input_count',\n                    'status',\n                    'error_message',\n                    'knowledge_base_id',\n                    'query_text',\n                    'session_id',\n                    'message_id',\n                    'call_type',\n                ]\n            elif export_type == 'errors':\n                data = await self.ap.monitoring_service.export_errors(\n                    bot_ids=bot_ids if bot_ids else None,\n                    pipeline_ids=pipeline_ids if pipeline_ids else None,\n                    start_time=start_time,\n                    end_time=end_time,\n                    limit=limit,\n                )\n                headers = [\n                    'id',\n                    'timestamp',\n                    'error_type',\n                    'error_message',\n                    'bot_id',\n                    'bot_name',\n                    'pipeline_id',\n                    'pipeline_name',\n                    'session_id',\n                    'message_id',\n                    'stack_trace',\n                ]\n            elif export_type == 'sessions':\n                data = await self.ap.monitoring_service.export_sessions(\n                    bot_ids=bot_ids if bot_ids else None,\n                    pipeline_ids=pipeline_ids if pipeline_ids else None,\n                    start_time=start_time,\n                    end_time=end_time,\n                    limit=limit,\n                )\n                headers = [\n                    'session_id',\n                    'bot_id',\n                    'bot_name',\n                    'pipeline_id',\n                    'pipeline_name',\n                    'message_count',\n                    'start_time',\n                    'last_activity',\n                    'is_active',\n                    'platform',\n                    'user_id',\n                ]\n            else:\n                return self.error(message=f'Invalid export type: {export_type}', code=400)\n\n            # Generate CSV content with UTF-8 BOM for Excel compatibility\n            import io\n\n            output = io.StringIO()\n            # Write UTF-8 BOM for Excel\n            output.write('\\ufeff')\n            # Write header\n            output.write(','.join(headers) + '\\n')\n\n            # Escape and write each row\n            for row in data:\n                escaped_values = []\n                for header in headers:\n                    value = row.get(header, '')\n                    escaped_values.append(self.ap.monitoring_service._escape_csv_field(value))\n                output.write(','.join(escaped_values) + '\\n')\n\n            csv_content = output.getvalue()\n\n            # Return as file download\n            response = await quart.make_response(csv_content)\n            response.headers['Content-Type'] = 'text/csv; charset=utf-8'\n            response.headers['Content-Disposition'] = (\n                f'attachment; filename=\"monitoring-{export_type}-{int(datetime.datetime.now().timestamp())}.csv\"'\n            )\n\n            return response, 200\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/pipelines/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py",
    "content": "from __future__ import annotations\n\nimport quart\n\nfrom ... import group\n\n\n@group.group_class('pipelines', '/api/v1/pipelines')\nclass PipelinesRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            if quart.request.method == 'GET':\n                sort_by = quart.request.args.get('sort_by', 'created_at')\n                sort_order = quart.request.args.get('sort_order', 'DESC')\n                return self.success(\n                    data={'pipelines': await self.ap.pipeline_service.get_pipelines(sort_by, sort_order)}\n                )\n            elif quart.request.method == 'POST':\n                json_data = await quart.request.json\n\n                pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data)\n\n                return self.success(data={'uuid': pipeline_uuid})\n\n        @self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()})\n\n        @self.route(\n            '/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY\n        )\n        async def _(pipeline_uuid: str) -> str:\n            if quart.request.method == 'GET':\n                pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)\n\n                if pipeline is None:\n                    return self.http_status(404, -1, 'pipeline not found')\n\n                return self.success(data={'pipeline': pipeline})\n            elif quart.request.method == 'PUT':\n                json_data = await quart.request.json\n\n                await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data)\n\n                return self.success()\n            elif quart.request.method == 'DELETE':\n                await self.ap.pipeline_service.delete_pipeline(pipeline_uuid)\n\n                return self.success()\n\n        @self.route('/<pipeline_uuid>/copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _(pipeline_uuid: str) -> str:\n            try:\n                new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid)\n                return self.success(data={'uuid': new_uuid})\n            except ValueError as e:\n                return self.http_status(404, -1, str(e))\n\n        @self.route(\n            '/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY\n        )\n        async def _(pipeline_uuid: str) -> str:\n            if quart.request.method == 'GET':\n                # Get current extensions and available plugins\n                pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)\n                if pipeline is None:\n                    return self.http_status(404, -1, 'pipeline not found')\n\n                # Only include plugins with pipeline-related components (Command, EventListener, Tool)\n                # Plugins that only have KnowledgeEngine components are not suitable for pipeline extensions\n                pipeline_component_kinds = ['Command', 'EventListener', 'Tool']\n                plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)\n                mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)\n\n                extensions_prefs = pipeline.get('extensions_preferences', {})\n                return self.success(\n                    data={\n                        'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),\n                        'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),\n                        'bound_plugins': extensions_prefs.get('plugins', []),\n                        'available_plugins': plugins,\n                        'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),\n                        'available_mcp_servers': mcp_servers,\n                    }\n                )\n            elif quart.request.method == 'PUT':\n                # Update bound plugins and MCP servers for this pipeline\n                json_data = await quart.request.json\n                enable_all_plugins = json_data.get('enable_all_plugins', True)\n                enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)\n                bound_plugins = json_data.get('bound_plugins', [])\n                bound_mcp_servers = json_data.get('bound_mcp_servers', [])\n\n                await self.ap.pipeline_service.update_pipeline_extensions(\n                    pipeline_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers\n                )\n\n                return self.success()\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py",
    "content": "\"\"\"WebSocket聊天路由 - 支持双向实时通信\"\"\"\n\nimport asyncio\nimport datetime\nimport json\nimport logging\n\nimport quart\n\nfrom ... import group\nfrom ......platform.sources.websocket_manager import ws_connection_manager\n\nlogger = logging.getLogger(__name__)\n\n\n@group.group_class('websocket_chat', '/api/v1/pipelines/<pipeline_uuid>/ws')\nclass WebSocketChatRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        # 直接使用 quart_app 注册 WebSocket 路由\n        @self.quart_app.websocket(self.path + '/connect')\n        async def websocket_connect(pipeline_uuid: str):\n            \"\"\"\n            建立WebSocket连接\n\n            URL参数:\n                - pipeline_uuid: 流水线UUID\n                - session_type: 会话类型 (person/group)\n            \"\"\"\n            try:\n                # 获取参数 - 在WebSocket上下文中使用 quart.websocket.args\n                session_type = quart.websocket.args.get('session_type', 'person')\n\n                if session_type not in ['person', 'group']:\n                    await quart.websocket.send(\n                        json.dumps({'type': 'error', 'message': 'session_type must be person or group'})\n                    )\n                    return\n\n                # 获取WebSocket适配器\n                websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter\n\n                if not websocket_adapter:\n                    await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))\n                    return\n\n                # 注册连接\n                connection = await ws_connection_manager.add_connection(\n                    websocket=quart.websocket._get_current_object(),\n                    pipeline_uuid=pipeline_uuid,\n                    session_type=session_type,\n                    metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},\n                )\n\n                # 发送连接成功消息\n                await quart.websocket.send(\n                    json.dumps(\n                        {\n                            'type': 'connected',\n                            'connection_id': connection.connection_id,\n                            'pipeline_uuid': pipeline_uuid,\n                            'session_type': session_type,\n                            'timestamp': connection.created_at.isoformat(),\n                        }\n                    )\n                )\n\n                logger.debug(\n                    f'WebSocket connection established: {connection.connection_id} '\n                    f'(pipeline={pipeline_uuid}, session_type={session_type})'\n                )\n\n                # 创建接收和发送任务\n                receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))\n                send_task = asyncio.create_task(self._handle_send(connection))\n\n                # 等待任务完成\n                try:\n                    await asyncio.gather(receive_task, send_task)\n                except Exception as e:\n                    logger.error(f'WebSocket task execution error: {e}')\n                finally:\n                    # 清理连接\n                    await ws_connection_manager.remove_connection(connection.connection_id)\n                    logger.debug(f'WebSocket connection cleaned: {connection.connection_id}')\n\n            except Exception as e:\n                logger.error(f'WebSocket connection error: {e}', exc_info=True)\n                try:\n                    await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)}))\n                except:\n                    pass\n\n        @self.route('/messages/<session_type>', methods=['GET'])\n        async def get_messages(pipeline_uuid: str, session_type: str) -> str:\n            \"\"\"获取消息历史\"\"\"\n            try:\n                if session_type not in ['person', 'group']:\n                    return self.http_status(400, -1, 'session_type must be person or group')\n\n                websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter\n\n                if not websocket_adapter:\n                    return self.http_status(404, -1, 'WebSocket adapter not found')\n\n                messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)\n\n                return self.success(data={'messages': messages})\n\n            except Exception as e:\n                return self.http_status(500, -1, f'Internal server error: {str(e)}')\n\n        @self.route('/reset/<session_type>', methods=['POST'])\n        async def reset_session(pipeline_uuid: str, session_type: str) -> str:\n            \"\"\"重置会话\"\"\"\n            try:\n                if session_type not in ['person', 'group']:\n                    return self.http_status(400, -1, 'session_type must be person or group')\n\n                websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter\n\n                if not websocket_adapter:\n                    return self.http_status(404, -1, 'WebSocket adapter not found')\n\n                websocket_adapter.reset_session(pipeline_uuid, session_type)\n\n                return self.success(data={'message': 'Session reset successfully'})\n\n            except Exception as e:\n                return self.http_status(500, -1, f'Internal server error: {str(e)}')\n\n        @self.route('/connections', methods=['GET'])\n        async def get_connections(pipeline_uuid: str) -> str:\n            \"\"\"获取当前连接统计\"\"\"\n            try:\n                stats = ws_connection_manager.get_stats()\n                connections = await ws_connection_manager.get_connections_by_pipeline(pipeline_uuid)\n\n                return self.success(\n                    data={\n                        'stats': stats,\n                        'connections': [\n                            {\n                                'connection_id': conn.connection_id,\n                                'session_type': conn.session_type,\n                                'created_at': conn.created_at.isoformat(),\n                                'last_active': conn.last_active.isoformat(),\n                                'is_active': conn.is_active,\n                            }\n                            for conn in connections\n                        ],\n                    }\n                )\n\n            except Exception as e:\n                return self.http_status(500, -1, f'Internal server error: {str(e)}')\n\n        @self.route('/broadcast', methods=['POST'])\n        async def broadcast_message(pipeline_uuid: str) -> str:\n            \"\"\"向所有连接广播消息（后端主动推送）\"\"\"\n            try:\n                data = await quart.request.get_json()\n                message = data.get('message')\n\n                if not message:\n                    return self.http_status(400, -1, 'message is required')\n\n                # 广播消息\n                broadcast_data = {\n                    'type': 'broadcast',\n                    'message': message,\n                    'timestamp': datetime.datetime.now().isoformat(),\n                }\n\n                await ws_connection_manager.broadcast_to_pipeline(pipeline_uuid, broadcast_data)\n\n                return self.success(data={'message': 'Broadcast sent successfully'})\n\n            except Exception as e:\n                return self.http_status(500, -1, f'Internal server error: {str(e)}')\n\n    async def _handle_receive(self, connection, websocket_adapter):\n        \"\"\"处理接收消息的任务\"\"\"\n        try:\n            while connection.is_active:\n                # 接收消息\n                message = await quart.websocket.receive()\n\n                # 更新活跃时间\n                await ws_connection_manager.update_activity(connection.connection_id)\n\n                try:\n                    data = json.loads(message)\n                    message_type = data.get('type', 'message')\n\n                    if message_type == 'ping':\n                        # 心跳响应\n                        await connection.send_queue.put(\n                            {'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}\n                        )\n\n                    elif message_type == 'message':\n                        # 处理用户消息\n                        logger.debug(f'收到消息: {data} from {connection.connection_id}')\n\n                        # 处理消息（不等待响应，响应会通过broadcast异步发送）\n                        await websocket_adapter.handle_websocket_message(connection, data)\n\n                    elif message_type == 'disconnect':\n                        # 客户端主动断开\n                        logger.debug(f'Client disconnected: {connection.connection_id}')\n                        break\n\n                    else:\n                        logger.warning(f'Unknown message type: {message_type}')\n\n                except json.JSONDecodeError:\n                    logger.error(f'Invalid JSON message: {message}')\n                    await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})\n\n        except Exception as e:\n            logger.error(f'Receive message error: {e}', exc_info=True)\n        finally:\n            connection.is_active = False\n\n    async def _handle_send(self, connection):\n        \"\"\"处理发送消息的任务\"\"\"\n        try:\n            while connection.is_active:\n                # 从队列获取消息\n                try:\n                    message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)\n\n                    # 发送消息\n                    await quart.websocket.send(json.dumps(message))\n\n                except asyncio.TimeoutError:\n                    # 超时继续循环\n                    continue\n\n        except Exception as e:\n            logger.error(f'Send message error: {e}', exc_info=True)\n        finally:\n            connection.is_active = False\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/platform/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/platform/adapters.py",
    "content": "import quart\nimport mimetypes\nfrom ... import group\nfrom langbot.pkg.utils import importutil\n\n\n@group.group_class('adapters', '/api/v1/platform/adapters')\nclass AdaptersRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET'])\n        async def _() -> str:\n            return self.success(data={'adapters': self.ap.platform_mgr.get_available_adapters_info()})\n\n        @self.route('/<adapter_name>', methods=['GET'])\n        async def _(adapter_name: str) -> str:\n            adapter_info = self.ap.platform_mgr.get_available_adapter_info_by_name(adapter_name)\n\n            if adapter_info is None:\n                return self.http_status(404, -1, 'adapter not found')\n\n            return self.success(data={'adapter': adapter_info})\n\n        @self.route('/<adapter_name>/icon', methods=['GET'], auth_type=group.AuthType.NONE)\n        async def _(adapter_name: str) -> quart.Response:\n            adapter_manifest = self.ap.platform_mgr.get_available_adapter_manifest_by_name(adapter_name)\n\n            if adapter_manifest is None:\n                return self.http_status(404, -1, 'adapter not found')\n\n            icon_path = adapter_manifest.icon_rel_path\n\n            if icon_path is None:\n                return self.http_status(404, -1, 'icon not found')\n\n            return quart.Response(\n                importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]\n            )\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/platform/bots.py",
    "content": "import quart\n\nfrom ... import group\n\n\n@group.group_class('bots', '/api/v1/platform/bots')\nclass BotsRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            if quart.request.method == 'GET':\n                return self.success(data={'bots': await self.ap.bot_service.get_bots()})\n            elif quart.request.method == 'POST':\n                json_data = await quart.request.json\n                bot_uuid = await self.ap.bot_service.create_bot(json_data)\n                return self.success(data={'uuid': bot_uuid})\n\n        @self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _(bot_uuid: str) -> str:\n            if quart.request.method == 'GET':\n                # 返回运行时信息，包括webhook地址等\n                bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)\n                if bot is None:\n                    return self.http_status(404, -1, 'bot not found')\n                return self.success(data={'bot': bot})\n            elif quart.request.method == 'PUT':\n                json_data = await quart.request.json\n                await self.ap.bot_service.update_bot(bot_uuid, json_data)\n                return self.success()\n            elif quart.request.method == 'DELETE':\n                await self.ap.bot_service.delete_bot(bot_uuid)\n                return self.success()\n\n        @self.route('/<bot_uuid>/logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _(bot_uuid: str) -> str:\n            json_data = await quart.request.json\n            from_index = json_data.get('from_index', -1)\n            max_count = json_data.get('max_count', 10)\n            logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)\n            return self.success(\n                data={\n                    'logs': logs,\n                    'total_count': total_count,\n                }\n            )\n\n        @self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)\n        async def _(bot_uuid: str) -> str:\n            \"\"\"Send message to a specific target via bot\"\"\"\n            json_data = await quart.request.json\n            target_type = json_data.get('target_type')\n            target_id = json_data.get('target_id')\n            message_chain_data = json_data.get('message_chain')\n\n            # Validate required fields\n            if not target_type:\n                return self.http_status(400, -1, 'target_type is required')\n            if not target_id:\n                return self.http_status(400, -1, 'target_id is required')\n            if not message_chain_data:\n                return self.http_status(400, -1, 'message_chain is required')\n\n            # Validate target_type\n            if target_type not in ['person', 'group']:\n                return self.http_status(400, -1, 'target_type must be either \"person\" or \"group\"')\n\n            try:\n                await self.ap.bot_service.send_message(bot_uuid, target_type, target_id, message_chain_data)\n                return self.success(data={'sent': True})\n            except Exception as e:\n                import traceback\n\n                traceback.print_exc()\n                return self.http_status(500, -1, f'Failed to send message: {str(e)}')\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/plugins.py",
    "content": "from __future__ import annotations\n\nimport base64\nimport quart\nimport re\nimport httpx\nimport uuid\nimport os\n\nfrom .....core import taskmgr\nfrom .. import group\nfrom langbot_plugin.runtime.plugin.mgr import PluginInstallSource\n\n\n@group.group_class('plugins', '/api/v1/plugins')\nclass PluginsRouterGroup(group.RouterGroup):\n    async def _check_extensions_limit(self) -> str | None:\n        \"\"\"Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise.\"\"\"\n        limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})\n        max_extensions = limitation.get('max_extensions', -1)\n        if max_extensions >= 0:\n            plugins = await self.ap.plugin_connector.list_plugins()\n            mcp_servers = await self.ap.mcp_service.get_mcp_servers()\n            total_extensions = len(plugins) + len(mcp_servers)\n            if total_extensions >= max_extensions:\n                return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached')\n        return None\n\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            plugins = await self.ap.plugin_connector.list_plugins()\n\n            return self.success(data={'plugins': plugins})\n\n        @self.route('/debug-info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            \"\"\"Get plugin debug information including debug URL and key\"\"\"\n            debug_info = await self.ap.plugin_connector.get_debug_info()\n\n            # Get debug URL from config\n            plugin_config = self.ap.instance_config.data.get('plugin', {})\n            debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401')\n\n            return self.success(\n                data={\n                    'debug_url': debug_url,\n                    'plugin_debug_key': debug_info.get('plugin_debug_key', ''),\n                }\n            )\n\n        @self.route(\n            '/<author>/<plugin_name>/upgrade',\n            methods=['POST'],\n            auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,\n        )\n        async def _(author: str, plugin_name: str) -> str:\n            ctx = taskmgr.TaskContext.new()\n            wrapper = self.ap.task_mgr.create_user_task(\n                self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx),\n                kind='plugin-operation',\n                name=f'plugin-upgrade-{plugin_name}',\n                label=f'Upgrading plugin {plugin_name}',\n                context=ctx,\n            )\n            return self.success(data={'task_id': wrapper.id})\n\n        @self.route(\n            '/<author>/<plugin_name>',\n            methods=['GET', 'DELETE'],\n            auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,\n        )\n        async def _(author: str, plugin_name: str) -> str:\n            if quart.request.method == 'GET':\n                plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)\n                if plugin is None:\n                    return self.http_status(404, -1, 'plugin not found')\n                return self.success(data={'plugin': plugin})\n            elif quart.request.method == 'DELETE':\n                delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'\n                ctx = taskmgr.TaskContext.new()\n                wrapper = self.ap.task_mgr.create_user_task(\n                    self.ap.plugin_connector.delete_plugin(\n                        author, plugin_name, delete_data=delete_data, task_context=ctx\n                    ),\n                    kind='plugin-operation',\n                    name=f'plugin-remove-{plugin_name}',\n                    label=f'Removing plugin {plugin_name}',\n                    context=ctx,\n                )\n\n                return self.success(data={'task_id': wrapper.id})\n\n        @self.route(\n            '/<author>/<plugin_name>/config',\n            methods=['GET', 'PUT'],\n            auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,\n        )\n        async def _(author: str, plugin_name: str) -> quart.Response:\n            plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)\n            if plugin is None:\n                return self.http_status(404, -1, 'plugin not found')\n\n            if quart.request.method == 'GET':\n                return self.success(data={'config': plugin['plugin_config']})\n            elif quart.request.method == 'PUT':\n                data = await quart.request.json\n\n                await self.ap.plugin_connector.set_plugin_config(author, plugin_name, data)\n\n                return self.success(data={})\n\n        @self.route(\n            '/<author>/<plugin_name>/readme',\n            methods=['GET'],\n            auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,\n        )\n        async def _(author: str, plugin_name: str) -> quart.Response:\n            language = quart.request.args.get('language', 'en')\n            readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)\n            return self.success(data={'readme': readme})\n\n        @self.route(\n            '/<author>/<plugin_name>/icon',\n            methods=['GET'],\n            auth_type=group.AuthType.NONE,\n        )\n        async def _(author: str, plugin_name: str) -> quart.Response:\n            icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)\n            icon_base64 = icon_data['plugin_icon_base64']\n            mime_type = icon_data['mime_type']\n\n            icon_data = base64.b64decode(icon_base64)\n\n            return quart.Response(icon_data, mimetype=mime_type)\n\n        @self.route(\n            '/<author>/<plugin_name>/assets/<filepath>',\n            methods=['GET'],\n            auth_type=group.AuthType.NONE,\n        )\n        async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:\n            asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath)\n            asset_bytes = base64.b64decode(asset_data['asset_base64'])\n            mime_type = asset_data['mime_type']\n            return quart.Response(asset_bytes, mimetype=mime_type)\n\n        @self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            \"\"\"Get releases from a GitHub repository URL\"\"\"\n            data = await quart.request.json\n            repo_url = data.get('repo_url', '')\n\n            # Parse GitHub repository URL to extract owner and repo\n            # Supports: https://github.com/owner/repo or github.com/owner/repo\n            pattern = r'github\\.com/([^/]+)/([^/]+?)(?:\\.git)?(?:/.*)?$'\n            match = re.search(pattern, repo_url)\n\n            if not match:\n                return self.http_status(400, -1, 'Invalid GitHub repository URL')\n\n            owner, repo = match.groups()\n\n            try:\n                # Fetch releases from GitHub API\n                url = f'https://api.github.com/repos/{owner}/{repo}/releases'\n                async with httpx.AsyncClient(\n                    trust_env=True,\n                    follow_redirects=True,\n                    timeout=10,\n                ) as client:\n                    response = await client.get(url)\n                    response.raise_for_status()\n                    releases = response.json()\n\n                # Format releases data for frontend\n                formatted_releases = []\n                for release in releases:\n                    formatted_releases.append(\n                        {\n                            'id': release['id'],\n                            'tag_name': release['tag_name'],\n                            'name': release['name'],\n                            'published_at': release['published_at'],\n                            'prerelease': release['prerelease'],\n                            'draft': release['draft'],\n                        }\n                    )\n\n                return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo})\n            except httpx.RequestError as e:\n                return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')\n\n        @self.route(\n            '/github/release-assets',\n            methods=['POST'],\n            auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,\n        )\n        async def _() -> str:\n            \"\"\"Get assets from a specific GitHub release\"\"\"\n            data = await quart.request.json\n            owner = data.get('owner', '')\n            repo = data.get('repo', '')\n            release_id = data.get('release_id', '')\n\n            if not all([owner, repo, release_id]):\n                return self.http_status(400, -1, 'Missing required parameters')\n\n            try:\n                # Fetch release assets from GitHub API\n                url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}'\n                async with httpx.AsyncClient(\n                    trust_env=True,\n                    follow_redirects=True,\n                    timeout=10,\n                ) as client:\n                    response = await client.get(\n                        url,\n                    )\n                    response.raise_for_status()\n                    release = response.json()\n\n                # Format assets data for frontend\n                formatted_assets = []\n                for asset in release.get('assets', []):\n                    formatted_assets.append(\n                        {\n                            'id': asset['id'],\n                            'name': asset['name'],\n                            'size': asset['size'],\n                            'download_url': asset['browser_download_url'],\n                            'content_type': asset['content_type'],\n                        }\n                    )\n\n                # add zipball as a downloadable asset\n                # formatted_assets.append(\n                #     {\n                #         \"id\": 0,\n                #         \"name\": \"Source code (zip)\",\n                #         \"size\": -1,\n                #         \"download_url\": release[\"zipball_url\"],\n                #         \"content_type\": \"application/zip\",\n                #     }\n                # )\n\n                return self.success(data={'assets': formatted_assets})\n            except httpx.RequestError as e:\n                return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}')\n\n        @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            \"\"\"Install plugin from GitHub release asset\"\"\"\n            limit_error = await self._check_extensions_limit()\n            if limit_error is not None:\n                return limit_error\n\n            data = await quart.request.json\n            asset_url = data.get('asset_url', '')\n            owner = data.get('owner', '')\n            repo = data.get('repo', '')\n            release_tag = data.get('release_tag', '')\n\n            if not asset_url:\n                return self.http_status(400, -1, 'Missing asset_url parameter')\n\n            ctx = taskmgr.TaskContext.new()\n            install_info = {\n                'asset_url': asset_url,\n                'owner': owner,\n                'repo': repo,\n                'release_tag': release_tag,\n                'github_url': f'https://github.com/{owner}/{repo}',\n            }\n\n            wrapper = self.ap.task_mgr.create_user_task(\n                self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx),\n                kind='plugin-operation',\n                name='plugin-install-github',\n                label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}',\n                context=ctx,\n            )\n\n            return self.success(data={'task_id': wrapper.id})\n\n        @self.route(\n            '/install/marketplace',\n            methods=['POST'],\n            auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,\n        )\n        async def _() -> str:\n            limit_error = await self._check_extensions_limit()\n            if limit_error is not None:\n                return limit_error\n\n            data = await quart.request.json\n\n            ctx = taskmgr.TaskContext.new()\n            wrapper = self.ap.task_mgr.create_user_task(\n                self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),\n                kind='plugin-operation',\n                name='plugin-install-marketplace',\n                label=f'Installing plugin from marketplace ...{data}',\n                context=ctx,\n            )\n\n            return self.success(data={'task_id': wrapper.id})\n\n        @self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            limit_error = await self._check_extensions_limit()\n            if limit_error is not None:\n                return limit_error\n\n            file = (await quart.request.files).get('file')\n            if file is None:\n                return self.http_status(400, -1, 'file is required')\n\n            file_bytes = file.read()\n\n            data = {\n                'plugin_file': file_bytes,\n            }\n\n            ctx = taskmgr.TaskContext.new()\n            wrapper = self.ap.task_mgr.create_user_task(\n                self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),\n                kind='plugin-operation',\n                name='plugin-install-local',\n                label=f'Installing plugin from local ...{file.filename}',\n                context=ctx,\n            )\n\n            return self.success(data={'task_id': wrapper.id})\n\n        @self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)\n        async def _() -> str:\n            \"\"\"Upload a file for plugin configuration\"\"\"\n            file = (await quart.request.files).get('file')\n            if file is None:\n                return self.http_status(400, -1, 'file is required')\n\n            # Check file size (10MB limit)\n            MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB\n            file_bytes = file.read()\n            if len(file_bytes) > MAX_FILE_SIZE:\n                return self.http_status(400, -1, 'file size exceeds 10MB limit')\n\n            # Generate unique file key with original extension\n            original_filename = file.filename\n            _, ext = os.path.splitext(original_filename)\n            file_key = f'plugin_config_{uuid.uuid4().hex}{ext}'\n\n            # Save file using storage manager\n            await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)\n\n            return self.success(data={'file_key': file_key})\n\n        @self.route('/config-files/<file_key>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)\n        async def _(file_key: str) -> str:\n            \"\"\"Delete a plugin configuration file\"\"\"\n            # Only allow deletion of files with plugin_config_ prefix for security\n            if not file_key.startswith('plugin_config_'):\n                return self.http_status(400, -1, 'invalid file key')\n\n            try:\n                await self.ap.storage_mgr.storage_provider.delete(file_key)\n                return self.success(data={'deleted': True})\n            except Exception as e:\n                return self.http_status(500, -1, f'failed to delete file: {str(e)}')\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/provider/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/provider/models.py",
    "content": "import quart\n\nfrom ... import group\n\n\n@group.group_class('models/llm', '/api/v1/provider/models/llm')\nclass LLMModelsRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            if quart.request.method == 'GET':\n                provider_uuid = quart.request.args.get('provider_uuid')\n                if provider_uuid:\n                    return self.success(\n                        data={'models': await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)}\n                    )\n                return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})\n            elif quart.request.method == 'POST':\n                json_data = await quart.request.json\n                model_uuid = await self.ap.llm_model_service.create_llm_model(json_data)\n                return self.success(data={'uuid': model_uuid})\n\n        @self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _(model_uuid: str) -> str:\n            if quart.request.method == 'GET':\n                model = await self.ap.llm_model_service.get_llm_model(model_uuid)\n\n                if model is None:\n                    return self.http_status(404, -1, 'model not found')\n\n                return self.success(data={'model': model})\n            elif quart.request.method == 'PUT':\n                json_data = await quart.request.json\n\n                await self.ap.llm_model_service.update_llm_model(model_uuid, json_data)\n\n                return self.success()\n            elif quart.request.method == 'DELETE':\n                await self.ap.llm_model_service.delete_llm_model(model_uuid)\n\n                return self.success()\n\n        @self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _(model_uuid: str) -> str:\n            json_data = await quart.request.json\n\n            await self.ap.llm_model_service.test_llm_model(model_uuid, json_data)\n\n            return self.success()\n\n\n@group.group_class('models/embedding', '/api/v1/provider/models/embedding')\nclass EmbeddingModelsRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            if quart.request.method == 'GET':\n                provider_uuid = quart.request.args.get('provider_uuid')\n                if provider_uuid:\n                    return self.success(\n                        data={\n                            'models': await self.ap.embedding_models_service.get_embedding_models_by_provider(\n                                provider_uuid\n                            )\n                        }\n                    )\n                return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})\n            elif quart.request.method == 'POST':\n                json_data = await quart.request.json\n                model_uuid = await self.ap.embedding_models_service.create_embedding_model(json_data)\n                return self.success(data={'uuid': model_uuid})\n\n        @self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _(model_uuid: str) -> str:\n            if quart.request.method == 'GET':\n                model = await self.ap.embedding_models_service.get_embedding_model(model_uuid)\n\n                if model is None:\n                    return self.http_status(404, -1, 'model not found')\n\n                return self.success(data={'model': model})\n            elif quart.request.method == 'PUT':\n                json_data = await quart.request.json\n\n                await self.ap.embedding_models_service.update_embedding_model(model_uuid, json_data)\n\n                return self.success()\n            elif quart.request.method == 'DELETE':\n                await self.ap.embedding_models_service.delete_embedding_model(model_uuid)\n\n                return self.success()\n\n        @self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _(model_uuid: str) -> str:\n            json_data = await quart.request.json\n\n            await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)\n\n            return self.success()\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/provider/providers.py",
    "content": "import quart\n\nfrom ... import group\n\n\n@group.group_class('models/providers', '/api/v1/provider/providers')\nclass ModelProvidersRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)\n        async def _() -> str:\n            if quart.request.method == 'GET':\n                providers = await self.ap.provider_service.get_providers()\n                # Add model counts\n                for provider in providers:\n                    counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])\n                    provider['llm_count'] = counts['llm_count']\n                    provider['embedding_count'] = counts['embedding_count']\n                return self.success(data={'providers': providers})\n            elif quart.request.method == 'POST':\n                json_data = await quart.request.json\n                provider_uuid = await self.ap.provider_service.create_provider(json_data)\n                return self.success(data={'uuid': provider_uuid})\n\n        @self.route(\n            '/<provider_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY\n        )\n        async def _(provider_uuid: str) -> str:\n            if quart.request.method == 'GET':\n                provider = await self.ap.provider_service.get_provider(provider_uuid)\n                if provider is None:\n                    return self.http_status(404, -1, 'provider not found')\n                counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)\n                provider['llm_count'] = counts['llm_count']\n                provider['embedding_count'] = counts['embedding_count']\n                return self.success(data={'provider': provider})\n            elif quart.request.method == 'PUT':\n                json_data = await quart.request.json\n                await self.ap.provider_service.update_provider(provider_uuid, json_data)\n                return self.success()\n            elif quart.request.method == 'DELETE':\n                try:\n                    await self.ap.provider_service.delete_provider(provider_uuid)\n                    return self.success()\n                except ValueError as e:\n                    return self.http_status(400, -1, str(e))\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/provider/requesters.py",
    "content": "import quart\nimport mimetypes\n\nfrom ... import group\nfrom langbot.pkg.utils import importutil\n\n\n@group.group_class('provider/requesters', '/api/v1/provider/requesters')\nclass RequestersRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET'])\n        async def _() -> quart.Response:\n            model_type = quart.request.args.get('type', '')\n            return self.success(data={'requesters': self.ap.model_mgr.get_available_requesters_info(model_type)})\n\n        @self.route('/<requester_name>', methods=['GET'])\n        async def _(requester_name: str) -> quart.Response:\n            requester_info = self.ap.model_mgr.get_available_requester_info_by_name(requester_name)\n\n            if requester_info is None:\n                return self.http_status(404, -1, 'requester not found')\n\n            return self.success(data={'requester': requester_info})\n\n        @self.route('/<requester_name>/icon', methods=['GET'], auth_type=group.AuthType.NONE)\n        async def _(requester_name: str) -> quart.Response:\n            requester_manifest = self.ap.model_mgr.get_available_requester_manifest_by_name(requester_name)\n\n            if requester_manifest is None:\n                return self.http_status(404, -1, 'requester not found')\n\n            icon_path = requester_manifest.icon_rel_path\n\n            if icon_path is None:\n                return self.http_status(404, -1, 'icon not found')\n\n            return quart.Response(\n                importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]\n            )\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/resources/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/resources/mcp.py",
    "content": "from __future__ import annotations\n\nimport quart\nimport traceback\n\n\nfrom ... import group\n\n\n@group.group_class('mcp', '/api/v1/mcp')\nclass MCPRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)\n        async def _() -> str:\n            \"\"\"获取MCP服务器列表\"\"\"\n            if quart.request.method == 'GET':\n                servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)\n\n                return self.success(data={'servers': servers})\n\n            elif quart.request.method == 'POST':\n                data = await quart.request.json\n\n                try:\n                    uuid = await self.ap.mcp_service.create_mcp_server(data)\n                    return self.success(data={'uuid': uuid})\n                except Exception as e:\n                    traceback.print_exc()\n                    return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')\n\n        @self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)\n        async def _(server_name: str) -> str:\n            \"\"\"获取、更新或删除MCP服务器配置\"\"\"\n\n            server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)\n            if server_data is None:\n                return self.http_status(404, -1, 'Server not found')\n\n            if quart.request.method == 'GET':\n                return self.success(data={'server': server_data})\n\n            elif quart.request.method == 'PUT':\n                data = await quart.request.json\n                try:\n                    await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data)\n                    return self.success()\n                except Exception as e:\n                    return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}')\n\n            elif quart.request.method == 'DELETE':\n                try:\n                    await self.ap.mcp_service.delete_mcp_server(server_data['uuid'])\n                    return self.success()\n                except Exception as e:\n                    return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')\n\n        @self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)\n        async def _(server_name: str) -> str:\n            \"\"\"测试MCP服务器连接\"\"\"\n            server_data = await quart.request.json\n            task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)\n            return self.success(data={'task_id': task_id})\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/stats.py",
    "content": "from .. import group\n\n\n@group.group_class('stats', '/api/v1/stats')\nclass StatsRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('/basic', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def _() -> str:\n            conv_count = 0\n            for session in self.ap.sess_mgr.session_list:\n                conv_count += len(session.conversations if session.conversations is not None else [])\n\n            return self.success(\n                data={\n                    'active_session_count': len(self.ap.sess_mgr.session_list),\n                    'conversation_count': conv_count,\n                    'query_count': self.ap.query_pool.query_id_counter,\n                }\n            )\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/survey.py",
    "content": "import quart\n\nfrom .. import group\n\n\n@group.group_class('survey', '/api/v1/survey')\nclass SurveyRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('/pending', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def _get_pending() -> str:\n            \"\"\"Get pending survey for the frontend to display.\"\"\"\n            survey = self.ap.survey.get_pending_survey() if self.ap.survey else None\n            return self.success(data={'survey': survey})\n\n        @self.route('/respond', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)\n        async def _respond() -> str:\n            \"\"\"Submit survey response.\"\"\"\n            json_data = await quart.request.json\n            survey_id = json_data.get('survey_id')\n            answers = json_data.get('answers', {})\n            completed = json_data.get('completed', True)\n\n            if not survey_id:\n                return self.fail(1, 'survey_id required')\n\n            if self.ap.survey:\n                ok = await self.ap.survey.submit_response(survey_id, answers, completed)\n                if ok:\n                    return self.success()\n                return self.fail(2, 'Failed to submit response')\n            return self.fail(3, 'Survey not available')\n\n        @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)\n        async def _dismiss() -> str:\n            \"\"\"Dismiss survey.\"\"\"\n            json_data = await quart.request.json\n            survey_id = json_data.get('survey_id')\n\n            if not survey_id:\n                return self.fail(1, 'survey_id required')\n\n            if self.ap.survey:\n                ok = await self.ap.survey.dismiss_survey(survey_id)\n                if ok:\n                    return self.success()\n                return self.fail(2, 'Failed to dismiss')\n            return self.fail(3, 'Survey not available')\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/system.py",
    "content": "import quart\n\nfrom .. import group\nfrom .....utils import constants\n\n\n@group.group_class('system', '/api/v1/system')\nclass SystemRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)\n        async def _() -> str:\n            return self.success(\n                data={\n                    'version': constants.semantic_version,\n                    'debug': constants.debug_mode,\n                    'edition': constants.edition,\n                    'enable_marketplace': self.ap.instance_config.data.get('plugin', {}).get(\n                        'enable_marketplace', True\n                    ),\n                    'cloud_service_url': (\n                        self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app')\n                    ),\n                    'allow_modify_login_info': self.ap.instance_config.data.get('system', {}).get(\n                        'allow_modify_login_info', True\n                    ),\n                    'disable_models_service': self.ap.instance_config.data.get('space', {}).get(\n                        'disable_models_service', False\n                    ),\n                    'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),\n                }\n            )\n\n        @self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def _() -> str:\n            task_type = quart.request.args.get('type')\n\n            if task_type == '':\n                task_type = None\n\n            return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type))\n\n        @self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def _(task_id: str) -> str:\n            task = self.ap.task_mgr.get_task_by_id(int(task_id))\n\n            if task is None:\n                return self.http_status(404, 404, 'Task not found')\n\n            return self.success(data=task.to_dict())\n\n        @self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)\n        async def _() -> str:\n            if not constants.debug_mode:\n                return self.http_status(403, 403, 'Forbidden')\n\n            py_code = await quart.request.data\n\n            ap = self.ap\n\n            return self.success(data=exec(py_code, {'ap': ap}))\n\n        @self.route(\n            '/debug/plugin/action',\n            methods=['POST'],\n            auth_type=group.AuthType.USER_TOKEN,\n        )\n        async def _() -> str:\n            if not constants.debug_mode:\n                return self.http_status(403, 403, 'Forbidden')\n\n            data = await quart.request.json\n\n            class AnoymousAction:\n                value = 'anonymous_action'\n\n                def __init__(self, value: str):\n                    self.value = value\n\n            resp = await self.ap.plugin_connector.handler.call_action(\n                AnoymousAction(data['action']),\n                data['data'],\n                timeout=data.get('timeout', 10),\n            )\n\n            return self.success(data=resp)\n\n        @self.route(\n            '/status/plugin-system',\n            methods=['GET'],\n            auth_type=group.AuthType.USER_TOKEN,\n        )\n        async def _() -> str:\n            plugin_connector_error = 'ok'\n            is_connected = True\n\n            try:\n                await self.ap.plugin_connector.ping_plugin_runtime()\n            except Exception as e:\n                plugin_connector_error = str(e)\n                is_connected = False\n\n            return self.success(\n                data={\n                    'is_enable': self.ap.plugin_connector.is_enable_plugin,\n                    'is_connected': is_connected,\n                    'plugin_connector_error': plugin_connector_error,\n                }\n            )\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/user.py",
    "content": "import quart\nimport argon2\nimport asyncio\nimport traceback\n\nfrom .. import group\nfrom .....entity.errors import account as account_errors\n\n\n@group.group_class('user', '/api/v1/user')\nclass UserRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)\n        async def _() -> str:\n            if quart.request.method == 'GET':\n                return self.success(data={'initialized': await self.ap.user_service.is_initialized()})\n\n            if await self.ap.user_service.is_initialized():\n                return self.fail(1, 'System already initialized')\n\n            json_data = await quart.request.json\n\n            user_email = json_data['user']\n            password = json_data['password']\n\n            await self.ap.user_service.create_user(user_email, password)\n\n            return self.success()\n\n        @self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE)\n        async def _() -> str:\n            json_data = await quart.request.json\n\n            try:\n                token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])\n            except argon2.exceptions.VerifyMismatchError:\n                return self.fail(1, 'Invalid username or password')\n            except ValueError as e:\n                return self.fail(1, str(e))\n\n            return self.success(data={'token': token})\n\n        @self.route('/check-token', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def _(user_email: str) -> str:\n            token = await self.ap.user_service.generate_jwt_token(user_email)\n\n            return self.success(data={'token': token})\n\n        @self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)\n        async def _() -> str:\n            json_data = await quart.request.json\n\n            user_email = json_data['user']\n            recovery_key = json_data['recovery_key']\n            new_password = json_data['new_password']\n\n            # hard sleep 3s for security\n            await asyncio.sleep(3)\n\n            if not await self.ap.user_service.is_initialized():\n                return self.http_status(400, -1, 'System not initialized')\n\n            user_obj = await self.ap.user_service.get_user_by_email(user_email)\n\n            if user_obj is None:\n                return self.http_status(400, -1, 'User not found')\n\n            if recovery_key != self.ap.instance_config.data['system']['recovery_key']:\n                return self.http_status(403, -1, 'Invalid recovery key')\n\n            await self.ap.user_service.reset_password(user_email, new_password)\n\n            return self.success(data={'user': user_email})\n\n        @self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)\n        async def _(user_email: str) -> str:\n            # Check if password change is allowed\n            allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(\n                'allow_modify_login_info', True\n            )\n            if not allow_modify_login_info:\n                return self.http_status(403, -1, 'Modifying login info is disabled')\n\n            json_data = await quart.request.json\n\n            current_password = json_data['current_password']\n            new_password = json_data['new_password']\n\n            try:\n                await self.ap.user_service.change_password(user_email, current_password, new_password)\n            except argon2.exceptions.VerifyMismatchError:\n                return self.http_status(400, -1, 'Current password is incorrect')\n            except ValueError as e:\n                return self.http_status(400, -1, str(e))\n\n            return self.success(data={'user': user_email})\n\n        # Space OAuth endpoints (redirect flow)\n\n        @self.route('/space/authorize-url', methods=['GET'], auth_type=group.AuthType.NONE)\n        async def _() -> str:\n            \"\"\"Get Space OAuth authorization URL for redirect\"\"\"\n            redirect_uri = quart.request.args.get('redirect_uri', '')\n            state = quart.request.args.get('state', '')\n\n            if not redirect_uri:\n                return self.fail(1, 'Missing redirect_uri parameter')\n\n            try:\n                authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)\n                return self.success(data={'authorize_url': authorize_url})\n            except Exception as e:\n                return self.fail(1, str(e))\n\n        @self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE)\n        async def _() -> str:\n            \"\"\"Handle OAuth callback - exchange code for tokens and authenticate\"\"\"\n            json_data = await quart.request.json\n            code = json_data.get('code')\n\n            if not code:\n                return self.fail(1, 'Missing authorization code')\n\n            try:\n                # Exchange code for tokens\n                token_data = await self.ap.space_service.exchange_oauth_code(code)\n                access_token = token_data.get('access_token')\n                refresh_token = token_data.get('refresh_token')\n                expires_in = token_data.get('expires_in', 0)\n\n                if not access_token:\n                    return self.fail(1, 'Failed to get access token from Space')\n\n                # Authenticate and create/update local user\n                jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(\n                    access_token, refresh_token, expires_in\n                )\n\n                return self.success(\n                    data={\n                        'token': jwt_token,\n                        'user': user_obj.user,\n                    }\n                )\n            except account_errors.AccountEmailMismatchError as e:\n                return self.fail(3, str(e))\n            except ValueError as e:\n                traceback.print_exc()\n                return self.fail(1, str(e))\n            except Exception as e:\n                traceback.print_exc()\n                return self.fail(2, f'OAuth callback failed: {str(e)}')\n\n        @self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def _(user_email: str) -> str:\n            \"\"\"Get current user information including account type\"\"\"\n            user_obj = await self.ap.user_service.get_user_by_email(user_email)\n\n            if user_obj is None:\n                return self.http_status(404, -1, 'User not found')\n\n            return self.success(\n                data={\n                    'user': user_obj.user,\n                    'account_type': user_obj.account_type,\n                    'has_password': bool(user_obj.password and user_obj.password.strip()),\n                }\n            )\n\n        @self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)\n        async def _(user_email: str) -> str:\n            \"\"\"Get Space credits balance for current user\"\"\"\n            credits = await self.ap.space_service.get_credits(user_email)\n            return self.success(data={'credits': credits})\n\n        @self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)\n        async def _() -> str:\n            \"\"\"Get account info for login page (account type and has_password)\"\"\"\n            if not await self.ap.user_service.is_initialized():\n                return self.success(data={'initialized': False})\n\n            user_obj = await self.ap.user_service.get_first_user()\n            if user_obj is None:\n                return self.success(data={'initialized': False})\n\n            return self.success(\n                data={\n                    'initialized': True,\n                    'account_type': user_obj.account_type,\n                    'has_password': bool(user_obj.password and user_obj.password.strip()),\n                }\n            )\n\n        @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)\n        async def _(user_email: str) -> str:\n            \"\"\"Set password for Space account (first time) or change password\"\"\"\n            json_data = await quart.request.json\n            new_password = json_data.get('new_password')\n            current_password = json_data.get('current_password')\n\n            if not new_password:\n                return self.http_status(400, -1, 'New password is required')\n\n            user_obj = await self.ap.user_service.get_user_by_email(user_email)\n            if user_obj is None:\n                return self.http_status(404, -1, 'User not found')\n\n            try:\n                await self.ap.user_service.set_password(user_email, new_password, current_password)\n                return self.success(data={'user': user_email})\n            except ValueError as e:\n                return self.http_status(400, -1, str(e))\n            except argon2.exceptions.VerifyMismatchError:\n                return self.http_status(400, -1, 'Current password is incorrect')\n\n        @self.route('/bind-space', methods=['POST'], auth_type=group.AuthType.NONE)\n        async def _() -> str:\n            \"\"\"Bind Space account to existing local account\"\"\"\n            # Check if modifying login info is allowed\n            allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(\n                'allow_modify_login_info', True\n            )\n            if not allow_modify_login_info:\n                return self.http_status(403, -1, 'Modifying login info is disabled')\n\n            json_data = await quart.request.json\n            code = json_data.get('code')\n            state = json_data.get('state')  # JWT token passed as state\n\n            if not code:\n                return self.http_status(400, -1, 'Missing authorization code')\n\n            if not state:\n                return self.http_status(400, -1, 'Missing state parameter')\n\n            # Verify state is a valid JWT token\n            try:\n                user_email = await self.ap.user_service.verify_jwt_token(state)\n            except Exception:\n                return self.http_status(401, -1, 'Invalid or expired state')\n\n            user_obj = await self.ap.user_service.get_user_by_email(user_email)\n            if user_obj is None:\n                return self.http_status(404, -1, 'User not found')\n\n            if user_obj.account_type != 'local':\n                return self.http_status(400, -1, 'Only local accounts can bind to Space')\n\n            try:\n                updated_user = await self.ap.user_service.bind_space_account(user_email, code)\n                jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user)\n                return self.success(\n                    data={\n                        'token': jwt_token,\n                        'user': updated_user.user,\n                        'account_type': updated_user.account_type,\n                    }\n                )\n            except ValueError as e:\n                return self.http_status(400, -1, str(e))\n            except Exception as e:\n                return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}')\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py",
    "content": "import quart\n\nfrom .. import group\n\n\n@group.group_class('webhook_mgmt', '/api/v1/webhooks')\nclass WebhookManagementRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('', methods=['GET', 'POST'])\n        async def _() -> str:\n            if quart.request.method == 'GET':\n                webhooks = await self.ap.webhook_service.get_webhooks()\n                return self.success(data={'webhooks': webhooks})\n            elif quart.request.method == 'POST':\n                json_data = await quart.request.json\n                name = json_data.get('name', '')\n                url = json_data.get('url', '')\n                description = json_data.get('description', '')\n                enabled = json_data.get('enabled', True)\n\n                if not name:\n                    return self.http_status(400, -1, 'Name is required')\n                if not url:\n                    return self.http_status(400, -1, 'URL is required')\n\n                webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)\n                return self.success(data={'webhook': webhook})\n\n        @self.route('/<int:webhook_id>', methods=['GET', 'PUT', 'DELETE'])\n        async def _(webhook_id: int) -> str:\n            if quart.request.method == 'GET':\n                webhook = await self.ap.webhook_service.get_webhook(webhook_id)\n                if webhook is None:\n                    return self.http_status(404, -1, 'Webhook not found')\n                return self.success(data={'webhook': webhook})\n\n            elif quart.request.method == 'PUT':\n                json_data = await quart.request.json\n                name = json_data.get('name')\n                url = json_data.get('url')\n                description = json_data.get('description')\n                enabled = json_data.get('enabled')\n\n                await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)\n                return self.success()\n\n            elif quart.request.method == 'DELETE':\n                await self.ap.webhook_service.delete_webhook(webhook_id)\n                return self.success()\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/groups/webhooks.py",
    "content": "from __future__ import annotations\n\nimport quart\nimport traceback\n\nfrom .. import group\n\n\n@group.group_class('webhooks', '/bots')\nclass WebhookRouterGroup(group.RouterGroup):\n    async def initialize(self) -> None:\n        @self.route('/<bot_uuid>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)\n        async def handle_webhook(bot_uuid: str):\n            \"\"\"处理 bot webhook 回调（无子路径）\"\"\"\n            return await self._dispatch_webhook(bot_uuid, '')\n\n        @self.route('/<bot_uuid>/<path:path>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)\n        async def handle_webhook_with_path(bot_uuid: str, path: str):\n            \"\"\"处理 bot webhook 回调（带子路径）\"\"\"\n            return await self._dispatch_webhook(bot_uuid, path)\n\n    async def _dispatch_webhook(self, bot_uuid: str, path: str):\n        \"\"\"分发 webhook 请求到对应的 bot adapter\n\n        Args:\n            bot_uuid: Bot 的 UUID\n            path: 子路径（如果有的话）\n\n        Returns:\n            适配器返回的响应\n        \"\"\"\n        try:\n            runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)\n\n            if not runtime_bot:\n                return quart.jsonify({'error': 'Bot not found'}), 404\n\n            if not runtime_bot.enable:\n                return quart.jsonify({'error': 'Bot is disabled'}), 403\n\n            if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'):\n                return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501\n\n            response = await runtime_bot.adapter.handle_unified_webhook(\n                bot_uuid=bot_uuid,\n                path=path,\n                request=quart.request,\n            )\n\n            return response\n\n        except Exception as e:\n            self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}')\n            return quart.jsonify({'error': str(e)}), 500\n"
  },
  {
    "path": "src/langbot/pkg/api/http/controller/main.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport os\n\nimport quart\nimport quart_cors\nfrom werkzeug.exceptions import RequestEntityTooLarge\n\nfrom ....core import app, entities as core_entities\nfrom ....utils import importutil\n\nfrom . import groups\nfrom . import group\nfrom .groups import provider as groups_provider\nfrom .groups import platform as groups_platform\nfrom .groups import pipelines as groups_pipelines\nfrom .groups import knowledge as groups_knowledge\nfrom .groups import resources as groups_resources\n\nimportutil.import_modules_in_pkg(groups)\nimportutil.import_modules_in_pkg(groups_provider)\nimportutil.import_modules_in_pkg(groups_platform)\nimportutil.import_modules_in_pkg(groups_pipelines)\nimportutil.import_modules_in_pkg(groups_knowledge)\nimportutil.import_modules_in_pkg(groups_resources)\n\n\nclass HTTPController:\n    ap: app.Application\n\n    quart_app: quart.Quart\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n        self.quart_app = quart.Quart(__name__)\n        quart_cors.cors(self.quart_app, allow_origin='*')\n\n        # Set maximum content length to prevent large file uploads\n        self.quart_app.config['MAX_CONTENT_LENGTH'] = group.MAX_FILE_SIZE\n\n    async def initialize(self) -> None:\n        # Register custom error handler for file size limit\n        @self.quart_app.errorhandler(RequestEntityTooLarge)\n        async def handle_request_entity_too_large(e):\n            return quart.jsonify(\n                {\n                    'code': 400,\n                    'msg': 'File size exceeds 10MB limit. Please split large files into smaller parts.',\n                }\n            ), 400\n\n        await self.register_routes()\n\n    async def run(self) -> None:\n        if True:\n\n            async def shutdown_trigger_placeholder():\n                while True:\n                    await asyncio.sleep(1)\n\n            async def exception_handler(*args, **kwargs):\n                try:\n                    await self.quart_app.run_task(*args, **kwargs)\n                except Exception as e:\n                    self.ap.logger.error(f'Failed to start HTTP service: {e}')\n\n            self.ap.task_mgr.create_task(\n                exception_handler(\n                    host='0.0.0.0',\n                    port=self.ap.instance_config.data['api']['port'],\n                    shutdown_trigger=shutdown_trigger_placeholder,\n                ),\n                name='http-api-quart',\n                scopes=[core_entities.LifecycleControlScope.APPLICATION],\n            )\n\n            # await asyncio.sleep(5)\n\n    async def register_routes(self) -> None:\n        @self.quart_app.route('/healthz')\n        async def healthz():\n            return {'code': 0, 'msg': 'ok'}\n\n        for g in group.preregistered_groups:\n            ginst = g(self.ap, self.quart_app)\n            await ginst.initialize()\n\n        from ....utils import paths\n\n        frontend_path = paths.get_frontend_path()\n\n        @self.quart_app.route('/')\n        async def index():\n            response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')\n            response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'\n            response.headers['Pragma'] = 'no-cache'\n            response.headers['Expires'] = '0'\n            return response\n\n        @self.quart_app.route('/<path:path>')\n        async def static_file(path: str):\n            if not (\n                os.path.exists(os.path.join(frontend_path, path)) and os.path.isfile(os.path.join(frontend_path, path))\n            ):\n                if os.path.exists(os.path.join(frontend_path, path + '.html')):\n                    path += '.html'\n                else:\n                    return await quart.send_from_directory(frontend_path, '404.html')\n\n            mimetype = None\n\n            if path.endswith('.html'):\n                mimetype = 'text/html'\n            elif path.endswith('.js'):\n                mimetype = 'application/javascript'\n            elif path.endswith('.css'):\n                mimetype = 'text/css'\n            elif path.endswith('.png'):\n                mimetype = 'image/png'\n            elif path.endswith('.jpg'):\n                mimetype = 'image/jpeg'\n            elif path.endswith('.jpeg'):\n                mimetype = 'image/jpeg'\n            elif path.endswith('.gif'):\n                mimetype = 'image/gif'\n            elif path.endswith('.svg'):\n                mimetype = 'image/svg+xml'\n            elif path.endswith('.ico'):\n                mimetype = 'image/x-icon'\n            elif path.endswith('.json'):\n                mimetype = 'application/json'\n            elif path.endswith('.txt'):\n                mimetype = 'text/plain'\n\n            response = await quart.send_from_directory(frontend_path, path, mimetype=mimetype)\n            response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'\n            response.headers['Pragma'] = 'no-cache'\n            response.headers['Expires'] = '0'\n            return response\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/api/http/service/apikey.py",
    "content": "from __future__ import annotations\n\nimport secrets\nimport sqlalchemy\n\nfrom ....core import app\nfrom ....entity.persistence import apikey\n\n\nclass ApiKeyService:\n    ap: app.Application\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    async def get_api_keys(self) -> list[dict]:\n        \"\"\"Get all API keys\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey))\n\n        keys = result.all()\n        return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys]\n\n    async def create_api_key(self, name: str, description: str = '') -> dict:\n        \"\"\"Create a new API key\"\"\"\n        # Generate a secure random API key\n        key = f'lbk_{secrets.token_urlsafe(32)}'\n\n        key_data = {'name': name, 'key': key, 'description': description}\n\n        await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data))\n\n        # Retrieve the created key\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)\n        )\n        created_key = result.first()\n\n        return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key)\n\n    async def get_api_key(self, key_id: int) -> dict | None:\n        \"\"\"Get a specific API key by ID\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id)\n        )\n\n        key = result.first()\n\n        if key is None:\n            return None\n\n        return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key)\n\n    async def verify_api_key(self, key: str) -> bool:\n        \"\"\"Verify if an API key is valid\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)\n        )\n\n        key_obj = result.first()\n        return key_obj is not None\n\n    async def delete_api_key(self, key_id: int) -> None:\n        \"\"\"Delete an API key\"\"\"\n        await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id))\n\n    async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None:\n        \"\"\"Update an API key's metadata (name, description)\"\"\"\n        update_data = {}\n        if name is not None:\n            update_data['name'] = name\n        if description is not None:\n            update_data['description'] = description\n\n        if update_data:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data)\n            )\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/bot.py",
    "content": "from __future__ import annotations\n\nimport uuid\nimport sqlalchemy\nimport typing\n\nfrom ....core import app\nfrom ....entity.persistence import bot as persistence_bot\nfrom ....entity.persistence import pipeline as persistence_pipeline\n\n\nclass BotService:\n    \"\"\"Bot service\"\"\"\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    async def get_bots(self, include_secret: bool = True) -> list[dict]:\n        \"\"\"获取所有机器人\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))\n\n        bots = result.all()\n\n        masked_columns = []\n        if not include_secret:\n            masked_columns = ['adapter_config']\n\n        return [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) for bot in bots]\n\n    async def get_bot(self, bot_uuid: str, include_secret: bool = True) -> dict | None:\n        \"\"\"获取机器人\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)\n        )\n\n        bot = result.first()\n\n        if bot is None:\n            return None\n\n        masked_columns = []\n        if not include_secret:\n            masked_columns = ['adapter_config']\n\n        return self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns)\n\n    async def get_runtime_bot_info(self, bot_uuid: str, include_secret: bool = True) -> dict:\n        \"\"\"获取机器人运行时信息\"\"\"\n        persistence_bot = await self.get_bot(bot_uuid, include_secret)\n        if persistence_bot is None:\n            raise Exception('Bot not found')\n\n        adapter_runtime_values = {}\n\n        runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)\n        if runtime_bot is not None:\n            adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id\n\n        # Webhook URL for unified webhook adapters (independent of bot running state)\n        if persistence_bot['adapter'] in [\n            'wecom',\n            'wecombot',\n            'officialaccount',\n            'qqofficial',\n            'slack',\n            'wecomcs',\n            'LINE',\n            'lark',\n        ]:\n            webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')\n            extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')\n            webhook_url = f'/bots/{bot_uuid}'\n            adapter_runtime_values['webhook_url'] = webhook_url\n            adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'\n            adapter_runtime_values['extra_webhook_full_url'] = (\n                f'{extra_webhook_prefix}{webhook_url}' if extra_webhook_prefix else ''\n            )\n        else:\n            adapter_runtime_values['webhook_url'] = None\n            adapter_runtime_values['webhook_full_url'] = None\n            adapter_runtime_values['extra_webhook_full_url'] = None\n\n        persistence_bot['adapter_runtime_values'] = adapter_runtime_values\n\n        return persistence_bot\n\n    async def create_bot(self, bot_data: dict) -> str:\n        \"\"\"Create bot\"\"\"\n        # Check limitation\n        limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})\n        max_bots = limitation.get('max_bots', -1)\n        if max_bots >= 0:\n            existing_bots = await self.get_bots()\n            if len(existing_bots) >= max_bots:\n                raise ValueError(f'Maximum number of bots ({max_bots}) reached')\n\n        # TODO: 检查配置信息格式\n        bot_data['uuid'] = str(uuid.uuid4())\n\n        # checkout the default pipeline\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(\n                persistence_pipeline.LegacyPipeline.is_default == True\n            )\n        )\n        pipeline = result.first()\n        if pipeline is not None:\n            bot_data['use_pipeline_uuid'] = pipeline.uuid\n            bot_data['use_pipeline_name'] = pipeline.name\n\n        await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))\n\n        bot = await self.get_bot(bot_data['uuid'])\n\n        await self.ap.platform_mgr.load_bot(bot)\n\n        return bot_data['uuid']\n\n    async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:\n        \"\"\"Update bot\"\"\"\n        if 'uuid' in bot_data:\n            del bot_data['uuid']\n\n        # set use_pipeline_name\n        if 'use_pipeline_uuid' in bot_data:\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(\n                    persistence_pipeline.LegacyPipeline.uuid == bot_data['use_pipeline_uuid']\n                )\n            )\n            pipeline = result.first()\n            if pipeline is not None:\n                bot_data['use_pipeline_name'] = pipeline.name\n            else:\n                raise Exception('Pipeline not found')\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_bot.Bot).values(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)\n        )\n        await self.ap.platform_mgr.remove_bot(bot_uuid)\n\n        # select from db\n        bot = await self.get_bot(bot_uuid)\n\n        runtime_bot = await self.ap.platform_mgr.load_bot(bot)\n\n        if runtime_bot.enable:\n            await runtime_bot.run()\n\n        # update all conversation that use this bot\n        for session in self.ap.sess_mgr.session_list:\n            if session.using_conversation is not None and session.using_conversation.bot_uuid == bot_uuid:\n                session.using_conversation = None\n\n    async def delete_bot(self, bot_uuid: str) -> None:\n        \"\"\"Delete bot\"\"\"\n        await self.ap.platform_mgr.remove_bot(bot_uuid)\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)\n        )\n\n    async def list_event_logs(\n        self, bot_uuid: str, from_index: int, max_count: int\n    ) -> typing.Tuple[list[dict], int, int, int]:\n        runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)\n        if runtime_bot is None:\n            raise Exception('Bot not found')\n\n        logs, total_count = await runtime_bot.logger.get_logs(from_index, max_count)\n\n        return [log.to_json() for log in logs], total_count\n\n    async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None:\n        \"\"\"Send message to a specific target via bot\n\n        Args:\n            bot_uuid: The UUID of the bot\n            target_type: The type of the target, can be \"group\", \"person\"\n            target_id: The ID of the target\n            message_chain_data: The message chain data in dict format\n        \"\"\"\n        # Import here to avoid circular imports\n        import langbot_plugin.api.entities.builtin.platform.message as platform_message\n\n        # Get runtime bot\n        runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)\n        if runtime_bot is None:\n            raise Exception(f'Bot not found: {bot_uuid}')\n\n        # Validate and convert message chain\n        try:\n            message_chain = platform_message.MessageChain.model_validate(message_chain_data)\n        except Exception as e:\n            raise Exception(f'Invalid message_chain format: {str(e)}')\n\n        # Send message via adapter\n        await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain)\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/knowledge.py",
    "content": "from __future__ import annotations\n\nimport sqlalchemy\n\nfrom ....core import app\nfrom ....entity.persistence import rag as persistence_rag\n\n\nclass KnowledgeService:\n    \"\"\"知识库服务\"\"\"\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    async def get_knowledge_bases(self) -> list[dict]:\n        \"\"\"获取所有知识库\"\"\"\n        return await self.ap.rag_mgr.get_all_knowledge_base_details()\n\n    async def get_knowledge_base(self, kb_uuid: str) -> dict | None:\n        \"\"\"获取知识库\"\"\"\n        return await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid)\n\n    async def create_knowledge_base(self, kb_data: dict) -> str:\n        \"\"\"创建知识库\"\"\"\n        # In new architecture, we delegate entirely to RAGManager which uses plugins.\n        # Legacy internal KB creation is removed.\n\n        knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id')\n        if not knowledge_engine_plugin_id:\n            raise ValueError('knowledge_engine_plugin_id is required')\n\n        kb = await self.ap.rag_mgr.create_knowledge_base(\n            name=kb_data.get('name', 'Untitled'),\n            knowledge_engine_plugin_id=knowledge_engine_plugin_id,\n            creation_settings=kb_data.get('creation_settings', {}),\n            retrieval_settings=kb_data.get('retrieval_settings', {}),\n            description=kb_data.get('description', ''),\n        )\n        return kb.uuid\n\n    async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:\n        \"\"\"更新知识库\"\"\"\n        # Filter to only mutable fields\n        filtered_data = {k: v for k, v in kb_data.items() if k in persistence_rag.KnowledgeBase.MUTABLE_FIELDS}\n\n        if not filtered_data:\n            return\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_rag.KnowledgeBase)\n            .values(filtered_data)\n            .where(persistence_rag.KnowledgeBase.uuid == kb_uuid)\n        )\n        await self.ap.rag_mgr.remove_knowledge_base_from_runtime(kb_uuid)\n\n        kb = await self.get_knowledge_base(kb_uuid)\n        if kb is None:\n            raise Exception('Knowledge base not found after update')\n\n        await self.ap.rag_mgr.load_knowledge_base(kb)\n\n    async def _check_doc_capability(self, kb_uuid: str, operation: str) -> None:\n        \"\"\"Check if the KB's Knowledge Engine supports document operations.\n\n        Args:\n            kb_uuid: Knowledge base UUID.\n            operation: Human-readable operation name for error messages.\n\n        Raises:\n            Exception: If the KB does not support doc_ingestion.\n        \"\"\"\n        kb_info = await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid)\n        if not kb_info:\n            raise Exception('Knowledge base not found')\n        capabilities = kb_info.get('knowledge_engine', {}).get('capabilities', [])\n        if 'doc_ingestion' not in capabilities:\n            raise Exception(f'This knowledge base does not support {operation}')\n\n    async def store_file(self, kb_uuid: str, file_id: str, parser_plugin_id: str | None = None) -> str:\n        \"\"\"存储文件\"\"\"\n        runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)\n        if runtime_kb is None:\n            raise Exception('Knowledge base not found')\n\n        await self._check_doc_capability(kb_uuid, 'document upload')\n\n        result = await runtime_kb.store_file(file_id, parser_plugin_id=parser_plugin_id)\n\n        # Update the KB's updated_at timestamp\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_rag.KnowledgeBase)\n            .values(updated_at=sqlalchemy.func.now())\n            .where(persistence_rag.KnowledgeBase.uuid == kb_uuid)\n        )\n\n        return result\n\n    async def retrieve_knowledge_base(\n        self, kb_uuid: str, query: str, retrieval_settings: dict | None = None\n    ) -> list[dict]:\n        \"\"\"检索知识库\"\"\"\n        runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)\n        if runtime_kb is None:\n            raise Exception('Knowledge base not found')\n\n        # Pass retrieval_settings\n        results = await runtime_kb.retrieve(query, settings=retrieval_settings)\n\n        return [result.model_dump() for result in results]\n\n    async def get_files_by_knowledge_base(self, kb_uuid: str) -> list[dict]:\n        \"\"\"获取知识库文件\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid)\n        )\n        files = result.all()\n        return [self.ap.persistence_mgr.serialize_model(persistence_rag.File, file) for file in files]\n\n    async def delete_file(self, kb_uuid: str, file_id: str) -> None:\n        \"\"\"删除文件\"\"\"\n        runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)\n        if runtime_kb is None:\n            raise Exception('Knowledge base not found')\n\n        await self._check_doc_capability(kb_uuid, 'document deletion')\n\n        await runtime_kb.delete_file(file_id)\n\n        # Update the KB's updated_at timestamp\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_rag.KnowledgeBase)\n            .values(updated_at=sqlalchemy.func.now())\n            .where(persistence_rag.KnowledgeBase.uuid == kb_uuid)\n        )\n\n    async def delete_knowledge_base(self, kb_uuid: str) -> None:\n        \"\"\"删除知识库\"\"\"\n        # Delete from DB first to commit the deletion, then clean up runtime/plugin (best-effort)\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid)\n        )\n\n        # delete files\n        # NOTE: Chunk cleanup is for legacy (pre-plugin) KBs that stored chunks locally.\n        # For plugin-based Knowledge Engines, the Chunk table is not populated, so this is a no-op.\n        files = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid)\n        )\n        for file in files:\n            # delete chunks\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.delete(persistence_rag.Chunk).where(persistence_rag.Chunk.file_id == file.uuid)\n            )\n            # delete file\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file.uuid)\n            )\n\n        # Remove from runtime and notify plugin (best-effort, DB is already cleaned up)\n        await self.ap.rag_mgr.delete_knowledge_base(kb_uuid)\n\n    # ================= Knowledge Engine Discovery =================\n\n    async def list_knowledge_engines(self) -> list[dict]:\n        \"\"\"List all available Knowledge Engines from plugins.\"\"\"\n        engines = []\n\n        if not self.ap.plugin_connector.is_enable_plugin:\n            return engines\n\n        # Get KnowledgeEngine plugins\n        try:\n            knowledge_engines = await self.ap.plugin_connector.list_knowledge_engines()\n            engines.extend(knowledge_engines)\n        except Exception as e:\n            self.ap.logger.warning(f'Failed to list Knowledge Engines from plugins: {e}')\n\n        return engines\n\n    async def list_parsers(self, mime_type: str | None = None) -> list[dict]:\n        \"\"\"List available parsers, optionally filtered by MIME type.\"\"\"\n        if not self.ap.plugin_connector.is_enable_plugin:\n            return []\n        try:\n            parsers = await self.ap.plugin_connector.list_parsers()\n            if mime_type:\n                parsers = [p for p in parsers if mime_type in p.get('supported_mime_types', [])]\n            return parsers\n        except Exception as e:\n            self.ap.logger.warning(f'Failed to list parsers: {e}')\n            return []\n\n    async def get_engine_creation_schema(self, plugin_id: str) -> dict:\n        \"\"\"Get creation settings schema for a specific Knowledge Engine.\"\"\"\n        try:\n            return await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)\n        except Exception as e:\n            self.ap.logger.warning(f'Failed to get creation schema for {plugin_id}: {e}')\n            return {}\n\n    async def get_engine_retrieval_schema(self, plugin_id: str) -> dict:\n        \"\"\"Get retrieval settings schema for a specific Knowledge Engine.\"\"\"\n        try:\n            return await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)\n        except Exception as e:\n            self.ap.logger.warning(f'Failed to get retrieval schema for {plugin_id}: {e}')\n            return {}\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/mcp.py",
    "content": "from __future__ import annotations\n\nimport sqlalchemy\nimport uuid\nimport asyncio\n\nfrom ....core import app\nfrom ....entity.persistence import mcp as persistence_mcp\nfrom ....core import taskmgr\nfrom ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus\n\n\nclass MCPService:\n    ap: app.Application\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    async def get_runtime_info(self, server_name: str) -> dict | None:\n        session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)\n        if session:\n            return session.get_runtime_info_dict()\n        return None\n\n    async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]:\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))\n\n        servers = result.all()\n        serialized_servers = [\n            self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers\n        ]\n        if contain_runtime_info:\n            for server in serialized_servers:\n                runtime_info = await self.get_runtime_info(server['name'])\n\n                server['runtime_info'] = runtime_info if runtime_info else None\n\n        return serialized_servers\n\n    async def create_mcp_server(self, server_data: dict) -> str:\n        # Check limitation (extensions = MCP servers + plugins)\n        limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})\n        max_extensions = limitation.get('max_extensions', -1)\n        if max_extensions >= 0:\n            existing_mcp_servers = await self.get_mcp_servers()\n            plugins = await self.ap.plugin_connector.list_plugins()\n            total_extensions = len(existing_mcp_servers) + len(plugins)\n            if total_extensions >= max_extensions:\n                raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')\n\n        server_data['uuid'] = str(uuid.uuid4())\n        await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))\n\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid'])\n        )\n        server_entity = result.first()\n        if server_entity:\n            server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)\n            if self.ap.tool_mgr.mcp_tool_loader:\n                task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))\n                self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)\n\n        return server_data['uuid']\n\n    async def get_mcp_server_by_name(self, server_name: str) -> dict | None:\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)\n        )\n        server = result.first()\n        if server is None:\n            return None\n\n        runtime_info = await self.get_runtime_info(server.name)\n        server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)\n        server_data['runtime_info'] = runtime_info if runtime_info else None\n        return server_data\n\n    async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None:\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)\n        )\n        old_server = result.first()\n        old_server_name = old_server.name if old_server else None\n        old_enable = old_server.enable if old_server else False\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_mcp.MCPServer)\n            .where(persistence_mcp.MCPServer.uuid == server_uuid)\n            .values(server_data)\n        )\n\n        if self.ap.tool_mgr.mcp_tool_loader:\n            new_enable = server_data.get('enable', False)\n\n            need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions\n\n            if old_enable and not new_enable:\n                if need_remove:\n                    await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)\n\n            elif not old_enable and new_enable:\n                result = await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)\n                )\n                updated_server = result.first()\n                if updated_server:\n                    server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)\n                    task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))\n                    self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)\n\n            elif old_enable and new_enable:\n                if need_remove:\n                    await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)\n                result = await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)\n                )\n                updated_server = result.first()\n                if updated_server:\n                    server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)\n                    task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))\n                    self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)\n\n    async def delete_mcp_server(self, server_uuid: str) -> None:\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)\n        )\n        server = result.first()\n        server_name = server.name if server else None\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)\n        )\n\n        if server_name and self.ap.tool_mgr.mcp_tool_loader:\n            if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:\n                await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)\n\n    async def test_mcp_server(self, server_name: str, server_data: dict) -> int:\n        \"\"\"测试 MCP 服务器连接并返回任务 ID\"\"\"\n\n        runtime_mcp_session: RuntimeMCPSession | None = None\n\n        if server_name != '_':\n            runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)\n            if runtime_mcp_session is None:\n                raise ValueError(f'Server not found: {server_name}')\n\n            if runtime_mcp_session.status == MCPSessionStatus.ERROR:\n                coroutine = runtime_mcp_session.start()\n            else:\n                coroutine = runtime_mcp_session.refresh()\n        else:\n            runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)\n            coroutine = runtime_mcp_session.start()\n\n        ctx = taskmgr.TaskContext.new()\n        wrapper = self.ap.task_mgr.create_user_task(\n            coroutine,\n            kind='mcp-operation',\n            name=f'mcp-test-{server_name}',\n            label=f'Testing MCP server {server_name}',\n            context=ctx,\n        )\n        return wrapper.id\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/model.py",
    "content": "from __future__ import annotations\n\nimport uuid\n\nimport sqlalchemy\nfrom langbot_plugin.api.entities.builtin.provider import message as provider_message\n\nfrom ....core import app\nfrom ....entity.persistence import model as persistence_model\nfrom ....entity.persistence import pipeline as persistence_pipeline\nfrom ....provider.modelmgr import requester as model_requester\n\n\ndef _parse_provider_api_keys(provider_dict: dict) -> dict:\n    \"\"\"Parse api_keys if it's a JSON string\"\"\"\n    if isinstance(provider_dict.get('api_keys'), str):\n        import json\n\n        try:\n            provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])\n        except Exception:\n            provider_dict['api_keys'] = []\n    return provider_dict\n\n\nclass LLMModelsService:\n    ap: app.Application\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    async def get_llm_models(self, include_secret: bool = True) -> list[dict]:\n        \"\"\"Get all LLM models with provider info\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))\n        models = result.all()\n\n        # Get all providers for lookup\n        providers_result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.ModelProvider)\n        )\n        providers = {p.uuid: p for p in providers_result.all()}\n\n        models_list = []\n        for model in models:\n            model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)\n            provider = providers.get(model.provider_uuid)\n            if provider:\n                provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)\n                provider_dict = _parse_provider_api_keys(provider_dict)\n                if not include_secret:\n                    provider_dict['api_keys'] = ['***'] * len(provider_dict.get('api_keys', []))\n                model_dict['provider'] = provider_dict\n            models_list.append(model_dict)\n\n        return models_list\n\n    async def get_llm_models_by_provider(self, provider_uuid: str) -> list[dict]:\n        \"\"\"Get LLM models by provider UUID\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.LLMModel).where(\n                persistence_model.LLMModel.provider_uuid == provider_uuid\n            )\n        )\n        models = result.all()\n        return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]\n\n    async def create_llm_model(\n        self, model_data: dict, preserve_uuid: bool = False, auto_set_to_default_pipeline: bool = True\n    ) -> str:\n        \"\"\"Create a new LLM model\"\"\"\n        if not preserve_uuid:\n            model_data['uuid'] = str(uuid.uuid4())\n\n        # Handle provider creation if needed\n        if 'provider' in model_data:\n            provider_data = model_data.pop('provider')\n            if provider_data.get('uuid'):\n                model_data['provider_uuid'] = provider_data['uuid']\n            else:\n                # Create new provider\n                provider_uuid = await self.ap.provider_service.find_or_create_provider(\n                    requester=provider_data.get('requester', ''),\n                    base_url=provider_data.get('base_url', ''),\n                    api_keys=provider_data.get('api_keys', []),\n                )\n                model_data['provider_uuid'] = provider_uuid\n\n        await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))\n\n        runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])\n        if runtime_provider is None:\n            raise Exception('provider not found')\n\n        runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(\n            persistence_model.LLMModel(**model_data),\n            runtime_provider,\n        )\n        self.ap.model_mgr.llm_models.append(runtime_llm_model)\n\n        if auto_set_to_default_pipeline:\n            # set the default pipeline model to this model\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(\n                    persistence_pipeline.LegacyPipeline.is_default == True\n                )\n            )\n            pipeline = result.first()\n            if pipeline is not None:\n                model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})\n                if not model_config.get('primary', ''):\n                    pipeline_config = pipeline.config\n                    pipeline_config['ai']['local-agent']['model'] = {\n                        'primary': model_data['uuid'],\n                        'fallbacks': [],\n                    }\n                    pipeline_data = {'config': pipeline_config}\n                    await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)\n\n        return model_data['uuid']\n\n    async def get_llm_model(self, model_uuid: str) -> dict | None:\n        \"\"\"Get a single LLM model with provider info\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)\n        )\n        model = result.first()\n        if model is None:\n            return None\n\n        model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)\n\n        # Get provider\n        provider_result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.ModelProvider).where(\n                persistence_model.ModelProvider.uuid == model.provider_uuid\n            )\n        )\n        provider = provider_result.first()\n        if provider:\n            provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)\n            model_dict['provider'] = _parse_provider_api_keys(provider_dict)\n\n        return model_dict\n\n    async def update_llm_model(self, model_uuid: str, model_data: dict) -> None:\n        \"\"\"Update an existing LLM model\"\"\"\n        if 'uuid' in model_data:\n            del model_data['uuid']\n\n        # Handle provider update if needed\n        if 'provider' in model_data:\n            provider_data = model_data.pop('provider')\n            if provider_data.get('uuid'):\n                model_data['provider_uuid'] = provider_data['uuid']\n            else:\n                provider_uuid = await self.ap.provider_service.find_or_create_provider(\n                    requester=provider_data.get('requester', ''),\n                    base_url=provider_data.get('base_url', ''),\n                    api_keys=provider_data.get('api_keys', []),\n                )\n                model_data['provider_uuid'] = provider_uuid\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_model.LLMModel)\n            .where(persistence_model.LLMModel.uuid == model_uuid)\n            .values(**model_data)\n        )\n\n        await self.ap.model_mgr.remove_llm_model(model_uuid)\n\n        runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])\n        if runtime_provider is None:\n            raise Exception('provider not found')\n\n        runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(\n            persistence_model.LLMModel(**model_data),\n            runtime_provider,\n        )\n        self.ap.model_mgr.llm_models.append(runtime_llm_model)\n\n    async def delete_llm_model(self, model_uuid: str) -> None:\n        \"\"\"Delete an LLM model\"\"\"\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)\n        )\n        await self.ap.model_mgr.remove_llm_model(model_uuid)\n\n    async def test_llm_model(self, model_uuid: str, model_data: dict) -> None:\n        \"\"\"Test an LLM model\"\"\"\n        runtime_llm_model: model_requester.RuntimeLLMModel | None = None\n\n        if model_uuid != '_':\n            for model in self.ap.model_mgr.llm_models:\n                if model.model_entity.uuid == model_uuid:\n                    runtime_llm_model = model\n                    break\n            if runtime_llm_model is None:\n                raise Exception('model not found')\n        else:\n            runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(model_data)\n\n        extra_args = model_data.get('extra_args', {})\n        await runtime_llm_model.provider.invoke_llm(\n            query=None,\n            model=runtime_llm_model,\n            messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a \"Hello\".')],\n            funcs=[],\n            extra_args=extra_args,\n        )\n\n\nclass EmbeddingModelsService:\n    ap: app.Application\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    async def get_embedding_models(self) -> list[dict]:\n        \"\"\"Get all embedding models with provider info\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))\n        models = result.all()\n\n        providers_result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.ModelProvider)\n        )\n        providers = {p.uuid: p for p in providers_result.all()}\n\n        models_list = []\n        for model in models:\n            model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)\n            provider = providers.get(model.provider_uuid)\n            if provider:\n                provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)\n                model_dict['provider'] = _parse_provider_api_keys(provider_dict)\n            models_list.append(model_dict)\n\n        return models_list\n\n    async def get_embedding_models_by_provider(self, provider_uuid: str) -> list[dict]:\n        \"\"\"Get embedding models by provider UUID\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.EmbeddingModel).where(\n                persistence_model.EmbeddingModel.provider_uuid == provider_uuid\n            )\n        )\n        models = result.all()\n        return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models]\n\n    async def create_embedding_model(self, model_data: dict, preserve_uuid: bool = False) -> str:\n        \"\"\"Create a new embedding model\"\"\"\n        if not preserve_uuid:\n            model_data['uuid'] = str(uuid.uuid4())\n\n        if 'provider' in model_data:\n            provider_data = model_data.pop('provider')\n            if provider_data.get('uuid'):\n                model_data['provider_uuid'] = provider_data['uuid']\n            else:\n                provider_uuid = await self.ap.provider_service.find_or_create_provider(\n                    requester=provider_data.get('requester', ''),\n                    base_url=provider_data.get('base_url', ''),\n                    api_keys=provider_data.get('api_keys', []),\n                )\n                model_data['provider_uuid'] = provider_uuid\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)\n        )\n\n        runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])\n        if runtime_provider is None:\n            raise Exception('provider not found')\n\n        runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(\n            persistence_model.EmbeddingModel(**model_data),\n            runtime_provider,\n        )\n        self.ap.model_mgr.embedding_models.append(runtime_embedding_model)\n\n        return model_data['uuid']\n\n    async def get_embedding_model(self, model_uuid: str) -> dict | None:\n        \"\"\"Get a single embedding model with provider info\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.EmbeddingModel).where(\n                persistence_model.EmbeddingModel.uuid == model_uuid\n            )\n        )\n        model = result.first()\n        if model is None:\n            return None\n\n        model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)\n\n        provider_result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.ModelProvider).where(\n                persistence_model.ModelProvider.uuid == model.provider_uuid\n            )\n        )\n        provider = provider_result.first()\n        if provider:\n            provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)\n            model_dict['provider'] = _parse_provider_api_keys(provider_dict)\n\n        return model_dict\n\n    async def update_embedding_model(self, model_uuid: str, model_data: dict) -> None:\n        \"\"\"Update an existing embedding model\"\"\"\n        if 'uuid' in model_data:\n            del model_data['uuid']\n\n        if 'provider' in model_data:\n            provider_data = model_data.pop('provider')\n            if provider_data.get('uuid'):\n                model_data['provider_uuid'] = provider_data['uuid']\n            else:\n                provider_uuid = await self.ap.provider_service.find_or_create_provider(\n                    requester=provider_data.get('requester', ''),\n                    base_url=provider_data.get('base_url', ''),\n                    api_keys=provider_data.get('api_keys', []),\n                )\n                model_data['provider_uuid'] = provider_uuid\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_model.EmbeddingModel)\n            .where(persistence_model.EmbeddingModel.uuid == model_uuid)\n            .values(**model_data)\n        )\n\n        await self.ap.model_mgr.remove_embedding_model(model_uuid)\n\n        runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])\n        if runtime_provider is None:\n            raise Exception('provider not found')\n\n        runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(\n            persistence_model.EmbeddingModel(**model_data),\n            runtime_provider,\n        )\n        self.ap.model_mgr.embedding_models.append(runtime_embedding_model)\n\n    async def delete_embedding_model(self, model_uuid: str) -> None:\n        \"\"\"Delete an embedding model\"\"\"\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(persistence_model.EmbeddingModel).where(\n                persistence_model.EmbeddingModel.uuid == model_uuid\n            )\n        )\n        await self.ap.model_mgr.remove_embedding_model(model_uuid)\n\n    async def test_embedding_model(self, model_uuid: str, model_data: dict) -> None:\n        \"\"\"Test an embedding model\"\"\"\n        runtime_embedding_model: model_requester.RuntimeEmbeddingModel | None = None\n\n        if model_uuid != '_':\n            for model in self.ap.model_mgr.embedding_models:\n                if model.model_entity.uuid == model_uuid:\n                    runtime_embedding_model = model\n                    break\n            if runtime_embedding_model is None:\n                raise Exception('model not found')\n        else:\n            runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(model_data)\n\n        await runtime_embedding_model.provider.invoke_embedding(\n            model=runtime_embedding_model,\n            input_text=['Hello, world!'],\n            extra_args={},\n        )\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/monitoring.py",
    "content": "from __future__ import annotations\n\nimport uuid\nimport datetime\nimport sqlalchemy\n\nfrom ....core import app\nfrom ....entity.persistence import monitoring as persistence_monitoring\n\n\nclass MonitoringService:\n    \"\"\"Monitoring service\"\"\"\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    # ========== Recording Methods ==========\n\n    async def record_message(\n        self,\n        bot_id: str,\n        bot_name: str,\n        pipeline_id: str,\n        pipeline_name: str,\n        message_content: str,\n        session_id: str,\n        status: str = 'success',\n        level: str = 'info',\n        platform: str | None = None,\n        user_id: str | None = None,\n        user_name: str | None = None,\n        runner_name: str | None = None,\n        variables: str | None = None,\n        role: str = 'user',\n    ) -> str:\n        \"\"\"Record a message\"\"\"\n        message_id = str(uuid.uuid4())\n        message_data = {\n            'id': message_id,\n            'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),\n            'bot_id': bot_id,\n            'bot_name': bot_name,\n            'pipeline_id': pipeline_id,\n            'pipeline_name': pipeline_name,\n            'message_content': message_content,\n            'session_id': session_id,\n            'status': status,\n            'level': level,\n            'platform': platform,\n            'user_id': user_id,\n            'user_name': user_name,\n            'runner_name': runner_name,\n            'variables': variables,\n            'role': role,\n        }\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(persistence_monitoring.MonitoringMessage).values(message_data)\n        )\n\n        return message_id\n\n    async def record_llm_call(\n        self,\n        bot_id: str,\n        bot_name: str,\n        pipeline_id: str,\n        pipeline_name: str,\n        session_id: str,\n        model_name: str,\n        input_tokens: int,\n        output_tokens: int,\n        duration: int,\n        status: str = 'success',\n        cost: float | None = None,\n        error_message: str | None = None,\n        message_id: str | None = None,\n    ) -> str:\n        \"\"\"Record an LLM call\"\"\"\n        call_id = str(uuid.uuid4())\n        call_data = {\n            'id': call_id,\n            'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),\n            'model_name': model_name,\n            'input_tokens': input_tokens,\n            'output_tokens': output_tokens,\n            'total_tokens': input_tokens + output_tokens,\n            'duration': duration,\n            'cost': cost,\n            'status': status,\n            'bot_id': bot_id,\n            'bot_name': bot_name,\n            'pipeline_id': pipeline_id,\n            'pipeline_name': pipeline_name,\n            'session_id': session_id,\n            'error_message': error_message,\n            'message_id': message_id,\n        }\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(persistence_monitoring.MonitoringLLMCall).values(call_data)\n        )\n\n        return call_id\n\n    async def record_embedding_call(\n        self,\n        model_name: str,\n        prompt_tokens: int,\n        total_tokens: int,\n        duration: int,\n        input_count: int,\n        status: str = 'success',\n        error_message: str | None = None,\n        knowledge_base_id: str | None = None,\n        query_text: str | None = None,\n        session_id: str | None = None,\n        message_id: str | None = None,\n        call_type: str | None = None,\n    ) -> str:\n        \"\"\"Record an embedding call\"\"\"\n        call_id = str(uuid.uuid4())\n        call_data = {\n            'id': call_id,\n            'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),\n            'model_name': model_name,\n            'prompt_tokens': prompt_tokens,\n            'total_tokens': total_tokens,\n            'duration': duration,\n            'input_count': input_count,\n            'status': status,\n            'error_message': error_message,\n            'knowledge_base_id': knowledge_base_id,\n            'query_text': query_text,\n            'session_id': session_id,\n            'message_id': message_id,\n            'call_type': call_type,\n        }\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(persistence_monitoring.MonitoringEmbeddingCall).values(call_data)\n        )\n\n        return call_id\n\n    async def record_session_start(\n        self,\n        session_id: str,\n        bot_id: str,\n        bot_name: str,\n        pipeline_id: str,\n        pipeline_name: str,\n        platform: str | None = None,\n        user_id: str | None = None,\n        user_name: str | None = None,\n    ) -> None:\n        \"\"\"Record a new session\"\"\"\n        session_data = {\n            'session_id': session_id,\n            'bot_id': bot_id,\n            'bot_name': bot_name,\n            'pipeline_id': pipeline_id,\n            'pipeline_name': pipeline_name,\n            'message_count': 0,\n            'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),\n            'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),\n            'is_active': True,\n            'platform': platform,\n            'user_id': user_id,\n            'user_name': user_name,\n        }\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data)\n        )\n\n    async def update_session_activity(\n        self,\n        session_id: str,\n        pipeline_id: str | None = None,\n        pipeline_name: str | None = None,\n    ) -> bool:\n        \"\"\"Update session last activity time and increment message count.\n\n        Also updates pipeline info if the bot's pipeline has changed.\n\n        Returns:\n            True if session was found and updated, False if session doesn't exist.\n        \"\"\"\n        update_values = {\n            'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),\n            'message_count': persistence_monitoring.MonitoringSession.message_count + 1,\n        }\n\n        # Update pipeline info if provided (handles pipeline switch)\n        if pipeline_id is not None:\n            update_values['pipeline_id'] = pipeline_id\n        if pipeline_name is not None:\n            update_values['pipeline_name'] = pipeline_name\n\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_monitoring.MonitoringSession)\n            .where(persistence_monitoring.MonitoringSession.session_id == session_id)\n            .values(update_values)\n        )\n        # Check if any rows were updated\n        return result.rowcount > 0\n\n    async def record_error(\n        self,\n        bot_id: str,\n        bot_name: str,\n        pipeline_id: str,\n        pipeline_name: str,\n        error_type: str,\n        error_message: str,\n        session_id: str | None = None,\n        stack_trace: str | None = None,\n        message_id: str | None = None,\n    ) -> str:\n        \"\"\"Record an error\"\"\"\n        error_id = str(uuid.uuid4())\n        error_data = {\n            'id': error_id,\n            'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),\n            'error_type': error_type,\n            'error_message': error_message,\n            'bot_id': bot_id,\n            'bot_name': bot_name,\n            'pipeline_id': pipeline_id,\n            'pipeline_name': pipeline_name,\n            'session_id': session_id,\n            'stack_trace': stack_trace,\n            'message_id': message_id,\n        }\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(persistence_monitoring.MonitoringError).values(error_data)\n        )\n\n        return error_id\n\n    async def update_message_status(\n        self,\n        message_id: str,\n        status: str,\n        level: str | None = None,\n        variables: str | None = None,\n    ) -> None:\n        \"\"\"Update message status and optionally variables\"\"\"\n        update_values = {'status': status}\n        if level is not None:\n            update_values['level'] = level\n        if variables is not None:\n            update_values['variables'] = variables\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_monitoring.MonitoringMessage)\n            .where(persistence_monitoring.MonitoringMessage.id == message_id)\n            .values(update_values)\n        )\n\n    # ========== Query Methods ==========\n\n    async def get_overview_metrics(\n        self,\n        bot_ids: list[str] | None = None,\n        pipeline_ids: list[str] | None = None,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n    ) -> dict:\n        \"\"\"Get overview metrics\"\"\"\n        # Build base query conditions\n        message_conditions = []\n        llm_conditions = []\n        embedding_conditions = []\n        session_conditions = []\n\n        if bot_ids:\n            message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))\n            llm_conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))\n            session_conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))\n\n        if pipeline_ids:\n            message_conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids))\n            llm_conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids))\n            session_conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))\n\n        if start_time:\n            message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)\n            llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)\n            embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)\n            session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)\n\n        if end_time:\n            message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)\n            llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)\n            embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)\n            session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)\n\n        # Total messages\n        message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))\n        if message_conditions:\n            message_query = message_query.where(sqlalchemy.and_(*message_conditions))\n\n        total_messages_result = await self.ap.persistence_mgr.execute_async(message_query)\n        total_messages = total_messages_result.scalar() or 0\n\n        # Total LLM calls\n        llm_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id))\n        if llm_conditions:\n            llm_query = llm_query.where(sqlalchemy.and_(*llm_conditions))\n\n        llm_calls_result = await self.ap.persistence_mgr.execute_async(llm_query)\n        llm_calls = llm_calls_result.scalar() or 0\n\n        # Total Embedding calls\n        embedding_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id))\n        if embedding_conditions:\n            embedding_query = embedding_query.where(sqlalchemy.and_(*embedding_conditions))\n\n        embedding_calls_result = await self.ap.persistence_mgr.execute_async(embedding_query)\n        embedding_calls = embedding_calls_result.scalar() or 0\n\n        # Total model calls (LLM + Embedding)\n        model_calls = llm_calls + embedding_calls\n\n        # Success rate (based on messages)\n        success_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)).where(\n            persistence_monitoring.MonitoringMessage.status == 'success'\n        )\n        if message_conditions:\n            success_query = success_query.where(sqlalchemy.and_(*message_conditions))\n\n        success_result = await self.ap.persistence_mgr.execute_async(success_query)\n        success_count = success_result.scalar() or 0\n        success_rate = (success_count / total_messages * 100) if total_messages > 0 else 100\n\n        # Active sessions\n        active_session_query = sqlalchemy.select(\n            sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id)\n        ).where(persistence_monitoring.MonitoringSession.is_active == True)\n        if session_conditions:\n            active_session_query = active_session_query.where(sqlalchemy.and_(*session_conditions))\n\n        active_sessions_result = await self.ap.persistence_mgr.execute_async(active_session_query)\n        active_sessions = active_sessions_result.scalar() or 0\n\n        return {\n            'total_messages': total_messages,\n            'llm_calls': llm_calls,\n            'embedding_calls': embedding_calls,\n            'model_calls': model_calls,\n            'success_rate': round(success_rate, 2),\n            'active_sessions': active_sessions,\n        }\n\n    async def get_messages(\n        self,\n        bot_ids: list[str] | None = None,\n        pipeline_ids: list[str] | None = None,\n        session_ids: list[str] | None = None,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n        limit: int = 100,\n        offset: int = 0,\n    ) -> tuple[list[dict], int]:\n        \"\"\"Get messages with filters\"\"\"\n        conditions = []\n\n        if bot_ids:\n            conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))\n        if pipeline_ids:\n            conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids))\n        if session_ids:\n            conditions.append(persistence_monitoring.MonitoringMessage.session_id.in_(session_ids))\n        if start_time:\n            conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)\n        if end_time:\n            conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)\n\n        # Get total count\n        count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))\n        if conditions:\n            count_query = count_query.where(sqlalchemy.and_(*conditions))\n\n        count_result = await self.ap.persistence_mgr.execute_async(count_query)\n        total = count_result.scalar() or 0\n\n        # Get messages\n        query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by(\n            persistence_monitoring.MonitoringMessage.timestamp.desc()\n        )\n        if conditions:\n            query = query.where(sqlalchemy.and_(*conditions))\n\n        query = query.limit(limit).offset(offset)\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        messages_rows = result.all()\n\n        serialized = []\n        for row in messages_rows:\n            # Extract model instance from Row (SQLAlchemy returns Row objects)\n            msg = row[0] if isinstance(row, tuple) else row\n            serialized_msg = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, msg)\n            serialized.append(serialized_msg)\n\n        return (serialized, total)\n\n    async def get_llm_calls(\n        self,\n        bot_ids: list[str] | None = None,\n        pipeline_ids: list[str] | None = None,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n        limit: int = 100,\n        offset: int = 0,\n    ) -> tuple[list[dict], int]:\n        \"\"\"Get LLM calls with filters\"\"\"\n        conditions = []\n\n        if bot_ids:\n            conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))\n        if pipeline_ids:\n            conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids))\n        if start_time:\n            conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)\n        if end_time:\n            conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)\n\n        # Get total count\n        count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id))\n        if conditions:\n            count_query = count_query.where(sqlalchemy.and_(*conditions))\n\n        count_result = await self.ap.persistence_mgr.execute_async(count_query)\n        total = count_result.scalar() or 0\n\n        # Get LLM calls\n        query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by(\n            persistence_monitoring.MonitoringLLMCall.timestamp.desc()\n        )\n        if conditions:\n            query = query.where(sqlalchemy.and_(*conditions))\n\n        query = query.limit(limit).offset(offset)\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        llm_calls_rows = result.all()\n\n        return (\n            [\n                self.ap.persistence_mgr.serialize_model(\n                    persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row\n                )\n                for row in llm_calls_rows\n            ],\n            total,\n        )\n\n    async def get_embedding_calls(\n        self,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n        knowledge_base_id: str | None = None,\n        limit: int = 100,\n        offset: int = 0,\n    ) -> tuple[list[dict], int]:\n        \"\"\"Get embedding calls with filters\"\"\"\n        conditions = []\n\n        if start_time:\n            conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)\n        if end_time:\n            conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)\n        if knowledge_base_id:\n            conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id)\n\n        # Get total count\n        count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id))\n        if conditions:\n            count_query = count_query.where(sqlalchemy.and_(*conditions))\n\n        count_result = await self.ap.persistence_mgr.execute_async(count_query)\n        total = count_result.scalar() or 0\n\n        # Get embedding calls\n        query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by(\n            persistence_monitoring.MonitoringEmbeddingCall.timestamp.desc()\n        )\n        if conditions:\n            query = query.where(sqlalchemy.and_(*conditions))\n\n        query = query.limit(limit).offset(offset)\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        embedding_calls_rows = result.all()\n\n        return (\n            [\n                self.ap.persistence_mgr.serialize_model(\n                    persistence_monitoring.MonitoringEmbeddingCall, row[0] if isinstance(row, tuple) else row\n                )\n                for row in embedding_calls_rows\n            ],\n            total,\n        )\n\n    async def get_sessions(\n        self,\n        bot_ids: list[str] | None = None,\n        pipeline_ids: list[str] | None = None,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n        is_active: bool | None = None,\n        limit: int = 100,\n        offset: int = 0,\n    ) -> tuple[list[dict], int]:\n        \"\"\"Get sessions with filters\"\"\"\n        conditions = []\n\n        if bot_ids:\n            conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))\n        if pipeline_ids:\n            conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))\n        if start_time:\n            conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)\n        if end_time:\n            conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)\n        if is_active is not None:\n            conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)\n\n        # Get total count\n        count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id))\n        if conditions:\n            count_query = count_query.where(sqlalchemy.and_(*conditions))\n\n        count_result = await self.ap.persistence_mgr.execute_async(count_query)\n        total = count_result.scalar() or 0\n\n        # Get sessions\n        query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(\n            persistence_monitoring.MonitoringSession.last_activity.desc()\n        )\n        if conditions:\n            query = query.where(sqlalchemy.and_(*conditions))\n\n        query = query.limit(limit).offset(offset)\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        sessions_rows = result.all()\n\n        return (\n            [\n                self.ap.persistence_mgr.serialize_model(\n                    persistence_monitoring.MonitoringSession, row[0] if isinstance(row, tuple) else row\n                )\n                for row in sessions_rows\n            ],\n            total,\n        )\n\n    async def get_errors(\n        self,\n        bot_ids: list[str] | None = None,\n        pipeline_ids: list[str] | None = None,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n        limit: int = 100,\n        offset: int = 0,\n    ) -> tuple[list[dict], int]:\n        \"\"\"Get errors with filters\"\"\"\n        conditions = []\n\n        if bot_ids:\n            conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))\n        if pipeline_ids:\n            conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids))\n        if start_time:\n            conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time)\n        if end_time:\n            conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time)\n\n        # Get total count\n        count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringError.id))\n        if conditions:\n            count_query = count_query.where(sqlalchemy.and_(*conditions))\n\n        count_result = await self.ap.persistence_mgr.execute_async(count_query)\n        total = count_result.scalar() or 0\n\n        # Get errors\n        query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by(\n            persistence_monitoring.MonitoringError.timestamp.desc()\n        )\n        if conditions:\n            query = query.where(sqlalchemy.and_(*conditions))\n\n        query = query.limit(limit).offset(offset)\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        errors_rows = result.all()\n\n        return (\n            [\n                self.ap.persistence_mgr.serialize_model(\n                    persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row\n                )\n                for row in errors_rows\n            ],\n            total,\n        )\n\n    async def get_session_analysis(\n        self,\n        session_id: str,\n    ) -> dict:\n        \"\"\"Get detailed analysis for a specific session\"\"\"\n        # Get session info\n        session_query = sqlalchemy.select(persistence_monitoring.MonitoringSession).where(\n            persistence_monitoring.MonitoringSession.session_id == session_id\n        )\n        session_result = await self.ap.persistence_mgr.execute_async(session_query)\n        session_row = session_result.first()\n\n        if not session_row:\n            return {\n                'session_id': session_id,\n                'found': False,\n            }\n\n        session = session_row[0] if isinstance(session_row, tuple) else session_row\n\n        # Get messages for this session\n        messages_query = (\n            sqlalchemy.select(persistence_monitoring.MonitoringMessage)\n            .where(persistence_monitoring.MonitoringMessage.session_id == session_id)\n            .order_by(persistence_monitoring.MonitoringMessage.timestamp.asc())\n        )\n        messages_result = await self.ap.persistence_mgr.execute_async(messages_query)\n        messages_rows = messages_result.all()\n\n        # Count messages by status\n        success_messages = 0\n        error_messages = 0\n        pending_messages = 0\n        for row in messages_rows:\n            msg = row[0] if isinstance(row, tuple) else row\n            if msg.status == 'success':\n                success_messages += 1\n            elif msg.status == 'error':\n                error_messages += 1\n            elif msg.status == 'pending':\n                pending_messages += 1\n\n        # Get LLM calls for this session\n        llm_query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).where(\n            persistence_monitoring.MonitoringLLMCall.session_id == session_id\n        )\n        llm_result = await self.ap.persistence_mgr.execute_async(llm_query)\n        llm_rows = llm_result.all()\n\n        # Calculate LLM statistics\n        total_llm_calls = len(llm_rows)\n        total_input_tokens = 0\n        total_output_tokens = 0\n        total_tokens = 0\n        total_duration = 0\n        success_llm_calls = 0\n        error_llm_calls = 0\n\n        for row in llm_rows:\n            llm_call = row[0] if isinstance(row, tuple) else row\n            total_input_tokens += llm_call.input_tokens\n            total_output_tokens += llm_call.output_tokens\n            total_tokens += llm_call.total_tokens\n            total_duration += llm_call.duration\n            if llm_call.status == 'success':\n                success_llm_calls += 1\n            else:\n                error_llm_calls += 1\n\n        # Get errors for this session\n        error_query = (\n            sqlalchemy.select(persistence_monitoring.MonitoringError)\n            .where(persistence_monitoring.MonitoringError.session_id == session_id)\n            .order_by(persistence_monitoring.MonitoringError.timestamp.desc())\n        )\n        error_result = await self.ap.persistence_mgr.execute_async(error_query)\n        error_rows = error_result.all()\n\n        errors = [\n            self.ap.persistence_mgr.serialize_model(\n                persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row\n            )\n            for row in error_rows\n        ]\n\n        # Calculate session duration\n        if messages_rows:\n            first_msg = messages_rows[0][0] if isinstance(messages_rows[0], tuple) else messages_rows[0]\n            last_msg = messages_rows[-1][0] if isinstance(messages_rows[-1], tuple) else messages_rows[-1]\n            session_duration_seconds = int((last_msg.timestamp - first_msg.timestamp).total_seconds())\n        else:\n            session_duration_seconds = 0\n\n        return {\n            'session_id': session_id,\n            'found': True,\n            'session': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSession, session),\n            'message_stats': {\n                'total': len(messages_rows),\n                'success': success_messages,\n                'error': error_messages,\n                'pending': pending_messages,\n            },\n            'llm_stats': {\n                'total_calls': total_llm_calls,\n                'success_calls': success_llm_calls,\n                'error_calls': error_llm_calls,\n                'total_input_tokens': total_input_tokens,\n                'total_output_tokens': total_output_tokens,\n                'total_tokens': total_tokens,\n                'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0,\n            },\n            'errors': errors,\n            'session_duration_seconds': session_duration_seconds,\n        }\n\n    async def get_message_details(\n        self,\n        message_id: str,\n    ) -> dict:\n        \"\"\"Get detailed information for a specific message including associated LLM calls and errors\"\"\"\n        # Get message info\n        message_query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(\n            persistence_monitoring.MonitoringMessage.id == message_id\n        )\n        message_result = await self.ap.persistence_mgr.execute_async(message_query)\n        message_row = message_result.first()\n\n        if not message_row:\n            return {\n                'message_id': message_id,\n                'found': False,\n            }\n\n        message = message_row[0] if isinstance(message_row, tuple) else message_row\n\n        # Get LLM calls for this message\n        llm_query = (\n            sqlalchemy.select(persistence_monitoring.MonitoringLLMCall)\n            .where(persistence_monitoring.MonitoringLLMCall.message_id == message_id)\n            .order_by(persistence_monitoring.MonitoringLLMCall.timestamp.asc())\n        )\n        llm_result = await self.ap.persistence_mgr.execute_async(llm_query)\n        llm_rows = llm_result.all()\n\n        llm_calls = [\n            self.ap.persistence_mgr.serialize_model(\n                persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row\n            )\n            for row in llm_rows\n        ]\n\n        # Calculate LLM statistics\n        total_input_tokens = sum(call.input_tokens for call in llm_rows)\n        total_output_tokens = sum(call.output_tokens for call in llm_rows)\n        total_tokens = sum(call.total_tokens for call in llm_rows)\n        total_duration = sum(call.duration for call in llm_rows)\n\n        # Get errors for this message\n        error_query = (\n            sqlalchemy.select(persistence_monitoring.MonitoringError)\n            .where(persistence_monitoring.MonitoringError.message_id == message_id)\n            .order_by(persistence_monitoring.MonitoringError.timestamp.asc())\n        )\n        error_result = await self.ap.persistence_mgr.execute_async(error_query)\n        error_rows = error_result.all()\n\n        errors = [\n            self.ap.persistence_mgr.serialize_model(\n                persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row\n            )\n            for row in error_rows\n        ]\n\n        return {\n            'message_id': message_id,\n            'found': True,\n            'message': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, message),\n            'llm_calls': llm_calls,\n            'llm_stats': {\n                'total_calls': len(llm_rows),\n                'total_input_tokens': total_input_tokens,\n                'total_output_tokens': total_output_tokens,\n                'total_tokens': total_tokens,\n                'total_duration_ms': total_duration,\n                'average_duration_ms': int(total_duration / len(llm_rows)) if len(llm_rows) > 0 else 0,\n            },\n            'errors': errors,\n        }\n\n    # ========== Export Methods ==========\n\n    def _escape_csv_field(self, field: str | None) -> str:\n        \"\"\"Escape a field for CSV output\"\"\"\n        if field is None:\n            return ''\n        # Convert non-string types to string first\n        if not isinstance(field, str):\n            field = str(field)\n        # Replace common escape sequences\n        field = field.replace('\\r\\n', '\\n').replace('\\r', '\\n')\n        # If field contains comma, double quote, or newline, wrap in quotes\n        if ',' in field or '\"' in field or '\\n' in field:\n            # Escape double quotes by doubling them\n            field = '\"' + field.replace('\"', '\"\"') + '\"'\n        return field\n\n    def _format_timestamp(self, dt: datetime.datetime) -> str:\n        \"\"\"Format datetime to ISO format string\"\"\"\n        return dt.strftime('%Y-%m-%d %H:%M:%S')\n\n    def _extract_message_text(self, message_content: str) -> str:\n        \"\"\"Extract plain text from message chain JSON\"\"\"\n        if not message_content:\n            return ''\n\n        try:\n            import json\n\n            message_chain = json.loads(message_content)\n            if not isinstance(message_chain, list):\n                return message_content\n\n            text_parts = []\n            for component in message_chain:\n                if not isinstance(component, dict):\n                    continue\n                component_type = component.get('type')\n                if component_type == 'Plain':\n                    text = component.get('text', '')\n                    text_parts.append(text)\n                elif component_type == 'At':\n                    display = component.get('display', '')\n                    target = component.get('target', '')\n                    if display:\n                        text_parts.append(f'@{display}')\n                    elif target:\n                        text_parts.append(f'@{target}')\n                elif component_type == 'AtAll':\n                    text_parts.append('@All')\n                elif component_type == 'Image':\n                    text_parts.append('[Image]')\n                elif component_type == 'File':\n                    name = component.get('name', 'File')\n                    text_parts.append(f'[File: {name}]')\n                elif component_type == 'Voice':\n                    length = component.get('length', 0)\n                    text_parts.append(f'[Voice {length}s]')\n                elif component_type == 'Quote':\n                    # Quote content is in 'origin' field\n                    origin = component.get('origin', [])\n                    if isinstance(origin, list):\n                        for item in origin:\n                            if isinstance(item, dict) and item.get('type') == 'Plain':\n                                text_parts.append(f'> {item.get(\"text\", \"\")}')\n                elif component_type == 'Source':\n                    # Skip Source component\n                    continue\n                else:\n                    # Other unknown types\n                    text_parts.append(f'[{component_type}]')\n\n            return ''.join(text_parts)\n        except (json.JSONDecodeError, TypeError, KeyError):\n            # If not valid JSON, return as-is\n            return message_content\n\n    async def export_messages(\n        self,\n        bot_ids: list[str] | None = None,\n        pipeline_ids: list[str] | None = None,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n        limit: int = 100000,\n    ) -> list[dict]:\n        \"\"\"Export messages as list of dictionaries for CSV conversion\"\"\"\n        conditions = []\n\n        if bot_ids:\n            conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))\n        if pipeline_ids:\n            conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids))\n        if start_time:\n            conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)\n        if end_time:\n            conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)\n\n        query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by(\n            persistence_monitoring.MonitoringMessage.timestamp.desc()\n        )\n        if conditions:\n            query = query.where(sqlalchemy.and_(*conditions))\n\n        query = query.limit(limit)\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        rows = result.all()\n\n        return [\n            {\n                'id': row[0].id if isinstance(row, tuple) else row.id,\n                'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),\n                'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,\n                'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,\n                'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,\n                'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,\n                'runner_name': row[0].runner_name if isinstance(row, tuple) else row.runner_name,\n                'message_content': row[0].message_content if isinstance(row, tuple) else row.message_content,\n                'message_text': self._extract_message_text(\n                    row[0].message_content if isinstance(row, tuple) else row.message_content\n                ),\n                'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,\n                'status': row[0].status if isinstance(row, tuple) else row.status,\n                'level': row[0].level if isinstance(row, tuple) else row.level,\n                'platform': row[0].platform if isinstance(row, tuple) else row.platform,\n                'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,\n            }\n            for row in rows\n        ]\n\n    async def export_llm_calls(\n        self,\n        bot_ids: list[str] | None = None,\n        pipeline_ids: list[str] | None = None,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n        limit: int = 100000,\n    ) -> list[dict]:\n        \"\"\"Export LLM calls as list of dictionaries for CSV conversion\"\"\"\n        conditions = []\n\n        if bot_ids:\n            conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))\n        if pipeline_ids:\n            conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids))\n        if start_time:\n            conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)\n        if end_time:\n            conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)\n\n        query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by(\n            persistence_monitoring.MonitoringLLMCall.timestamp.desc()\n        )\n        if conditions:\n            query = query.where(sqlalchemy.and_(*conditions))\n\n        query = query.limit(limit)\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        rows = result.all()\n\n        return [\n            {\n                'id': row[0].id if isinstance(row, tuple) else row.id,\n                'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),\n                'model_name': row[0].model_name if isinstance(row, tuple) else row.model_name,\n                'input_tokens': row[0].input_tokens if isinstance(row, tuple) else row.input_tokens,\n                'output_tokens': row[0].output_tokens if isinstance(row, tuple) else row.output_tokens,\n                'total_tokens': row[0].total_tokens if isinstance(row, tuple) else row.total_tokens,\n                'duration_ms': row[0].duration if isinstance(row, tuple) else row.duration,\n                'cost': row[0].cost if isinstance(row, tuple) else row.cost,\n                'status': row[0].status if isinstance(row, tuple) else row.status,\n                'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,\n                'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,\n                'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,\n                'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,\n                'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,\n                'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,\n                'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message,\n            }\n            for row in rows\n        ]\n\n    async def export_embedding_calls(\n        self,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n        knowledge_base_id: str | None = None,\n        limit: int = 100000,\n    ) -> list[dict]:\n        \"\"\"Export embedding calls as list of dictionaries for CSV conversion\"\"\"\n        conditions = []\n\n        if start_time:\n            conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)\n        if end_time:\n            conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)\n        if knowledge_base_id:\n            conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id)\n\n        query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by(\n            persistence_monitoring.MonitoringEmbeddingCall.timestamp.desc()\n        )\n        if conditions:\n            query = query.where(sqlalchemy.and_(*conditions))\n\n        query = query.limit(limit)\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        rows = result.all()\n\n        return [\n            {\n                'id': row[0].id if isinstance(row, tuple) else row.id,\n                'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),\n                'model_name': row[0].model_name if isinstance(row, tuple) else row.model_name,\n                'prompt_tokens': row[0].prompt_tokens if isinstance(row, tuple) else row.prompt_tokens,\n                'total_tokens': row[0].total_tokens if isinstance(row, tuple) else row.total_tokens,\n                'duration_ms': row[0].duration if isinstance(row, tuple) else row.duration,\n                'input_count': row[0].input_count if isinstance(row, tuple) else row.input_count,\n                'status': row[0].status if isinstance(row, tuple) else row.status,\n                'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message,\n                'knowledge_base_id': row[0].knowledge_base_id if isinstance(row, tuple) else row.knowledge_base_id,\n                'query_text': row[0].query_text if isinstance(row, tuple) else row.query_text,\n                'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,\n                'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,\n                'call_type': row[0].call_type if isinstance(row, tuple) else row.call_type,\n            }\n            for row in rows\n        ]\n\n    async def export_errors(\n        self,\n        bot_ids: list[str] | None = None,\n        pipeline_ids: list[str] | None = None,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n        limit: int = 100000,\n    ) -> list[dict]:\n        \"\"\"Export errors as list of dictionaries for CSV conversion\"\"\"\n        conditions = []\n\n        if bot_ids:\n            conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))\n        if pipeline_ids:\n            conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids))\n        if start_time:\n            conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time)\n        if end_time:\n            conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time)\n\n        query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by(\n            persistence_monitoring.MonitoringError.timestamp.desc()\n        )\n        if conditions:\n            query = query.where(sqlalchemy.and_(*conditions))\n\n        query = query.limit(limit)\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        rows = result.all()\n\n        return [\n            {\n                'id': row[0].id if isinstance(row, tuple) else row.id,\n                'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),\n                'error_type': row[0].error_type if isinstance(row, tuple) else row.error_type,\n                'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message,\n                'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,\n                'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,\n                'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,\n                'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,\n                'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,\n                'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,\n                'stack_trace': row[0].stack_trace if isinstance(row, tuple) else row.stack_trace,\n            }\n            for row in rows\n        ]\n\n    async def export_sessions(\n        self,\n        bot_ids: list[str] | None = None,\n        pipeline_ids: list[str] | None = None,\n        start_time: datetime.datetime | None = None,\n        end_time: datetime.datetime | None = None,\n        limit: int = 100000,\n    ) -> list[dict]:\n        \"\"\"Export sessions as list of dictionaries for CSV conversion\"\"\"\n        conditions = []\n\n        if bot_ids:\n            conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))\n        if pipeline_ids:\n            conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))\n        if start_time:\n            conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)\n        if end_time:\n            conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)\n\n        query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(\n            persistence_monitoring.MonitoringSession.last_activity.desc()\n        )\n        if conditions:\n            query = query.where(sqlalchemy.and_(*conditions))\n\n        query = query.limit(limit)\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        rows = result.all()\n\n        return [\n            {\n                'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,\n                'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,\n                'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,\n                'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,\n                'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,\n                'message_count': row[0].message_count if isinstance(row, tuple) else row.message_count,\n                'start_time': self._format_timestamp(row[0].start_time if isinstance(row, tuple) else row.start_time),\n                'last_activity': self._format_timestamp(\n                    row[0].last_activity if isinstance(row, tuple) else row.last_activity\n                ),\n                'is_active': str(row[0].is_active if isinstance(row, tuple) else row.is_active),\n                'platform': row[0].platform if isinstance(row, tuple) else row.platform,\n                'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,\n            }\n            for row in rows\n        ]\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/pipeline.py",
    "content": "from __future__ import annotations\n\nimport uuid\nimport json\nimport sqlalchemy\n\nfrom ....core import app\nfrom ....entity.persistence import pipeline as persistence_pipeline\n\n\ndefault_stage_order = [\n    'GroupRespondRuleCheckStage',  # 群响应规则检查\n    'BanSessionCheckStage',  # 封禁会话检查\n    'PreContentFilterStage',  # 内容过滤前置阶段\n    'PreProcessor',  # 预处理器\n    'ConversationMessageTruncator',  # 会话消息截断器\n    'RequireRateLimitOccupancy',  # 请求速率限制占用\n    'MessageProcessor',  # 处理器\n    'ReleaseRateLimitOccupancy',  # 释放速率限制占用\n    'PostContentFilterStage',  # 内容过滤后置阶段\n    'ResponseWrapper',  # 响应包装器\n    'LongTextProcessStage',  # 长文本处理\n    'SendResponseBackStage',  # 发送响应\n]\n\n\nclass PipelineService:\n    ap: app.Application\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    async def get_pipeline_metadata(self) -> list[dict]:\n        return [\n            self.ap.pipeline_config_meta_trigger,\n            self.ap.pipeline_config_meta_safety,\n            self.ap.pipeline_config_meta_ai,\n            self.ap.pipeline_config_meta_output,\n        ]\n\n    async def get_pipelines(self, sort_by: str = 'created_at', sort_order: str = 'DESC') -> list[dict]:\n        query = sqlalchemy.select(persistence_pipeline.LegacyPipeline)\n\n        if sort_by == 'created_at':\n            if sort_order == 'DESC':\n                query = query.order_by(persistence_pipeline.LegacyPipeline.created_at.desc())\n            else:\n                query = query.order_by(persistence_pipeline.LegacyPipeline.created_at.asc())\n        elif sort_by == 'updated_at':\n            if sort_order == 'DESC':\n                query = query.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())\n            else:\n                query = query.order_by(persistence_pipeline.LegacyPipeline.updated_at.asc())\n\n        result = await self.ap.persistence_mgr.execute_async(query)\n        pipelines = result.all()\n        return [\n            self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)\n            for pipeline in pipelines\n        ]\n\n    async def get_pipeline(self, pipeline_uuid: str) -> dict | None:\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(\n                persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid\n            )\n        )\n\n        pipeline = result.first()\n\n        if pipeline is None:\n            return None\n\n        return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)\n\n    async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:\n        from ....utils import paths as path_utils\n\n        # Check limitation\n        limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})\n        max_pipelines = limitation.get('max_pipelines', -1)\n        if max_pipelines >= 0:\n            existing_pipelines = await self.get_pipelines()\n            if len(existing_pipelines) >= max_pipelines:\n                raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached')\n\n        pipeline_data['uuid'] = str(uuid.uuid4())\n        pipeline_data['for_version'] = self.ap.ver_mgr.get_current_version()\n        pipeline_data['stages'] = default_stage_order.copy()\n        pipeline_data['is_default'] = default\n\n        template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')\n        with open(template_path, 'r', encoding='utf-8') as f:\n            pipeline_data['config'] = json.load(f)\n\n        # Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default\n        if 'extensions_preferences' not in pipeline_data:\n            pipeline_data['extensions_preferences'] = {\n                'enable_all_plugins': True,\n                'enable_all_mcp_servers': True,\n                'plugins': [],\n                'mcp_servers': [],\n            }\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**pipeline_data)\n        )\n\n        pipeline = await self.get_pipeline(pipeline_data['uuid'])\n\n        await self.ap.pipeline_mgr.load_pipeline(pipeline)\n\n        return pipeline_data['uuid']\n\n    async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:\n        if 'uuid' in pipeline_data:\n            del pipeline_data['uuid']\n        if 'for_version' in pipeline_data:\n            del pipeline_data['for_version']\n        if 'stages' in pipeline_data:\n            del pipeline_data['stages']\n        if 'is_default' in pipeline_data:\n            del pipeline_data['is_default']\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_pipeline.LegacyPipeline)\n            .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)\n            .values(**pipeline_data)\n        )\n\n        pipeline = await self.get_pipeline(pipeline_uuid)\n\n        if 'name' in pipeline_data:\n            from ....entity.persistence import bot as persistence_bot\n\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid)\n            )\n\n            bots = result.all()\n\n            for bot in bots:\n                bot_data = {'use_pipeline_name': pipeline_data['name']}\n                await self.ap.bot_service.update_bot(bot.uuid, bot_data)\n\n        await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)\n        await self.ap.pipeline_mgr.load_pipeline(pipeline)\n\n        # update all conversation that use this pipeline\n        for session in self.ap.sess_mgr.session_list:\n            if session.using_conversation is not None and session.using_conversation.pipeline_uuid == pipeline_uuid:\n                session.using_conversation = None\n\n    async def delete_pipeline(self, pipeline_uuid: str) -> None:\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where(\n                persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid\n            )\n        )\n        await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)\n\n    async def copy_pipeline(self, pipeline_uuid: str) -> str:\n        \"\"\"Copy a pipeline with all its configurations\"\"\"\n        # Check limitation\n        limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})\n        max_pipelines = limitation.get('max_pipelines', -1)\n        if max_pipelines >= 0:\n            existing_pipelines = await self.get_pipelines()\n            if len(existing_pipelines) >= max_pipelines:\n                raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached')\n\n        # Get the original pipeline\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(\n                persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid\n            )\n        )\n\n        original_pipeline = result.first()\n        if original_pipeline is None:\n            raise ValueError(f'Pipeline {pipeline_uuid} not found')\n\n        # Create new pipeline data\n        new_uuid = str(uuid.uuid4())\n        new_pipeline_data = {\n            'uuid': new_uuid,\n            'name': f'{original_pipeline.name} (Copy)',\n            'description': original_pipeline.description,\n            'for_version': self.ap.ver_mgr.get_current_version(),\n            'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(),\n            'config': original_pipeline.config.copy() if original_pipeline.config else {},\n            'is_default': False,\n            'extensions_preferences': (\n                original_pipeline.extensions_preferences.copy()\n                if original_pipeline.extensions_preferences\n                else {\n                    'enable_all_plugins': True,\n                    'enable_all_mcp_servers': True,\n                    'plugins': [],\n                    'mcp_servers': [],\n                }\n            ),\n        }\n\n        # Insert the new pipeline\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**new_pipeline_data)\n        )\n\n        # Load the new pipeline\n        pipeline = await self.get_pipeline(new_uuid)\n        await self.ap.pipeline_mgr.load_pipeline(pipeline)\n\n        return new_uuid\n\n    async def update_pipeline_extensions(\n        self,\n        pipeline_uuid: str,\n        bound_plugins: list[dict],\n        bound_mcp_servers: list[str] = None,\n        enable_all_plugins: bool = True,\n        enable_all_mcp_servers: bool = True,\n    ) -> None:\n        \"\"\"Update the bound plugins and MCP servers for a pipeline\"\"\"\n        # Get current pipeline\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(\n                persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid\n            )\n        )\n\n        pipeline = result.first()\n        if pipeline is None:\n            raise ValueError(f'Pipeline {pipeline_uuid} not found')\n\n        # Update extensions_preferences\n        extensions_preferences = pipeline.extensions_preferences or {}\n        extensions_preferences['enable_all_plugins'] = enable_all_plugins\n        extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers\n        extensions_preferences['plugins'] = bound_plugins\n        if bound_mcp_servers is not None:\n            extensions_preferences['mcp_servers'] = bound_mcp_servers\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_pipeline.LegacyPipeline)\n            .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)\n            .values(extensions_preferences=extensions_preferences)\n        )\n\n        # Reload pipeline to apply changes\n        await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)\n        pipeline = await self.get_pipeline(pipeline_uuid)\n        await self.ap.pipeline_mgr.load_pipeline(pipeline)\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/provider.py",
    "content": "from __future__ import annotations\n\nimport uuid\n\nimport sqlalchemy\n\nfrom ....core import app\nfrom ....entity.persistence import model as persistence_model\n\n\nclass ModelProviderService:\n    \"\"\"Service for managing model providers\"\"\"\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    async def get_providers(self) -> list[dict]:\n        \"\"\"Get all providers\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))\n        providers = result.all()\n        providers_list = []\n        for p in providers:\n            provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, p)\n            # Parse api_keys if it's a JSON string\n            if isinstance(provider_dict.get('api_keys'), str):\n                import json\n\n                try:\n                    provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])\n                except Exception:\n                    provider_dict['api_keys'] = []\n            providers_list.append(provider_dict)\n        return providers_list\n\n    async def get_provider(self, provider_uuid: str) -> dict | None:\n        \"\"\"Get a single provider by UUID\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.ModelProvider).where(\n                persistence_model.ModelProvider.uuid == provider_uuid\n            )\n        )\n        provider = result.first()\n        if provider is None:\n            return None\n        provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)\n        # Parse api_keys if it's a JSON string\n        if isinstance(provider_dict.get('api_keys'), str):\n            import json\n\n            try:\n                provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])\n            except Exception:\n                provider_dict['api_keys'] = []\n        return provider_dict\n\n    async def create_provider(self, provider_data: dict) -> str:\n        \"\"\"Create a new provider\"\"\"\n        provider_data['uuid'] = str(uuid.uuid4())\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)\n        )\n\n        # load to runtime\n        runtime_provider = await self.ap.model_mgr.load_provider(provider_data)\n        self.ap.model_mgr.provider_dict[runtime_provider.provider_entity.uuid] = runtime_provider\n        return provider_data['uuid']\n\n    async def update_provider(self, provider_uuid: str, provider_data: dict) -> None:\n        \"\"\"Update an existing provider\"\"\"\n        if 'uuid' in provider_data:\n            del provider_data['uuid']\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_model.ModelProvider)\n            .where(persistence_model.ModelProvider.uuid == provider_uuid)\n            .values(**provider_data)\n        )\n        await self.ap.model_mgr.reload_provider(provider_uuid)\n\n    async def delete_provider(self, provider_uuid: str) -> None:\n        \"\"\"Delete a provider (only if no models reference it)\"\"\"\n        # Check if any models use this provider\n        llm_result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.LLMModel).where(\n                persistence_model.LLMModel.provider_uuid == provider_uuid\n            )\n        )\n        if llm_result.first() is not None:\n            raise ValueError('Cannot delete provider: LLM models still reference it')\n\n        embedding_result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.EmbeddingModel).where(\n                persistence_model.EmbeddingModel.provider_uuid == provider_uuid\n            )\n        )\n        if embedding_result.first() is not None:\n            raise ValueError('Cannot delete provider: Embedding models still reference it')\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(persistence_model.ModelProvider).where(\n                persistence_model.ModelProvider.uuid == provider_uuid\n            )\n        )\n\n        await self.ap.model_mgr.remove_provider(provider_uuid)\n\n    async def get_provider_model_counts(self, provider_uuid: str) -> dict:\n        \"\"\"Get count of models using this provider\"\"\"\n        llm_result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(sqlalchemy.func.count())\n            .select_from(persistence_model.LLMModel)\n            .where(persistence_model.LLMModel.provider_uuid == provider_uuid)\n        )\n        llm_count = llm_result.scalar() or 0\n\n        embedding_result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(sqlalchemy.func.count())\n            .select_from(persistence_model.EmbeddingModel)\n            .where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid)\n        )\n        embedding_count = embedding_result.scalar() or 0\n\n        return {'llm_count': llm_count, 'embedding_count': embedding_count}\n\n    async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:\n        \"\"\"Find existing provider or create new one\"\"\"\n        # Try to find existing provider with same config\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.ModelProvider).where(\n                persistence_model.ModelProvider.requester == requester,\n                persistence_model.ModelProvider.base_url == base_url,\n            )\n        )\n        for provider in result.all():\n            if sorted(provider.api_keys or []) == sorted(api_keys or []):\n                return provider.uuid\n\n        # Create new provider\n        provider_name = requester\n        if base_url:\n            try:\n                from urllib.parse import urlparse\n\n                parsed = urlparse(base_url)\n                provider_name = parsed.netloc or requester\n            except Exception:\n                pass\n\n        return await self.create_provider(\n            {\n                'name': provider_name,\n                'requester': requester,\n                'base_url': base_url,\n                'api_keys': api_keys or [],\n            }\n        )\n\n    async def update_space_model_provider_api_keys(self, api_key: str) -> None:\n        \"\"\"Update Space model provider API keys\"\"\"\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_model.ModelProvider)\n            .where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')\n            .values(api_keys=[api_key])\n        )\n        await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/space.py",
    "content": "from __future__ import annotations\n\nfrom langbot.pkg.utils import httpclient\nimport typing\nimport datetime\nimport time\nimport sqlalchemy\n\nfrom ....core import app\nfrom ....entity.persistence import user\nfrom ....entity.dto.space_model import SpaceModel\n\n\nclass SpaceService:\n    \"\"\"Service for interacting with LangBot Space API\"\"\"\n\n    ap: app.Application\n    _credits_cache: typing.Dict[str, typing.Tuple[int, float]]  # {user_email: (credits, timestamp)}\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n        self._credits_cache = {}\n\n    def _get_space_config(self) -> typing.Dict[str, str]:\n        \"\"\"Get Space configuration from config file\"\"\"\n        space_config = self.ap.instance_config.data.get('space', {})\n        return {\n            'url': space_config.get('url', 'https://space.langbot.app'),\n            'oauth_authorize_url': space_config.get('oauth_authorize_url', 'https://space.langbot.app/auth/authorize'),\n        }\n\n    async def _get_user_by_email(self, user_email: str) -> user.User | None:\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(user.User).where(user.User.user == user_email)\n        )\n        result_list = result.all()\n        return result_list[0] if result_list else None\n\n    async def _ensure_valid_token(self, user_email: str) -> str | None:\n        \"\"\"Ensure access token is valid, refresh if expired. Returns valid access_token or None.\"\"\"\n        user_obj = await self._get_user_by_email(user_email)\n        if not user_obj or user_obj.account_type != 'space':\n            return None\n\n        if not user_obj.space_access_token:\n            return None\n\n        # Check if token is expired (with 60s buffer)\n        if user_obj.space_access_token_expires_at:\n            if datetime.datetime.now() >= user_obj.space_access_token_expires_at - datetime.timedelta(seconds=60):\n                # Token expired, try to refresh\n                if user_obj.space_refresh_token:\n                    try:\n                        new_token = await self._refresh_and_save_token(user_obj)\n                        return new_token\n                    except Exception:\n                        return None\n                return None\n\n        return user_obj.space_access_token\n\n    async def _refresh_and_save_token(self, user_obj: user.User) -> str:\n        \"\"\"Refresh token and save to database\"\"\"\n        token_data = await self.refresh_token(user_obj.space_refresh_token)\n        access_token = token_data.get('access_token')\n        expires_in = token_data.get('expires_in', 0)\n\n        if not access_token:\n            raise ValueError('Failed to refresh token')\n\n        expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(user.User)\n            .where(user.User.user == user_obj.user)\n            .values(\n                space_access_token=access_token,\n                space_access_token_expires_at=expires_at,\n            )\n        )\n\n        return access_token\n\n    # === Raw API calls (no token validation) ===\n\n    def get_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str:\n        \"\"\"Get the Space OAuth authorization URL for redirect\"\"\"\n        space_config = self._get_space_config()\n        authorize_url = space_config['oauth_authorize_url']\n        params = f'redirect_uri={redirect_uri}'\n        if state:\n            params += f'&state={state}'\n        return f'{authorize_url}?{params}'\n\n    async def exchange_oauth_code(self, code: str) -> typing.Dict:\n        \"\"\"Exchange OAuth authorization code for tokens\"\"\"\n        from langbot.pkg.utils import constants\n\n        space_config = self._get_space_config()\n        space_url = space_config['url']\n\n        session = httpclient.get_session()\n        async with session.post(\n            f'{space_url}/api/v1/accounts/oauth/token',\n            json={'code': code, 'instance_id': constants.instance_id},\n        ) as response:\n            if response.status != 200:\n                raise ValueError(f'Failed to exchange OAuth code: {await response.text()}')\n            data = await response.json()\n            if data.get('code') != 0:\n                raise ValueError(f'Failed to exchange OAuth code: {data.get(\"msg\")}')\n            return data.get('data', {})\n\n    async def refresh_token(self, refresh_token: str) -> typing.Dict:\n        \"\"\"Refresh Space access token\"\"\"\n        space_config = self._get_space_config()\n        space_url = space_config['url']\n\n        session = httpclient.get_session()\n        async with session.post(\n            f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token}\n        ) as response:\n            if response.status != 200:\n                raise ValueError(f'Failed to refresh token: {await response.text()}')\n            data = await response.json()\n            if data.get('code') != 0:\n                raise ValueError(f'Failed to refresh token: {data.get(\"msg\")}')\n            return data.get('data', {})\n\n    async def get_user_info_raw(self, access_token: str) -> typing.Dict:\n        \"\"\"Get user info from Space using access token (no validation)\"\"\"\n        space_config = self._get_space_config()\n        space_url = space_config['url']\n\n        session = httpclient.get_session()\n        async with session.get(\n            f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'}\n        ) as response:\n            if response.status != 200:\n                raise ValueError(f'Failed to get user info: {await response.text()}')\n            data = await response.json()\n            if data.get('code') != 0:\n                raise ValueError(f'Failed to get user info: {data.get(\"msg\")}')\n            return data.get('data', {})\n\n    # === API calls with token validation ===\n\n    async def get_user_info(self, user_email: str) -> typing.Dict | None:\n        \"\"\"Get user info from Space (with token validation)\"\"\"\n        access_token = await self._ensure_valid_token(user_email)\n        if not access_token:\n            return None\n        return await self.get_user_info_raw(access_token)\n\n    async def get_credits(self, user_email: str, force_refresh: bool = False) -> int | None:\n        \"\"\"Get Space credits for user with caching (60s TTL)\"\"\"\n        cache_ttl = 60\n\n        if not force_refresh and user_email in self._credits_cache:\n            credits, ts = self._credits_cache[user_email]\n            if time.time() - ts < cache_ttl:\n                return credits\n\n        try:\n            info = await self.get_user_info(user_email)\n            if info is None:\n                return None\n            credits = info.get('credits')\n            if credits is not None:\n                self._credits_cache[user_email] = (credits, time.time())\n            return credits\n        except Exception:\n            return self._credits_cache.get(user_email, (None, 0))[0]\n\n    async def get_models(self) -> typing.List[SpaceModel]:\n        \"\"\"Get models from Space\"\"\"\n\n        space_config = self._get_space_config()\n        space_url = space_config['url']\n\n        session = httpclient.get_session()\n        async with session.get(f'{space_url}/api/v1/models') as response:\n            if response.status != 200:\n                raise ValueError(f'Failed to get models: {await response.text()}')\n            data = await response.json()\n            if data.get('code') != 0:\n                raise ValueError(f'Failed to get models: {data.get(\"msg\")}')\n            models_data = data.get('data', {}).get('models', [])\n            return [SpaceModel.model_validate(model_dict) for model_dict in models_data]\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/user.py",
    "content": "from __future__ import annotations\n\nimport sqlalchemy\nimport argon2\nimport jwt\nimport datetime\nimport typing\nimport asyncio\n\nfrom ....core import app\nfrom ....entity.persistence import user\nfrom ....utils import constants\nfrom ....entity.errors import account as account_errors\n\n\nclass UserService:\n    ap: app.Application\n    _create_user_lock: asyncio.Lock\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n        self._create_user_lock = asyncio.Lock()\n\n    async def is_initialized(self) -> bool:\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))\n\n        result_list = result.all()\n        return result_list is not None and len(result_list) > 0\n\n    async def create_user(self, user_email: str, password: str) -> None:\n        ph = argon2.PasswordHasher()\n\n        hashed_password = ph.hash(password)\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')\n        )\n\n    async def get_user_by_email(self, user_email: str) -> user.User | None:\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(user.User).where(user.User.user == user_email)\n        )\n\n        result_list = result.all()\n        return result_list[0] if result_list is not None and len(result_list) > 0 else None\n\n    async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None:\n        \"\"\"Get user by Space account UUID\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid)\n        )\n\n        result_list = result.all()\n        return result_list[0] if result_list is not None and len(result_list) > 0 else None\n\n    async def authenticate(self, user_email: str, password: str) -> str | None:\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(user.User).where(user.User.user == user_email)\n        )\n\n        result_list = result.all()\n\n        if result_list is None or len(result_list) == 0:\n            raise ValueError('用户不存在')\n\n        user_obj = result_list[0]\n\n        # Check if this is a Space account\n        if user_obj.account_type == 'space':\n            raise ValueError('请使用 Space 账户登录')\n\n        ph = argon2.PasswordHasher()\n\n        ph.verify(user_obj.password, password)\n\n        return await self.generate_jwt_token(user_email)\n\n    async def generate_jwt_token(self, user_email: str) -> str:\n        jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']\n        jwt_expire = self.ap.instance_config.data['system']['jwt']['expire']\n\n        payload = {\n            'user': user_email,\n            'iss': 'LangBot-' + constants.edition,\n            'exp': datetime.datetime.now() + datetime.timedelta(seconds=jwt_expire),\n        }\n\n        return jwt.encode(payload, jwt_secret, algorithm='HS256')\n\n    async def verify_jwt_token(self, token: str) -> str:\n        jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']\n\n        return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user']\n\n    async def reset_password(self, user_email: str, new_password: str) -> None:\n        ph = argon2.PasswordHasher()\n\n        hashed_password = ph.hash(new_password)\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)\n        )\n\n    async def change_password(self, user_email: str, current_password: str, new_password: str) -> None:\n        ph = argon2.PasswordHasher()\n\n        user_obj = await self.get_user_by_email(user_email)\n        if user_obj is None:\n            raise ValueError('User not found')\n\n        # Space accounts cannot change password locally\n        if user_obj.account_type == 'space':\n            raise ValueError('Space account cannot change password locally')\n\n        ph.verify(user_obj.password, current_password)\n\n        hashed_password = ph.hash(new_password)\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)\n        )\n\n    # Space user management\n\n    async def create_or_update_space_user(\n        self,\n        space_account_uuid: str,\n        email: str,\n        access_token: str,\n        refresh_token: str,\n        api_key: str,\n        expires_in: int = 0,\n    ) -> user.User:\n        \"\"\"Create or update a Space user account (only if system not initialized or user exists)\"\"\"\n        expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None\n\n        async with self._create_user_lock:\n            # Check if user with this Space UUID already exists\n            existing_user = await self.get_user_by_space_account_uuid(space_account_uuid)\n\n            if existing_user:\n                # Update existing user's tokens\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.update(user.User)\n                    .where(user.User.space_account_uuid == space_account_uuid)\n                    .values(\n                        space_access_token=access_token,\n                        space_refresh_token=refresh_token,\n                        space_api_key=api_key,\n                        space_access_token_expires_at=expires_at,\n                    )\n                )\n                await self.ap.provider_service.update_space_model_provider_api_keys(api_key)\n                return await self.get_user_by_space_account_uuid(space_account_uuid)\n\n            # Check if user with same email exists\n            existing_email_user = await self.get_user_by_email(email)\n            if existing_email_user:\n                # Update existing user to link with Space account\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.update(user.User)\n                    .where(user.User.user == email)\n                    .values(\n                        account_type='space',\n                        space_account_uuid=space_account_uuid,\n                        space_access_token=access_token,\n                        space_refresh_token=refresh_token,\n                        space_api_key=api_key,\n                        space_access_token_expires_at=expires_at,\n                    )\n                )\n                await self.ap.provider_service.update_space_model_provider_api_keys(api_key)\n                return await self.get_user_by_email(email)\n\n            # Check if system is already initialized\n            is_initialized = await self.is_initialized()\n            if is_initialized:\n                raise account_errors.AccountEmailMismatchError()\n\n            # Create new Space user (first time initialization)\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.insert(user.User).values(\n                    user=email,\n                    password='',  # Space users don't have local password\n                    account_type='space',\n                    space_account_uuid=space_account_uuid,\n                    space_access_token=access_token,\n                    space_refresh_token=refresh_token,\n                    space_api_key=api_key,\n                    space_access_token_expires_at=expires_at,\n                )\n            )\n            await self.ap.provider_service.update_space_model_provider_api_keys(api_key)\n\n            return await self.get_user_by_space_account_uuid(space_account_uuid)\n\n    async def authenticate_space_user(\n        self, access_token: str, refresh_token: str, expires_in: int = 0\n    ) -> typing.Tuple[str, user.User]:\n        \"\"\"Authenticate with Space and return JWT token\"\"\"\n        # Get user info from Space using raw API (token just obtained, no need to validate)\n        user_info = await self.ap.space_service.get_user_info_raw(access_token)\n\n        account = user_info.get('account', {})\n        api_key = user_info.get('api_key', '')\n\n        space_account_uuid = account.get('uuid')\n        email = account.get('email')\n\n        if not space_account_uuid or not email:\n            raise ValueError('Invalid Space user info')\n\n        # Create or update Space user in local database\n        user_obj = await self.create_or_update_space_user(\n            space_account_uuid=space_account_uuid,\n            email=email,\n            access_token=access_token,\n            refresh_token=refresh_token,\n            api_key=api_key,\n            expires_in=expires_in,\n        )\n\n        # Generate JWT token\n        jwt_token = await self.generate_jwt_token(email)\n\n        return jwt_token, user_obj\n\n    async def get_first_user(self) -> user.User | None:\n        \"\"\"Get the first user (for single-user mode)\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))\n        result_list = result.all()\n        return result_list[0] if result_list else None\n\n    async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None:\n        \"\"\"Set or change password for a user\"\"\"\n        ph = argon2.PasswordHasher()\n        user_obj = await self.get_user_by_email(user_email)\n\n        if user_obj is None:\n            raise ValueError('User not found')\n\n        # If user already has a password, verify current password\n        has_password = bool(user_obj.password and user_obj.password.strip())\n        if has_password:\n            if not current_password:\n                raise ValueError('Current password is required')\n            ph.verify(user_obj.password, current_password)\n\n        hashed_password = ph.hash(new_password)\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)\n        )\n\n    async def bind_space_account(self, user_email: str, code: str) -> user.User:\n        \"\"\"Bind Space account to existing local account\"\"\"\n        # Exchange code for tokens\n        token_data = await self.ap.space_service.exchange_oauth_code(code)\n        access_token = token_data.get('access_token')\n        refresh_token = token_data.get('refresh_token')\n        expires_in = token_data.get('expires_in', 0)\n\n        if not access_token:\n            raise ValueError('Failed to get access token from Space')\n\n        expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None\n\n        # Get Space user info (token just obtained, use raw API)\n        user_info = await self.ap.space_service.get_user_info_raw(access_token)\n        account = user_info.get('account', {})\n        api_key = user_info.get('api_key', '')\n\n        space_account_uuid = account.get('uuid')\n        space_email = account.get('email')\n\n        if not space_account_uuid or not space_email:\n            raise ValueError('Invalid Space user info')\n\n        # Check if this Space account is already bound to another user\n        existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)\n        if existing_space_user and existing_space_user.user != user_email:\n            raise ValueError('This Space account is already bound to another user')\n\n        # Update local account to Space account\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(user.User)\n            .where(user.User.user == user_email)\n            .values(\n                user=space_email,  # Update email to Space email\n                account_type='space',\n                space_account_uuid=space_account_uuid,\n                space_access_token=access_token,\n                space_refresh_token=refresh_token,\n                space_api_key=api_key,\n                space_access_token_expires_at=expires_at,\n            )\n        )\n\n        # Update Space model provider API keys\n        await self.ap.provider_service.update_space_model_provider_api_keys(api_key)\n\n        return await self.get_user_by_email(space_email)\n"
  },
  {
    "path": "src/langbot/pkg/api/http/service/webhook.py",
    "content": "from __future__ import annotations\n\nimport sqlalchemy\n\nfrom ....core import app\nfrom ....entity.persistence import webhook\n\n\nclass WebhookService:\n    ap: app.Application\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    async def get_webhooks(self) -> list[dict]:\n        \"\"\"Get all webhooks\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(webhook.Webhook))\n\n        webhooks = result.all()\n        return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks]\n\n    async def create_webhook(self, name: str, url: str, description: str = '', enabled: bool = True) -> dict:\n        \"\"\"Create a new webhook\"\"\"\n        webhook_data = {'name': name, 'url': url, 'description': description, 'enabled': enabled}\n\n        await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(webhook.Webhook).values(**webhook_data))\n\n        # Retrieve the created webhook\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.url == url).order_by(webhook.Webhook.id.desc())\n        )\n        created_webhook = result.first()\n\n        return self.ap.persistence_mgr.serialize_model(webhook.Webhook, created_webhook)\n\n    async def get_webhook(self, webhook_id: int) -> dict | None:\n        \"\"\"Get a specific webhook by ID\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id)\n        )\n\n        wh = result.first()\n\n        if wh is None:\n            return None\n\n        return self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh)\n\n    async def update_webhook(\n        self, webhook_id: int, name: str = None, url: str = None, description: str = None, enabled: bool = None\n    ) -> None:\n        \"\"\"Update a webhook's metadata\"\"\"\n        update_data = {}\n        if name is not None:\n            update_data['name'] = name\n        if url is not None:\n            update_data['url'] = url\n        if description is not None:\n            update_data['description'] = description\n        if enabled is not None:\n            update_data['enabled'] = enabled\n\n        if update_data:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data)\n            )\n\n    async def delete_webhook(self, webhook_id: int) -> None:\n        \"\"\"Delete a webhook\"\"\"\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id)\n        )\n\n    async def get_enabled_webhooks(self) -> list[dict]:\n        \"\"\"Get all enabled webhooks\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True)\n        )\n\n        webhooks = result.all()\n        return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks]\n"
  },
  {
    "path": "src/langbot/pkg/command/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/command/cmdmgr.py",
    "content": "from __future__ import annotations\n\nimport typing\n\nfrom ..core import app\nfrom . import operator\nfrom ..utils import importutil\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nfrom langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors\n\n# 引入所有算子以便注册\nfrom . import operators\n\nimportutil.import_modules_in_pkg(operators)\n\n\nclass CommandManager:\n    ap: app.Application\n\n    cmd_list: list[operator.CommandOperator]\n    \"\"\"\n    Runtime command list, flat storage, each object contains a reference to the corresponding child node\n    \"\"\"\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        # 设置各个类的路径\n        def set_path(cls: operator.CommandOperator, ancestors: list[str]):\n            cls.path = '.'.join(ancestors + [cls.name])\n            for op in operator.preregistered_operators:\n                if op.parent_class == cls:\n                    set_path(op, ancestors + [cls.name])\n\n        for cls in operator.preregistered_operators:\n            if cls.parent_class is None:\n                set_path(cls, [])\n\n        # 应用命令权限配置\n        # for cls in operator.preregistered_operators:\n        #     if cls.path in self.ap.instance_config.data['command']['privilege']:\n        #         cls.lowest_privilege = self.ap.instance_config.data['command']['privilege'][cls.path]\n\n        # 实例化所有类\n        self.cmd_list = [cls(self.ap) for cls in operator.preregistered_operators]\n\n        # 设置所有类的子节点\n        for cmd in self.cmd_list:\n            cmd.children = [child for child in self.cmd_list if child.parent_class == cmd.__class__]\n\n        # 初始化所有类\n        for cmd in self.cmd_list:\n            await cmd.initialize()\n\n    async def _execute(\n        self,\n        context: command_context.ExecuteContext,\n        operator_list: list[operator.CommandOperator],\n        operator: operator.CommandOperator = None,\n        bound_plugins: list[str] | None = None,\n    ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n        \"\"\"执行命令\"\"\"\n\n        command_list = await self.ap.plugin_connector.list_commands(bound_plugins)\n\n        for command in command_list:\n            if command.metadata.name == context.command:\n                async for ret in self.ap.plugin_connector.execute_command(context, bound_plugins):\n                    yield ret\n                break\n        else:\n            yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(context.command))\n\n    async def execute(\n        self,\n        command_text: str,\n        full_command_text: str,\n        query: pipeline_query.Query,\n        session: provider_session.Session,\n    ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n        \"\"\"执行命令\"\"\"\n\n        privilege = 1\n\n        if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:\n            privilege = 2\n\n        ctx = command_context.ExecuteContext(\n            query_id=query.query_id,\n            session=session,\n            command_text=command_text,\n            full_command_text=full_command_text,\n            command='',\n            crt_command='',\n            params=command_text.split(' '),\n            crt_params=command_text.split(' '),\n            privilege=privilege,\n        )\n\n        ctx.command = ctx.params[0]\n\n        ctx.shift()\n\n        # Get bound plugins from query\n        bound_plugins = query.variables.get('_pipeline_bound_plugins', None)\n\n        async for ret in self._execute(ctx, self.cmd_list, bound_plugins=bound_plugins):\n            yield ret\n"
  },
  {
    "path": "src/langbot/pkg/command/operator.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport abc\n\nfrom ..core import app\nfrom langbot_plugin.api.entities.builtin.command import context as command_context\n\n\npreregistered_operators: list[typing.Type[CommandOperator]] = []\n\"\"\"预注册命令算子列表。在初始化时，所有算子类会被注册到此列表中。\"\"\"\n\n\ndef operator_class(\n    name: str,\n    help: str = '',\n    usage: str = None,\n    alias: list[str] = [],\n    privilege: int = 1,  # 1为普通用户，2为管理员\n    parent_class: typing.Type[CommandOperator] = None,\n) -> typing.Callable[[typing.Type[CommandOperator]], typing.Type[CommandOperator]]:\n    \"\"\"命令类装饰器\n\n    Args:\n        name (str): 名称\n        help (str, optional): 帮助信息. Defaults to \"\".\n        usage (str, optional): 使用说明. Defaults to None.\n        alias (list[str], optional): 别名. Defaults to [].\n        privilege (int, optional): 权限，1为普通用户可用，2为仅管理员可用. Defaults to 1.\n        parent_class (typing.Type[CommandOperator], optional): 父节点，若为None则为顶级命令. Defaults to None.\n\n    Returns:\n        typing.Callable[[typing.Type[CommandOperator]], typing.Type[CommandOperator]]: 装饰器\n    \"\"\"\n\n    def decorator(cls: typing.Type[CommandOperator]) -> typing.Type[CommandOperator]:\n        assert issubclass(cls, CommandOperator)\n\n        cls.name = name\n        cls.alias = alias\n        cls.help = help\n        cls.usage = usage\n        cls.parent_class = parent_class\n        cls.lowest_privilege = privilege\n\n        preregistered_operators.append(cls)\n\n        return cls\n\n    return decorator\n\n\nclass CommandOperator(metaclass=abc.ABCMeta):\n    \"\"\"命令算子抽象类\n\n    以下的参数均不需要在子类中设置，只需要在使用装饰器注册类时作为参数传递即可。\n    命令支持级联，即一个命令可以有多个子命令，子命令可以有子命令，以此类推。\n    处理命令时，若有子命令，会以当前参数列表的第一个参数去匹配子命令，若匹配成功，则转移到子命令中执行。\n    若没有匹配成功或没有子命令，则执行当前命令。\n    \"\"\"\n\n    ap: app.Application\n\n    name: str\n    \"\"\"名称，搜索到时若符合则使用\"\"\"\n\n    path: str\n    \"\"\"路径，所有父节点的name的连接，用于定义命令权限，由管理器在初始化时自动设置。\n    \"\"\"\n\n    alias: list[str]\n    \"\"\"同name\"\"\"\n\n    help: str\n    \"\"\"此节点的帮助信息\"\"\"\n\n    usage: str = None\n    \"\"\"用法\"\"\"\n\n    parent_class: typing.Union[typing.Type[CommandOperator], None] = None\n    \"\"\"父节点类。标记以供管理器在初始化时编织父子关系。\"\"\"\n\n    lowest_privilege: int = 0\n    \"\"\"最低权限。若权限低于此值，则不予执行。\"\"\"\n\n    children: list[CommandOperator]\n    \"\"\"子节点。解析命令时，若节点有子节点，则以下一个参数去匹配子节点，\n    若有匹配中的，转移到子节点中执行，若没有匹配中的或没有子节点，执行此节点。\"\"\"\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        self.children = []\n\n    async def initialize(self):\n        pass\n\n    @abc.abstractmethod\n    async def execute(\n        self, context: command_context.ExecuteContext\n    ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n        \"\"\"实现此方法以执行命令\n\n        支持多次yield以返回多个结果。\n        例如：一个安装插件的命令，可能会有下载、解压、安装等多个步骤，每个步骤都可以返回一个结果。\n\n        Args:\n            context (command_context.ExecuteContext): 命令执行上下文\n\n        Yields:\n            command_context.CommandReturn: 命令返回封装\n        \"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/command/operators/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/command/operators/delc.py",
    "content": "# from __future__ import annotations\n\n# import typing\n\n# from .. import operator\n# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors\n\n\n# @operator.operator_class(name='del', help='删除当前会话的历史记录', usage='!del <序号>\\n!del all')\n# class DelOperator(operator.CommandOperator):\n#     async def execute(\n#         self, context: command_context.ExecuteContext\n#     ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n#         if context.session.conversations:\n#             delete_index = 0\n#             if len(context.crt_params) > 0:\n#                 try:\n#                     delete_index = int(context.crt_params[0])\n#                 except Exception:\n#                     yield command_context.CommandReturn(error=command_errors.CommandOperationError('索引必须是整数'))\n#                     return\n\n#             if delete_index < 0 or delete_index >= len(context.session.conversations):\n#                 yield command_context.CommandReturn(error=command_errors.CommandOperationError('索引超出范围'))\n#                 return\n\n#             # 倒序\n#             to_delete_index = len(context.session.conversations) - 1 - delete_index\n\n#             if context.session.conversations[to_delete_index] == context.session.using_conversation:\n#                 context.session.using_conversation = None\n\n#             del context.session.conversations[to_delete_index]\n\n#             yield command_context.CommandReturn(text=f'已删除对话: {delete_index}')\n#         else:\n#             yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话'))\n\n\n# @operator.operator_class(name='all', help='删除此会话的所有历史记录', parent_class=DelOperator)\n# class DelAllOperator(operator.CommandOperator):\n#     async def execute(\n#         self, context: command_context.ExecuteContext\n#     ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n#         context.session.conversations = []\n#         context.session.using_conversation = None\n\n#         yield command_context.CommandReturn(text='已删除所有对话')\n"
  },
  {
    "path": "src/langbot/pkg/command/operators/last.py",
    "content": "# from __future__ import annotations\n\n# import typing\n\n\n# from .. import operator\n# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors\n\n\n# @operator.operator_class(name='last', help='切换到前一个对话', usage='!last')\n# class LastOperator(operator.CommandOperator):\n#     async def execute(\n#         self, context: command_context.ExecuteContext\n#     ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n#         if context.session.conversations:\n#             # 找到当前会话的上一个会话\n#             for index in range(len(context.session.conversations) - 1, -1, -1):\n#                 if context.session.conversations[index] == context.session.using_conversation:\n#                     if index == 0:\n#                         yield command_context.CommandReturn(\n#                             error=command_errors.CommandOperationError('已经是第一个对话了')\n#                         )\n#                         return\n#                     else:\n#                         context.session.using_conversation = context.session.conversations[index - 1]\n#                         time_str = context.session.using_conversation.create_time.strftime('%Y-%m-%d %H:%M:%S')\n\n#                         yield command_context.CommandReturn(\n#                             text=f'已切换到上一个对话: {index} {time_str}: {context.session.using_conversation.messages[0].readable_str()}'\n#                         )\n#                         return\n#         else:\n#             yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话'))\n"
  },
  {
    "path": "src/langbot/pkg/command/operators/list.py",
    "content": "# from __future__ import annotations\n\n# import typing\n\n# from .. import operator\n# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors\n\n\n# @operator.operator_class(name='list', help='列出此会话中的所有历史对话', usage='!list\\n!list <页码>')\n# class ListOperator(operator.CommandOperator):\n#     async def execute(\n#         self, context: command_context.ExecuteContext\n#     ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n#         page = 0\n\n#         if len(context.crt_params) > 0:\n#             try:\n#                 page = int(context.crt_params[0] - 1)\n#             except Exception:\n#                 yield command_context.CommandReturn(error=command_errors.CommandOperationError('页码应为整数'))\n#                 return\n\n#         record_per_page = 10\n\n#         content = ''\n\n#         index = 0\n\n#         using_conv_index = 0\n\n#         for conv in context.session.conversations[::-1]:\n#             time_str = conv.create_time.strftime('%Y-%m-%d %H:%M:%S')\n\n#             if conv == context.session.using_conversation:\n#                 using_conv_index = index\n\n#             if index >= page * record_per_page and index < (page + 1) * record_per_page:\n#                 content += (\n#                     f'{index} {time_str}: {conv.messages[0].readable_str() if len(conv.messages) > 0 else \"无内容\"}\\n'\n#                 )\n#             index += 1\n\n#         if content == '':\n#             content = '无'\n#         else:\n#             if context.session.using_conversation is None:\n#                 content += '\\n当前处于新会话'\n#             else:\n#                 content += f'\\n当前会话: {using_conv_index} {context.session.using_conversation.create_time.strftime(\"%Y-%m-%d %H:%M:%S\")}: {context.session.using_conversation.messages[0].readable_str() if len(context.session.using_conversation.messages) > 0 else \"无内容\"}'\n\n#         yield command_context.CommandReturn(text=f'第 {page + 1} 页 (时间倒序):\\n{content}')\n"
  },
  {
    "path": "src/langbot/pkg/command/operators/next.py",
    "content": "# from __future__ import annotations\n\n# import typing\n\n# from .. import operator\n# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors\n\n\n# @operator.operator_class(name='next', help='切换到后一个对话', usage='!next')\n# class NextOperator(operator.CommandOperator):\n#     async def execute(\n#         self, context: command_context.ExecuteContext\n#     ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n#         if context.session.conversations:\n#             # 找到当前会话的下一个会话\n#             for index in range(len(context.session.conversations)):\n#                 if context.session.conversations[index] == context.session.using_conversation:\n#                     if index == len(context.session.conversations) - 1:\n#                         yield command_context.CommandReturn(\n#                             error=command_errors.CommandOperationError('已经是最后一个对话了')\n#                         )\n#                         return\n#                     else:\n#                         context.session.using_conversation = context.session.conversations[index + 1]\n#                         time_str = context.session.using_conversation.create_time.strftime('%Y-%m-%d %H:%M:%S')\n\n#                         yield command_context.CommandReturn(\n#                             text=f'已切换到后一个对话: {index} {time_str}: {context.session.using_conversation.messages[0].content}'\n#                         )\n#                         return\n#         else:\n#             yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话'))\n"
  },
  {
    "path": "src/langbot/pkg/command/operators/prompt.py",
    "content": "# from __future__ import annotations\n\n# import typing\n\n# from .. import operator\n# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors\n\n\n# @operator.operator_class(name='prompt', help='查看当前对话的前文', usage='!prompt')\n# class PromptOperator(operator.CommandOperator):\n#     async def execute(\n#         self, context: command_context.ExecuteContext\n#     ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n#         \"\"\"执行\"\"\"\n#         if context.session.using_conversation is None:\n#             yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话'))\n#         else:\n#             reply_str = '当前对话所有内容:\\n\\n'\n\n#             for msg in context.session.using_conversation.messages:\n#                 reply_str += f'{msg.role}: {msg.content}\\n'\n\n#             yield command_context.CommandReturn(text=reply_str)\n"
  },
  {
    "path": "src/langbot/pkg/command/operators/resend.py",
    "content": "# from __future__ import annotations\n\n# import typing\n\n# from .. import operator\n# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors\n\n\n# @operator.operator_class(name='resend', help='重发当前会话的最后一条消息', usage='!resend')\n# class ResendOperator(operator.CommandOperator):\n#     async def execute(\n#         self, context: command_context.ExecuteContext\n#     ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n#         # 回滚到最后一条用户message前\n#         if context.session.using_conversation is None:\n#             yield command_context.CommandReturn(error=command_errors.CommandError('当前没有对话'))\n#         else:\n#             conv_msg = context.session.using_conversation.messages\n\n#             # 倒序一直删到最后一条用户message\n#             while len(conv_msg) > 0 and conv_msg[-1].role != 'user':\n#                 conv_msg.pop()\n\n#             if len(conv_msg) > 0:\n#                 # 删除最后一条用户message\n#                 conv_msg.pop()\n\n#             # 不重发了，提示用户已删除就行了\n#             yield command_context.CommandReturn(text='已删除最后一次请求记录')\n"
  },
  {
    "path": "src/langbot/pkg/config/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/config/impls/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/config/impls/json.py",
    "content": "import os\nimport json\nimport importlib.resources as resources\n\nfrom langbot.pkg.config import model as file_model\n\n\nclass JSONConfigFile(file_model.ConfigFile):\n    \"\"\"JSON config file\"\"\"\n\n    def __init__(\n        self,\n        config_file_name: str,\n        template_resource_name: str = None,\n        template_data: dict = None,\n    ) -> None:\n        self.config_file_name = config_file_name\n        self.template_resource_name = template_resource_name\n        self.template_data = template_data\n\n    def exists(self) -> bool:\n        return os.path.exists(self.config_file_name)\n\n    async def get_template_file_str(self) -> str:\n        if self.template_resource_name is None:\n            return None\n\n        with (\n            resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f\n        ):\n            return f.read()\n\n    async def create(self):\n        if await self.get_template_file_str() is not None:\n            with open(self.config_file_name, 'w', encoding='utf-8') as f:\n                f.write(await self.get_template_file_str())\n        elif self.template_data is not None:\n            with open(self.config_file_name, 'w', encoding='utf-8') as f:\n                json.dump(self.template_data, f, indent=4, ensure_ascii=False)\n        else:\n            raise ValueError('template_file_name or template_data must be provided')\n\n    async def load(self, completion: bool = True) -> dict:\n        if not self.exists():\n            await self.create()\n\n        template_file_str = await self.get_template_file_str()\n\n        if template_file_str is not None:\n            self.template_data = json.loads(template_file_str)\n\n        with open(self.config_file_name, 'r', encoding='utf-8') as f:\n            try:\n                cfg = json.load(f)\n            except json.JSONDecodeError as e:\n                raise Exception(f'Syntax error in config file {self.config_file_name}: {e}')\n\n        if completion:\n            for key in self.template_data:\n                if key not in cfg:\n                    cfg[key] = self.template_data[key]\n\n        return cfg\n\n    async def save(self, cfg: dict):\n        with open(self.config_file_name, 'w', encoding='utf-8') as f:\n            json.dump(cfg, f, indent=4, ensure_ascii=False)\n\n    def save_sync(self, cfg: dict):\n        with open(self.config_file_name, 'w', encoding='utf-8') as f:\n            json.dump(cfg, f, indent=4, ensure_ascii=False)\n"
  },
  {
    "path": "src/langbot/pkg/config/impls/pymodule.py",
    "content": "import os\nimport shutil\nimport importlib\nimport logging\n\nfrom .. import model as file_model\n\n\nclass PythonModuleConfigFile(file_model.ConfigFile):\n    \"\"\"Python module config file\"\"\"\n\n    config_file_name: str = None\n    \"\"\"Config file name\"\"\"\n\n    template_file_name: str = None\n    \"\"\"Template file name\"\"\"\n\n    def __init__(self, config_file_name: str, template_file_name: str) -> None:\n        self.config_file_name = config_file_name\n        self.template_file_name = template_file_name\n\n    def exists(self) -> bool:\n        return os.path.exists(self.config_file_name)\n\n    async def create(self):\n        shutil.copyfile(self.template_file_name, self.config_file_name)\n\n    async def load(self, completion: bool = True) -> dict:\n        module_name = os.path.splitext(os.path.basename(self.config_file_name))[0]\n        module = importlib.import_module(module_name)\n\n        cfg = {}\n\n        allowed_types = (int, float, str, bool, list, dict)\n\n        for key in dir(module):\n            if key.startswith('__'):\n                continue\n\n            if not isinstance(getattr(module, key), allowed_types):\n                continue\n\n            cfg[key] = getattr(module, key)\n\n        # complete from template module file\n        if completion:\n            module_name = os.path.splitext(os.path.basename(self.template_file_name))[0]\n            module = importlib.import_module(module_name)\n\n            for key in dir(module):\n                if key.startswith('__'):\n                    continue\n\n                if not isinstance(getattr(module, key), allowed_types):\n                    continue\n\n                if key not in cfg:\n                    cfg[key] = getattr(module, key)\n\n        return cfg\n\n    async def save(self, data: dict):\n        logging.warning('Python module config file does not support saving')\n\n    def save_sync(self, data: dict):\n        logging.warning('Python module config file does not support saving')\n"
  },
  {
    "path": "src/langbot/pkg/config/impls/yaml.py",
    "content": "import os\nimport yaml\nimport importlib.resources as resources\n\nfrom langbot.pkg.config import model as file_model\n\n\nclass YAMLConfigFile(file_model.ConfigFile):\n    \"\"\"YAML config file\"\"\"\n\n    def __init__(\n        self,\n        config_file_name: str,\n        template_resource_name: str = None,\n        template_data: dict = None,\n    ) -> None:\n        self.config_file_name = config_file_name\n        self.template_resource_name = template_resource_name\n        self.template_data = template_data\n\n    def exists(self) -> bool:\n        return os.path.exists(self.config_file_name)\n\n    async def get_template_file_str(self) -> str:\n        if self.template_resource_name is None:\n            return None\n\n        with (\n            resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f\n        ):\n            return f.read()\n\n    async def create(self):\n        if await self.get_template_file_str() is not None:\n            with open(self.config_file_name, 'w', encoding='utf-8') as f:\n                f.write(await self.get_template_file_str())\n        elif self.template_data is not None:\n            with open(self.config_file_name, 'w', encoding='utf-8') as f:\n                yaml.dump(self.template_data, f, indent=4, allow_unicode=True)\n        else:\n            raise ValueError('template_file_name or template_data must be provided')\n\n    async def load(self, completion: bool = True) -> dict:\n        if not self.exists():\n            await self.create()\n\n        template_file_str = await self.get_template_file_str()\n\n        if template_file_str is not None:\n            self.template_data = yaml.load(template_file_str, Loader=yaml.FullLoader)\n\n        with open(self.config_file_name, 'r', encoding='utf-8') as f:\n            try:\n                cfg = yaml.load(f, Loader=yaml.FullLoader)\n            except yaml.YAMLError as e:\n                raise Exception(f'Syntax error in config file {self.config_file_name}: {e}')\n\n        if completion:\n            for key in self.template_data:\n                if key not in cfg:\n                    cfg[key] = self.template_data[key]\n\n        return cfg\n\n    async def save(self, cfg: dict):\n        with open(self.config_file_name, 'w', encoding='utf-8') as f:\n            yaml.dump(cfg, f, indent=4, allow_unicode=True)\n\n    def save_sync(self, cfg: dict):\n        with open(self.config_file_name, 'w', encoding='utf-8') as f:\n            yaml.dump(cfg, f, indent=4, allow_unicode=True)\n"
  },
  {
    "path": "src/langbot/pkg/config/manager.py",
    "content": "from __future__ import annotations\n\nfrom . import model as file_model\nfrom .impls import pymodule, json as json_file, yaml as yaml_file\n\n\nclass ConfigManager:\n    \"\"\"Config file manager\"\"\"\n\n    name: str = None\n    \"\"\"Config manager name\"\"\"\n\n    description: str = None\n    \"\"\"Config manager description\"\"\"\n\n    schema: dict = None\n    \"\"\"Config file schema\n    Must conform to JSON Schema Draft 7 specification\n    \"\"\"\n\n    file: file_model.ConfigFile = None\n    \"\"\"Config file instance\"\"\"\n\n    data: dict = None\n    \"\"\"Config data\"\"\"\n\n    doc_link: str = None\n    \"\"\"Config file documentation link\"\"\"\n\n    def __init__(self, cfg_file: file_model.ConfigFile) -> None:\n        self.file = cfg_file\n        self.data = {}\n\n    async def load_config(self, completion: bool = True):\n        self.data = await self.file.load(completion=completion)\n\n    async def dump_config(self):\n        await self.file.save(self.data)\n\n    def dump_config_sync(self):\n        self.file.save_sync(self.data)\n\n\nasync def load_python_module_config(config_name: str, template_name: str, completion: bool = True) -> ConfigManager:\n    \"\"\"Load Python module config file\n\n    Args:\n        config_name (str): Config file name\n        template_name (str): Template file name\n        completion (bool): Whether to automatically complete the config file in memory\n\n    Returns:\n        ConfigManager: Config file manager\n    \"\"\"\n    cfg_inst = pymodule.PythonModuleConfigFile(config_name, template_name)\n\n    cfg_mgr = ConfigManager(cfg_inst)\n    await cfg_mgr.load_config(completion=completion)\n\n    return cfg_mgr\n\n\nasync def load_json_config(\n    config_name: str,\n    template_resource_name: str = None,\n    template_data: dict = None,\n    completion: bool = True,\n) -> ConfigManager:\n    \"\"\"Load JSON config file\n\n    Args:\n        config_name (str): Config file name\n        template_resource_name (str): Template resource name\n        template_data (dict): Template data\n        completion (bool): Whether to automatically complete the config file in memory\n    \"\"\"\n    cfg_inst = json_file.JSONConfigFile(config_name, template_resource_name, template_data)\n\n    cfg_mgr = ConfigManager(cfg_inst)\n    await cfg_mgr.load_config(completion=completion)\n\n    return cfg_mgr\n\n\nasync def load_yaml_config(\n    config_name: str,\n    template_resource_name: str = None,\n    template_data: dict = None,\n    completion: bool = True,\n) -> ConfigManager:\n    \"\"\"Load YAML config file\n\n    Args:\n        config_name (str): Config file name\n        template_resource_name (str): Template resource name\n        template_data (dict): Template data\n        completion (bool): Whether to automatically complete the config file in memory\n\n    Returns:\n        ConfigManager: Config file manager\n    \"\"\"\n    cfg_inst = yaml_file.YAMLConfigFile(config_name, template_resource_name, template_data)\n\n    cfg_mgr = ConfigManager(cfg_inst)\n    await cfg_mgr.load_config(completion=completion)\n\n    return cfg_mgr\n"
  },
  {
    "path": "src/langbot/pkg/config/model.py",
    "content": "import abc\n\n\nclass ConfigFile(metaclass=abc.ABCMeta):\n    \"\"\"Config file abstract class\"\"\"\n\n    config_file_name: str = None\n    \"\"\"Config file name\"\"\"\n\n    template_file_name: str = None\n    \"\"\"Template file name\"\"\"\n\n    template_data: dict = None\n    \"\"\"Template data\"\"\"\n\n    @abc.abstractmethod\n    def exists(self) -> bool:\n        pass\n\n    @abc.abstractmethod\n    async def create(self):\n        pass\n\n    @abc.abstractmethod\n    async def load(self, completion: bool = True) -> dict:\n        pass\n\n    @abc.abstractmethod\n    async def save(self, data: dict):\n        pass\n\n    @abc.abstractmethod\n    def save_sync(self, data: dict):\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/core/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/core/app.py",
    "content": "from __future__ import annotations\n\nimport logging\nimport asyncio\nimport traceback\nimport os\n\nfrom ..platform import botmgr as im_mgr\nfrom ..platform.webhook_pusher import WebhookPusher\nfrom ..provider.session import sessionmgr as llm_session_mgr\nfrom ..provider.modelmgr import modelmgr as llm_model_mgr\n\nfrom langbot.pkg.provider.tools import toolmgr as llm_tool_mgr\nfrom ..config import manager as config_mgr\nfrom ..command import cmdmgr\nfrom ..plugin import connector as plugin_connector\nfrom ..pipeline import pool\nfrom ..pipeline import controller, pipelinemgr\nfrom ..pipeline import aggregator as message_aggregator\nfrom ..utils import version as version_mgr, proxy as proxy_mgr\nfrom ..persistence import mgr as persistencemgr\nfrom ..api.http.controller import main as http_controller\nfrom ..api.http.service import user as user_service\nfrom ..api.http.service import space as space_service\nfrom ..api.http.service import model as model_service\nfrom ..api.http.service import provider as provider_service\nfrom ..api.http.service import pipeline as pipeline_service\nfrom ..api.http.service import bot as bot_service\nfrom ..api.http.service import knowledge as knowledge_service\nfrom ..api.http.service import mcp as mcp_service\nfrom ..api.http.service import apikey as apikey_service\nfrom ..api.http.service import webhook as webhook_service\nfrom ..api.http.service import monitoring as monitoring_service\n\nfrom ..discover import engine as discover_engine\nfrom ..storage import mgr as storagemgr\nfrom ..utils import logcache\nfrom . import taskmgr\nfrom . import entities as core_entities\nfrom ..rag.knowledge import kbmgr as rag_mgr\nfrom ..rag.service import RAGRuntimeService\nfrom ..vector import mgr as vectordb_mgr\nfrom ..telemetry import telemetry as telemetry_module\nfrom ..survey import manager as survey_module\n\n\nclass Application:\n    \"\"\"Runtime application object and context\"\"\"\n\n    event_loop: asyncio.AbstractEventLoop = None\n\n    # asyncio_tasks: list[asyncio.Task] = []\n    task_mgr: taskmgr.AsyncTaskManager = None\n\n    discover: discover_engine.ComponentDiscoveryEngine = None\n\n    platform_mgr: im_mgr.PlatformManager = None\n\n    webhook_pusher: WebhookPusher = None\n\n    cmd_mgr: cmdmgr.CommandManager = None\n\n    sess_mgr: llm_session_mgr.SessionManager = None\n\n    model_mgr: llm_model_mgr.ModelManager = None\n\n    rag_mgr: rag_mgr.RAGManager = None\n    rag_runtime_service: RAGRuntimeService = None\n\n    # TODO move to pipeline\n    tool_mgr: llm_tool_mgr.ToolManager = None\n\n    # ======= Config manager =======\n\n    command_cfg: config_mgr.ConfigManager = None  # deprecated\n\n    pipeline_cfg: config_mgr.ConfigManager = None  # deprecated\n\n    platform_cfg: config_mgr.ConfigManager = None  # deprecated\n\n    provider_cfg: config_mgr.ConfigManager = None  # deprecated\n\n    system_cfg: config_mgr.ConfigManager = None  # deprecated\n\n    instance_config: config_mgr.ConfigManager = None\n\n    instance_id: config_mgr.ConfigManager = None  # used to identify the instance\n\n    # ======= Metadata config manager =======\n\n    sensitive_meta: config_mgr.ConfigManager = None\n\n    pipeline_config_meta_trigger: config_mgr.ConfigManager = None\n    pipeline_config_meta_safety: config_mgr.ConfigManager = None\n    pipeline_config_meta_ai: config_mgr.ConfigManager = None\n    pipeline_config_meta_output: config_mgr.ConfigManager = None\n\n    # =========================\n\n    plugin_connector: plugin_connector.PluginRuntimeConnector = None\n\n    query_pool: pool.QueryPool = None\n\n    msg_aggregator: message_aggregator.MessageAggregator = None\n\n    ctrl: controller.Controller = None\n\n    pipeline_mgr: pipelinemgr.PipelineManager = None\n\n    ver_mgr: version_mgr.VersionManager = None\n\n    proxy_mgr: proxy_mgr.ProxyManager = None\n\n    logger: logging.Logger = None\n\n    persistence_mgr: persistencemgr.PersistenceManager = None\n\n    vector_db_mgr: vectordb_mgr.VectorDBManager = None\n\n    http_ctrl: http_controller.HTTPController = None\n\n    log_cache: logcache.LogCache = None\n\n    storage_mgr: storagemgr.StorageMgr = None\n\n    # ========= HTTP Services =========\n\n    user_service: user_service.UserService = None\n\n    space_service: space_service.SpaceService = None\n\n    llm_model_service: model_service.LLMModelsService = None\n\n    embedding_models_service: model_service.EmbeddingModelsService = None\n\n    provider_service: provider_service.ModelProviderService = None\n\n    pipeline_service: pipeline_service.PipelineService = None\n\n    bot_service: bot_service.BotService = None\n\n    knowledge_service: knowledge_service.KnowledgeService = None\n\n    mcp_service: mcp_service.MCPService = None\n\n    apikey_service: apikey_service.ApiKeyService = None\n\n    webhook_service: webhook_service.WebhookService = None\n\n    telemetry: telemetry_module.TelemetryManager = None\n\n    survey: survey_module.SurveyManager = None\n\n    monitoring_service: monitoring_service.MonitoringService = None\n\n    def __init__(self):\n        pass\n\n    async def initialize(self):\n        pass\n\n    async def run(self):\n        try:\n            await self.plugin_connector.initialize_plugins()\n\n            # 后续可能会允许动态重启其他任务\n            # 故为了防止程序在非 Ctrl-C 情况下退出，这里创建一个不会结束的协程\n            async def never_ending():\n                while True:\n                    await asyncio.sleep(1)\n\n            self.task_mgr.create_task(\n                self.platform_mgr.run(),\n                name='platform-manager',\n                scopes=[\n                    core_entities.LifecycleControlScope.APPLICATION,\n                    core_entities.LifecycleControlScope.PLATFORM,\n                ],\n            )\n            self.task_mgr.create_task(\n                self.ctrl.run(),\n                name='query-controller',\n                scopes=[core_entities.LifecycleControlScope.APPLICATION],\n            )\n            self.task_mgr.create_task(\n                self.http_ctrl.run(),\n                name='http-api-controller',\n                scopes=[core_entities.LifecycleControlScope.APPLICATION],\n            )\n\n            self.task_mgr.create_task(\n                never_ending(),\n                name='never-ending-task',\n                scopes=[core_entities.LifecycleControlScope.APPLICATION],\n            )\n\n            await self.print_web_access_info()\n            await self.task_mgr.wait_all()\n        except asyncio.CancelledError:\n            pass\n        except Exception as e:\n            self.logger.error(f'Application runtime fatal exception: {e}')\n            self.logger.debug(f'Traceback: {traceback.format_exc()}')\n\n    def dispose(self):\n        self.plugin_connector.dispose()\n\n    async def print_web_access_info(self):\n        \"\"\"Print access webui tips\"\"\"\n\n        from ..utils import paths\n\n        frontend_path = paths.get_frontend_path()\n\n        if not os.path.exists(frontend_path):\n            self.logger.warning('WebUI 文件缺失，请根据文档部署：https://docs.langbot.app/zh')\n            self.logger.warning(\n                'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'\n            )\n            return\n\n        host_ip = '127.0.0.1'\n\n        port = self.instance_config.data['api']['port']\n\n        tips = f\"\"\"\n=======================================\n✨ Access WebUI / 访问管理面板\n\n🏠 Local Address: http://{host_ip}:{port}/\n🌐 Public Address: http://<Your Public IP>:{port}/\n\n📌 Running this program in a container? Please ensure that the {port} port is exposed\n=======================================\n\"\"\".strip()\n        for line in tips.split('\\n'):\n            self.logger.info(line)\n"
  },
  {
    "path": "src/langbot/pkg/core/boot.py",
    "content": "from __future__ import annotations\n\nimport traceback\nimport asyncio\nimport os\n\nfrom . import app\nfrom . import stage\nfrom ..utils import constants, importutil\n\n# Import startup stage implementation to register\nfrom . import stages\n\nimportutil.import_modules_in_pkg(stages)\n\n\nstage_order = [\n    'LoadConfigStage',\n    'MigrationStage',\n    'GenKeysStage',\n    'SetupLoggerStage',\n    'BuildAppStage',\n    'ShowNotesStage',\n]\n\n\nasync def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:\n    # Determine if it is debug mode\n    if 'DEBUG' in os.environ and os.environ['DEBUG'] in ['true', '1']:\n        constants.debug_mode = True\n\n    ap = app.Application()\n\n    ap.event_loop = loop\n\n    # Execute startup stage\n    for stage_name in stage_order:\n        stage_cls = stage.preregistered_stages[stage_name]\n        stage_inst = stage_cls()\n\n        await stage_inst.run(ap)\n\n    await ap.initialize()\n\n    return ap\n\n\nasync def main(loop: asyncio.AbstractEventLoop):\n    try:\n        # Hang system signal processing\n        import signal\n\n        def signal_handler(sig, frame):\n            app_inst.dispose()\n            print('[Signal] Program exit.')\n            os._exit(0)\n\n        signal.signal(signal.SIGINT, signal_handler)\n\n        app_inst = await make_app(loop)\n        await app_inst.run()\n    except Exception:\n        traceback.print_exc()\n"
  },
  {
    "path": "src/langbot/pkg/core/bootutils/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/core/bootutils/config.py",
    "content": "from __future__ import annotations\n\n\nfrom ...config import manager as config_mgr\n\n\nload_python_module_config = config_mgr.load_python_module_config\nload_json_config = config_mgr.load_json_config\nload_yaml_config = config_mgr.load_yaml_config\n"
  },
  {
    "path": "src/langbot/pkg/core/bootutils/deps.py",
    "content": "import importlib.util\nimport pip\nimport os\nfrom ...utils import pkgmgr\n\n# Check dependencies to prevent users from not installing\n# Left is the import name, right is the dependency name\nrequired_deps = {\n    'requests': 'requests',\n    'openai': 'openai',\n    'anthropic': 'anthropic',\n    'colorlog': 'colorlog',\n    'aiocqhttp': 'aiocqhttp',\n    'botpy': 'qq-botpy-rc',\n    'PIL': 'pillow',\n    'nakuru': 'nakuru-project-idk',\n    'tiktoken': 'tiktoken',\n    'yaml': 'pyyaml',\n    'aiohttp': 'aiohttp',\n    'psutil': 'psutil',\n    'async_lru': 'async-lru',\n    'ollama': 'ollama',\n    'quart': 'quart',\n    'quart_cors': 'quart-cors',\n    'sqlalchemy': 'sqlalchemy[asyncio]',\n    'aiosqlite': 'aiosqlite',\n    'aiofiles': 'aiofiles',\n    'aioshutil': 'aioshutil',\n    'argon2': 'argon2-cffi',\n    'jwt': 'pyjwt',\n    'Crypto': 'pycryptodome',\n    'lark_oapi': 'lark-oapi',\n    'discord': 'discord.py',\n    'cryptography': 'cryptography',\n    'gewechat_client': 'gewechat-client',\n    'dingtalk_stream': 'dingtalk_stream',\n    'dashscope': 'dashscope',\n    'telegram': 'python-telegram-bot',\n    'certifi': 'certifi',\n    'mcp': 'mcp',\n    'sqlmodel': 'sqlmodel',\n    'telegramify_markdown': 'telegramify-markdown',\n    'slack_sdk': 'slack_sdk',\n    'asyncpg': 'asyncpg',\n}\n\n\nasync def check_deps() -> list[str]:\n    global required_deps\n\n    missing_deps = []\n    for dep in required_deps:\n        # Use find_spec instead of __import__ to avoid actually loading\n        # all modules into memory. find_spec only checks if the module\n        # can be found, without executing module-level code.\n        if importlib.util.find_spec(dep) is None:\n            missing_deps.append(dep)\n    return missing_deps\n\n\nasync def install_deps(deps: list[str]):\n    global required_deps\n\n    for dep in deps:\n        pip.main(['install', required_deps[dep]])\n\n\nasync def precheck_plugin_deps():\n    print('[Startup] Prechecking plugin dependencies...')\n\n    # Only execute plugin dependency installation when the plugins directory exists\n    if os.path.exists('plugins'):\n        for dir in os.listdir('plugins'):\n            subdir = os.path.join('plugins', dir)\n            if not os.path.isdir(subdir):\n                continue\n            if 'requirements.txt' in os.listdir(subdir):\n                pkgmgr.install_requirements(\n                    os.path.join(subdir, 'requirements.txt'),\n                    extra_params=[],\n                )\n"
  },
  {
    "path": "src/langbot/pkg/core/bootutils/files.py",
    "content": "from __future__ import annotations\n\nimport os\nimport shutil\n\n\nrequired_files = {\n    'data/config.yaml': 'templates/config.yaml',\n}\n\nrequired_paths = [\n    'temp',\n    'data',\n    'data/metadata',\n    'data/logs',\n    'data/labels',\n]\n\n\nasync def generate_files() -> list[str]:\n    global required_files, required_paths\n\n    from ...utils import paths as path_utils\n\n    for required_paths in required_paths:\n        if not os.path.exists(required_paths):\n            os.mkdir(required_paths)\n\n    generated_files = []\n    for file in required_files:\n        if not os.path.exists(file):\n            template_path = path_utils.get_resource_path(required_files[file])\n            shutil.copyfile(template_path, file)\n            generated_files.append(file)\n\n    return generated_files\n"
  },
  {
    "path": "src/langbot/pkg/core/bootutils/log.py",
    "content": "import logging\nimport logging.handlers\nimport sys\nimport time\n\nimport colorlog\n\nfrom ...utils import constants\n\n\nlog_colors_config = {\n    'DEBUG': 'green',  # cyan white\n    'INFO': 'white',\n    'WARNING': 'yellow',\n    'ERROR': 'red',\n    'CRITICAL': 'cyan',\n}\n\n# Log rotation configuration to prevent unbounded log file growth\nLOG_FILE_MAX_BYTES = 10 * 1024 * 1024  # 10MB per file\nLOG_FILE_BACKUP_COUNT = 5  # Keep 5 backup files (total ~50MB max)\n\n\nasync def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger:\n    # Remove all existing loggers\n    for handler in logging.root.handlers[:]:\n        logging.root.removeHandler(handler)\n\n    level = logging.INFO\n\n    if constants.debug_mode:\n        level = logging.DEBUG\n\n    log_file_name = 'data/logs/langbot-%s.log' % time.strftime('%Y-%m-%d', time.localtime())\n\n    qcg_logger = logging.getLogger('langbot')\n\n    qcg_logger.setLevel(level)\n\n    color_formatter = colorlog.ColoredFormatter(\n        fmt='%(log_color)s[%(asctime)s.%(msecs)03d] %(filename)s (%(lineno)d) - [%(levelname)s] : %(message)s',\n        datefmt='%m-%d %H:%M:%S',\n        log_colors=log_colors_config,\n    )\n\n    stream_handler = logging.StreamHandler(sys.stdout)\n    # stream_handler.setLevel(level)\n    # stream_handler.setFormatter(color_formatter)\n    stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)\n\n    # Use RotatingFileHandler to prevent unbounded log file growth\n    rotating_file_handler = logging.handlers.RotatingFileHandler(\n        log_file_name,\n        encoding='utf-8',\n        maxBytes=LOG_FILE_MAX_BYTES,\n        backupCount=LOG_FILE_BACKUP_COUNT,\n    )\n\n    log_handlers: list[logging.Handler] = [\n        stream_handler,\n        rotating_file_handler,\n    ]\n    log_handlers += extra_handlers if extra_handlers is not None else []\n\n    for handler in log_handlers:\n        handler.setLevel(level)\n        handler.setFormatter(color_formatter)\n        qcg_logger.addHandler(handler)\n\n    qcg_logger.debug('Logging initialized, log level: %s' % level)\n    logging.basicConfig(\n        level=logging.CRITICAL,  # Set log output format\n        format='[DEPR][%(asctime)s.%(msecs)03d] %(pathname)s (%(lineno)d) - [%(levelname)s] :\\n%(message)s',\n        # Log output format\n        # -8 is a placeholder, left-align the output, and output length is 8\n        datefmt='%Y-%m-%d %H:%M:%S',  # Time output format\n        handlers=[logging.NullHandler()],\n    )\n\n    return qcg_logger\n"
  },
  {
    "path": "src/langbot/pkg/core/entities.py",
    "content": "from __future__ import annotations\n\nimport enum\n\n\nclass LifecycleControlScope(enum.Enum):\n    APPLICATION = 'application'\n    PLATFORM = 'platform'\n    PLUGIN = 'plugin'\n    PROVIDER = 'provider'\n"
  },
  {
    "path": "src/langbot/pkg/core/migration.py",
    "content": "from __future__ import annotations\n\nimport abc\nimport typing\n\nfrom . import app\n\n\npreregistered_migrations: list[typing.Type[Migration]] = []\n\"\"\"Currently not supported for extension\"\"\"\n\n\ndef migration_class(name: str, number: int):\n    \"\"\"Register a migration\"\"\"\n\n    def decorator(cls: typing.Type[Migration]) -> typing.Type[Migration]:\n        cls.name = name\n        cls.number = number\n        preregistered_migrations.append(cls)\n        return cls\n\n    return decorator\n\n\nclass Migration(abc.ABC):\n    \"\"\"A version migration\"\"\"\n\n    name: str\n\n    number: int\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    @abc.abstractmethod\n    async def need_migrate(self) -> bool:\n        \"\"\"Determine if the current environment needs to run this migration\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def run(self):\n        \"\"\"Run migration\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/core/migrations/m001_sensitive_word_migration.py",
    "content": "from __future__ import annotations\n\nimport os\n\nfrom .. import migration\n\n\n@migration.migration_class('sensitive-word-migration', 1)\nclass SensitiveWordMigration(migration.Migration):\n    \"\"\"敏感词迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return os.path.exists('data/config/sensitive-words.json') and not os.path.exists(\n            'data/metadata/sensitive-words.json'\n        )\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        # 移动文件\n        os.rename('data/config/sensitive-words.json', 'data/metadata/sensitive-words.json')\n\n        # 重新加载配置\n        await self.ap.sensitive_meta.load_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m002_openai_config_migration.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('openai-config-migration', 2)\nclass OpenAIConfigMigration(migration.Migration):\n    \"\"\"OpenAI配置迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'openai-config' in self.ap.provider_cfg.data\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        old_openai_config = self.ap.provider_cfg.data['openai-config'].copy()\n\n        if 'keys' not in self.ap.provider_cfg.data:\n            self.ap.provider_cfg.data['keys'] = {}\n\n        if 'openai' not in self.ap.provider_cfg.data['keys']:\n            self.ap.provider_cfg.data['keys']['openai'] = []\n\n        self.ap.provider_cfg.data['keys']['openai'] = old_openai_config['api-keys']\n\n        self.ap.provider_cfg.data['model'] = old_openai_config['chat-completions-params']['model']\n\n        del old_openai_config['chat-completions-params']['model']\n\n        if 'requester' not in self.ap.provider_cfg.data:\n            self.ap.provider_cfg.data['requester'] = {}\n\n        if 'openai-chat-completions' not in self.ap.provider_cfg.data['requester']:\n            self.ap.provider_cfg.data['requester']['openai-chat-completions'] = {}\n\n        self.ap.provider_cfg.data['requester']['openai-chat-completions'] = {\n            'base-url': old_openai_config['base_url'],\n            'args': old_openai_config['chat-completions-params'],\n            'timeout': old_openai_config['request-timeout'],\n        }\n\n        del self.ap.provider_cfg.data['openai-config']\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m003_anthropic_requester_cfg_completion.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('anthropic-requester-config-completion', 3)\nclass AnthropicRequesterConfigCompletionMigration(migration.Migration):\n    \"\"\"OpenAI配置迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return (\n            'anthropic-messages' not in self.ap.provider_cfg.data['requester']\n            or 'anthropic' not in self.ap.provider_cfg.data['keys']\n        )\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        if 'anthropic-messages' not in self.ap.provider_cfg.data['requester']:\n            self.ap.provider_cfg.data['requester']['anthropic-messages'] = {\n                'base-url': 'https://api.anthropic.com',\n                'args': {'max_tokens': 1024},\n                'timeout': 120,\n            }\n\n        if 'anthropic' not in self.ap.provider_cfg.data['keys']:\n            self.ap.provider_cfg.data['keys']['anthropic'] = []\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m004_moonshot_cfg_completion.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('moonshot-config-completion', 4)\nclass MoonshotConfigCompletionMigration(migration.Migration):\n    \"\"\"OpenAI配置迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return (\n            'moonshot-chat-completions' not in self.ap.provider_cfg.data['requester']\n            or 'moonshot' not in self.ap.provider_cfg.data['keys']\n        )\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        if 'moonshot-chat-completions' not in self.ap.provider_cfg.data['requester']:\n            self.ap.provider_cfg.data['requester']['moonshot-chat-completions'] = {\n                'base-url': 'https://api.moonshot.cn/v1',\n                'args': {},\n                'timeout': 120,\n            }\n\n        if 'moonshot' not in self.ap.provider_cfg.data['keys']:\n            self.ap.provider_cfg.data['keys']['moonshot'] = []\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m005_deepseek_cfg_completion.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('deepseek-config-completion', 5)\nclass DeepseekConfigCompletionMigration(migration.Migration):\n    \"\"\"OpenAI配置迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return (\n            'deepseek-chat-completions' not in self.ap.provider_cfg.data['requester']\n            or 'deepseek' not in self.ap.provider_cfg.data['keys']\n        )\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        if 'deepseek-chat-completions' not in self.ap.provider_cfg.data['requester']:\n            self.ap.provider_cfg.data['requester']['deepseek-chat-completions'] = {\n                'base-url': 'https://api.deepseek.com',\n                'args': {},\n                'timeout': 120,\n            }\n\n        if 'deepseek' not in self.ap.provider_cfg.data['keys']:\n            self.ap.provider_cfg.data['keys']['deepseek'] = []\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m006_vision_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('vision-config', 6)\nclass VisionConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'enable-vision' not in self.ap.provider_cfg.data\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        if 'enable-vision' not in self.ap.provider_cfg.data:\n            self.ap.provider_cfg.data['enable-vision'] = False\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m007_qcg_center_url.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('qcg-center-url-config', 7)\nclass QCGCenterURLConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'qcg-center-url' not in self.ap.system_cfg.data\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n\n        if 'qcg-center-url' not in self.ap.system_cfg.data:\n            self.ap.system_cfg.data['qcg-center-url'] = 'https://api.qchatgpt.rockchin.top/api/v2'\n\n        await self.ap.system_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m008_ad_fixwin_config_migrate.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('ad-fixwin-cfg-migration', 8)\nclass AdFixwinConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return isinstance(self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default'], int)\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n\n        for session_name in self.ap.pipeline_cfg.data['rate-limit']['fixwin']:\n            temp_dict = {\n                'window-size': 60,\n                'limit': self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name],\n            }\n\n            self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name] = temp_dict\n\n        await self.ap.pipeline_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('msg-truncator-cfg-migration', 9)\nclass MsgTruncatorConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'msg-truncate' not in self.ap.pipeline_cfg.data\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n\n        self.ap.pipeline_cfg.data['msg-truncate'] = {\n            'method': 'round',\n            'round': {'max-round': 10},\n        }\n\n        await self.ap.pipeline_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m010_ollama_requester_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('ollama-requester-config', 10)\nclass MsgTruncatorConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'ollama-chat' not in self.ap.provider_cfg.data['requester']\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n\n        self.ap.provider_cfg.data['requester']['ollama-chat'] = {\n            'base-url': 'http://127.0.0.1:11434',\n            'args': {},\n            'timeout': 600,\n        }\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m011_command_prefix_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('command-prefix-config', 11)\nclass CommandPrefixConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'command-prefix' not in self.ap.command_cfg.data\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n\n        self.ap.command_cfg.data['command-prefix'] = ['!', '！']\n\n        await self.ap.command_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m012_runner_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('runner-config', 12)\nclass RunnerConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'runner' not in self.ap.provider_cfg.data\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n\n        self.ap.provider_cfg.data['runner'] = 'local-agent'\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m013_http_api_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('http-api-config', 13)\nclass HttpApiConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'http-api' not in self.ap.system_cfg.data or 'persistence' not in self.ap.system_cfg.data\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n\n        self.ap.system_cfg.data['http-api'] = {\n            'enable': True,\n            'host': '0.0.0.0',\n            'port': 5300,\n            'jwt-expire': 604800,\n        }\n\n        self.ap.system_cfg.data['persistence'] = {\n            'sqlite': {'path': 'data/persistence.db'},\n            'use': 'sqlite',\n        }\n\n        await self.ap.system_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m014_force_delay_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('force-delay-config', 14)\nclass ForceDelayConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return isinstance(self.ap.platform_cfg.data['force-delay'], list)\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n\n        self.ap.platform_cfg.data['force-delay'] = {\n            'min': self.ap.platform_cfg.data['force-delay'][0],\n            'max': self.ap.platform_cfg.data['force-delay'][1],\n        }\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m015_gitee_ai_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('gitee-ai-config', 15)\nclass GiteeAIConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return (\n            'gitee-ai-chat-completions' not in self.ap.provider_cfg.data['requester']\n            or 'gitee-ai' not in self.ap.provider_cfg.data['keys']\n        )\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['requester']['gitee-ai-chat-completions'] = {\n            'base-url': 'https://ai.gitee.com/v1',\n            'args': {},\n            'timeout': 120,\n        }\n\n        self.ap.provider_cfg.data['keys']['gitee-ai'] = ['XXXXX']\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m016_dify_service_api.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('dify-service-api-config', 16)\nclass DifyServiceAPICfgMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'dify-service-api' not in self.ap.provider_cfg.data\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['dify-service-api'] = {\n            'base-url': 'https://api.dify.ai/v1',\n            'app-type': 'chat',\n            'chat': {'api-key': 'app-1234567890'},\n            'workflow': {'api-key': 'app-1234567890', 'output-key': 'summary'},\n        }\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m017_dify_api_timeout_params.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('dify-api-timeout-params', 17)\nclass DifyAPITimeoutParamsMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return (\n            'timeout' not in self.ap.provider_cfg.data['dify-service-api']['chat']\n            or 'timeout' not in self.ap.provider_cfg.data['dify-service-api']['workflow']\n            or 'agent' not in self.ap.provider_cfg.data['dify-service-api']\n        )\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['dify-service-api']['chat']['timeout'] = 120\n        self.ap.provider_cfg.data['dify-service-api']['workflow']['timeout'] = 120\n        self.ap.provider_cfg.data['dify-service-api']['agent'] = {\n            'api-key': 'app-1234567890',\n            'timeout': 120,\n        }\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m018_xai_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('xai-config', 18)\nclass XaiConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'xai-chat-completions' not in self.ap.provider_cfg.data['requester']\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['requester']['xai-chat-completions'] = {\n            'base-url': 'https://api.x.ai/v1',\n            'args': {},\n            'timeout': 120,\n        }\n        self.ap.provider_cfg.data['keys']['xai'] = ['xai-1234567890']\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m019_zhipuai_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('zhipuai-config', 19)\nclass ZhipuaiConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'zhipuai-chat-completions' not in self.ap.provider_cfg.data['requester']\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['requester']['zhipuai-chat-completions'] = {\n            'base-url': 'https://open.bigmodel.cn/api/paas/v4',\n            'args': {},\n            'timeout': 120,\n        }\n        self.ap.provider_cfg.data['keys']['zhipuai'] = ['xxxxxxx']\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m020_wecom_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('wecom-config', 20)\nclass WecomConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        # for adapter in self.ap.platform_cfg.data['platform-adapters']:\n        #     if adapter['adapter'] == 'wecom':\n        #         return False\n\n        # return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.platform_cfg.data['platform-adapters'].append(\n            {\n                'adapter': 'wecom',\n                'enable': False,\n                'host': '0.0.0.0',\n                'port': 2290,\n                'corpid': '',\n                'secret': '',\n                'token': '',\n                'EncodingAESKey': '',\n                'contacts_secret': '',\n            }\n        )\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m021_lark_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('lark-config', 21)\nclass LarkConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        # for adapter in self.ap.platform_cfg.data['platform-adapters']:\n        #     if adapter['adapter'] == 'lark':\n        #         return False\n\n        # return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.platform_cfg.data['platform-adapters'].append(\n            {\n                'adapter': 'lark',\n                'enable': False,\n                'app_id': 'cli_abcdefgh',\n                'app_secret': 'XXXXXXXXXX',\n                'bot_name': 'LangBot',\n                'enable-webhook': False,\n                'port': 2285,\n                'encrypt-key': 'xxxxxxxxx',\n            }\n        )\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m022_lmstudio_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('lmstudio-config', 22)\nclass LmStudioConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        return 'lmstudio-chat-completions' not in self.ap.provider_cfg.data['requester']\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['requester']['lmstudio-chat-completions'] = {\n            'base-url': 'http://127.0.0.1:1234/v1',\n            'args': {},\n            'timeout': 120,\n        }\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m023_siliconflow_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('siliconflow-config', 23)\nclass SiliconFlowConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        return 'siliconflow-chat-completions' not in self.ap.provider_cfg.data['requester']\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['keys']['siliconflow'] = ['xxxxxxx']\n\n        self.ap.provider_cfg.data['requester']['siliconflow-chat-completions'] = {\n            'base-url': 'https://api.siliconflow.cn/v1',\n            'args': {},\n            'timeout': 120,\n        }\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m024_discord_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('discord-config', 24)\nclass DiscordConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        # for adapter in self.ap.platform_cfg.data['platform-adapters']:\n        #     if adapter['adapter'] == 'discord':\n        #         return False\n\n        # return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.platform_cfg.data['platform-adapters'].append(\n            {\n                'adapter': 'discord',\n                'enable': False,\n                'client_id': '1234567890',\n                'token': 'XXXXXXXXXX',\n            }\n        )\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m025_gewechat_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('gewechat-config', 25)\nclass GewechatConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        # for adapter in self.ap.platform_cfg.data['platform-adapters']:\n        #     if adapter['adapter'] == 'gewechat':\n        #         return False\n\n        # return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.platform_cfg.data['platform-adapters'].append(\n            {\n                'adapter': 'gewechat',\n                'enable': False,\n                'gewechat_url': 'http://your-gewechat-server:2531',\n                'gewechat_file_url': 'http://your-gewechat-server:2532',\n                'port': 2286,\n                'callback_url': 'http://your-callback-url:2286/gewechat/callback',\n                'app_id': '',\n                'token': '',\n            }\n        )\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m026_qqofficial_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('qqofficial-config', 26)\nclass QQOfficialConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        # for adapter in self.ap.platform_cfg.data['platform-adapters']:\n        #     if adapter['adapter'] == 'qqofficial':\n        #         return False\n\n        # return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.platform_cfg.data['platform-adapters'].append(\n            {\n                'adapter': 'qqofficial',\n                'enable': False,\n                'appid': '',\n                'secret': '',\n                'port': 2284,\n                'token': '',\n            }\n        )\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m027_wx_official_account_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('wx-official-account-config', 27)\nclass WXOfficialAccountConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        # for adapter in self.ap.platform_cfg.data['platform-adapters']:\n        #     if adapter['adapter'] == 'officialaccount':\n        #         return False\n\n        # return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.platform_cfg.data['platform-adapters'].append(\n            {\n                'adapter': 'officialaccount',\n                'enable': False,\n                'token': '',\n                'EncodingAESKey': '',\n                'AppID': '',\n                'AppSecret': '',\n                'host': '0.0.0.0',\n                'port': 2287,\n            }\n        )\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m028_aliyun_requester_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('bailian-requester-config', 28)\nclass BailianRequesterConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        return 'bailian-chat-completions' not in self.ap.provider_cfg.data['requester']\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['keys']['bailian'] = ['sk-xxxxxxx']\n\n        self.ap.provider_cfg.data['requester']['bailian-chat-completions'] = {\n            'base-url': 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n            'args': {},\n            'timeout': 120,\n        }\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m029_dashscope_app_api_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('dashscope-app-api-config', 29)\nclass DashscopeAppAPICfgMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'dashscope-app-api' not in self.ap.provider_cfg.data\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['dashscope-app-api'] = {\n            'app-type': 'agent',\n            'api-key': 'sk-1234567890',\n            'agent': {'app-id': 'Your_app_id', 'references_quote': '参考资料来自:'},\n            'workflow': {\n                'app-id': 'Your_app_id',\n                'references_quote': '参考资料来自:',\n                'biz_params': {'city': '北京', 'date': '2023-08-10'},\n            },\n        }\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m030_lark_config_cmpl.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('lark-config-cmpl', 30)\nclass LarkConfigCmplMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        for adapter in self.ap.platform_cfg.data['platform-adapters']:\n            if adapter['adapter'] == 'lark':\n                if 'enable-webhook' not in adapter:\n                    return True\n\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        for adapter in self.ap.platform_cfg.data['platform-adapters']:\n            if adapter['adapter'] == 'lark':\n                if 'enable-webhook' not in adapter:\n                    adapter['enable-webhook'] = False\n                if 'port' not in adapter:\n                    adapter['port'] = 2285\n                if 'encrypt-key' not in adapter:\n                    adapter['encrypt-key'] = 'xxxxxxxxx'\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m031_dingtalk_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('dingtalk-config', 31)\nclass DingTalkConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        # for adapter in self.ap.platform_cfg.data['platform-adapters']:\n        #     if adapter['adapter'] == 'dingtalk':\n        #         return False\n\n        # return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.platform_cfg.data['platform-adapters'].append(\n            {\n                'adapter': 'dingtalk',\n                'enable': False,\n                'client_id': '',\n                'client_secret': '',\n                'robot_code': '',\n                'robot_name': '',\n            }\n        )\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m032_volcark_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('volcark-requester-config', 32)\nclass VolcArkRequesterConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        return 'volcark-chat-completions' not in self.ap.provider_cfg.data['requester']\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['keys']['volcark'] = ['xxxxxxxx']\n\n        self.ap.provider_cfg.data['requester']['volcark-chat-completions'] = {\n            'base-url': 'https://ark.cn-beijing.volces.com/api/v3',\n            'args': {},\n            'timeout': 120,\n        }\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m033_dify_thinking_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('dify-thinking-config', 33)\nclass DifyThinkingConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        if 'options' not in self.ap.provider_cfg.data['dify-service-api']:\n            return True\n\n        if 'convert-thinking-tips' not in self.ap.provider_cfg.data['dify-service-api']['options']:\n            return True\n\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['dify-service-api']['options'] = {'convert-thinking-tips': 'plain'}\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m034_gewechat_file_url_config.py",
    "content": "from __future__ import annotations\n\nfrom urllib.parse import urlparse\n\nfrom .. import migration\n\n\n@migration.migration_class('gewechat-file-url-config', 34)\nclass GewechatFileUrlConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        for adapter in self.ap.platform_cfg.data['platform-adapters']:\n            if adapter['adapter'] == 'gewechat':\n                if 'gewechat_file_url' not in adapter:\n                    return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        for adapter in self.ap.platform_cfg.data['platform-adapters']:\n            if adapter['adapter'] == 'gewechat':\n                if 'gewechat_file_url' not in adapter:\n                    parsed_url = urlparse(adapter['gewechat_url'])\n                    adapter['gewechat_file_url'] = f'{parsed_url.scheme}://{parsed_url.hostname}:2532'\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m035_wxoa_mode.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('wxoa-mode', 35)\nclass WxoaModeMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        for adapter in self.ap.platform_cfg.data['platform-adapters']:\n            if adapter['adapter'] == 'officialaccount':\n                if 'Mode' not in adapter:\n                    return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        for adapter in self.ap.platform_cfg.data['platform-adapters']:\n            if adapter['adapter'] == 'officialaccount':\n                if 'Mode' not in adapter:\n                    adapter['Mode'] = 'drop'\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m036_wxoa_loading_message.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('wxoa-loading-message', 36)\nclass WxoaLoadingMessageMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        for adapter in self.ap.platform_cfg.data['platform-adapters']:\n            if adapter['adapter'] == 'officialaccount':\n                if 'LoadingMessage' not in adapter:\n                    return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        for adapter in self.ap.platform_cfg.data['platform-adapters']:\n            if adapter['adapter'] == 'officialaccount':\n                if 'LoadingMessage' not in adapter:\n                    adapter['LoadingMessage'] = 'AI正在思考中，请发送任意内容获取回复。'\n\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m037_mcp_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('mcp-config', 37)\nclass MCPConfigMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return 'mcp' not in self.ap.provider_cfg.data\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.provider_cfg.data['mcp'] = {'servers': []}\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m038_tg_dingtalk_markdown.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('tg-dingtalk-markdown', 38)\nclass TgDingtalkMarkdownMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n\n        for adapter in self.ap.platform_cfg.data['platform-adapters']:\n            if adapter['adapter'] in ['dingtalk', 'telegram']:\n                if 'markdown_card' not in adapter:\n                    return True\n        return False\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        for adapter in self.ap.platform_cfg.data['platform-adapters']:\n            if adapter['adapter'] in ['dingtalk', 'telegram']:\n                if 'markdown_card' not in adapter:\n                    adapter['markdown_card'] = False\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m039_modelscope_cfg_completion.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('modelscope-config-completion', 39)\nclass ModelScopeConfigCompletionMigration(migration.Migration):\n    \"\"\"ModelScope配置迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return (\n            'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester']\n            or 'modelscope' not in self.ap.provider_cfg.data['keys']\n        )\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        if 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester']:\n            self.ap.provider_cfg.data['requester']['modelscope-chat-completions'] = {\n                'base-url': 'https://api-inference.modelscope.cn/v1',\n                'args': {},\n                'timeout': 120,\n            }\n\n        if 'modelscope' not in self.ap.provider_cfg.data['keys']:\n            self.ap.provider_cfg.data['keys']['modelscope'] = []\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m040_ppio_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('ppio-config', 40)\nclass PPIOConfigMigration(migration.Migration):\n    \"\"\"PPIO配置迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return (\n            'ppio-chat-completions' not in self.ap.provider_cfg.data['requester']\n            or 'ppio' not in self.ap.provider_cfg.data['keys']\n        )\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        if 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester']:\n            self.ap.provider_cfg.data['requester']['ppio-chat-completions'] = {\n                'base-url': 'https://api.ppinfra.com/v3/openai',\n                'args': {},\n                'timeout': 120,\n            }\n\n        if 'ppio' not in self.ap.provider_cfg.data['keys']:\n            self.ap.provider_cfg.data['keys']['ppio'] = []\n\n        await self.ap.provider_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/migrations/m041_dingtalk_card_autolayout_config.py",
    "content": "from __future__ import annotations\n\nfrom .. import migration\n\n\n@migration.migration_class('dingtalk_card_auto_layout', 41)\nclass DingTalkCardAutoLayoutMigration(migration.Migration):\n    \"\"\"迁移\"\"\"\n\n    async def need_migrate(self) -> bool:\n        \"\"\"判断当前环境是否需要运行此迁移\"\"\"\n        return True\n\n    async def run(self):\n        \"\"\"执行迁移\"\"\"\n        self.ap.platform_cfg.data['platform-adapters']['app']['dingtalk']['card_auto_layout'] = False\n        await self.ap.platform_cfg.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/note.py",
    "content": "from __future__ import annotations\n\nimport abc\nimport typing\n\nfrom . import app\n\npreregistered_notes: list[typing.Type[LaunchNote]] = []\n\n\ndef note_class(name: str, number: int):\n    \"\"\"Register a launch information\"\"\"\n\n    def decorator(cls: typing.Type[LaunchNote]) -> typing.Type[LaunchNote]:\n        cls.name = name\n        cls.number = number\n        preregistered_notes.append(cls)\n        return cls\n\n    return decorator\n\n\nclass LaunchNote(abc.ABC):\n    \"\"\"Launch information\"\"\"\n\n    name: str\n\n    number: int\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    @abc.abstractmethod\n    async def need_show(self) -> bool:\n        \"\"\"Determine if the current environment needs to display this launch information\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]:\n        \"\"\"Generate launch information\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/core/notes/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/core/notes/n001_classic_msgs.py",
    "content": "from __future__ import annotations\n\nimport typing\n\nfrom .. import note\n\n\n@note.note_class('ClassicNotes', 1)\nclass ClassicNotes(note.LaunchNote):\n    \"\"\"Classic launch information\"\"\"\n\n    async def need_show(self) -> bool:\n        return True\n\n    async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]:\n        yield await self.ap.ver_mgr.show_version_update()\n"
  },
  {
    "path": "src/langbot/pkg/core/notes/n002_selection_mode_on_windows.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport os\nimport logging\n\nfrom .. import note\n\n\n@note.note_class('SelectionModeOnWindows', 2)\nclass SelectionModeOnWindows(note.LaunchNote):\n    \"\"\"Selection mode prompt information on Windows\"\"\"\n\n    async def need_show(self) -> bool:\n        return os.name == 'nt'\n\n    async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]:\n        yield (\n            \"\"\"您正在使用 Windows 系统，若窗口左上角显示处于”选择“模式，程序将被暂停运行，此时请右键窗口中空白区域退出选择模式。\"\"\",\n            logging.INFO,\n        )\n\n        yield (\n            \"\"\"You are using Windows system, if the top left corner of the window displays \"Selection\" mode, the program will be paused running, please right-click on the blank area in the window to exit the selection mode.\"\"\",\n            logging.INFO,\n        )\n"
  },
  {
    "path": "src/langbot/pkg/core/notes/n003_print_version.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport logging\n\nfrom .. import note\n\n\n@note.note_class('PrintVersion', 3)\nclass PrintVersion(note.LaunchNote):\n    \"\"\"Print Version Information\"\"\"\n\n    async def need_show(self) -> bool:\n        return True\n\n    async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]:\n        yield f'Current Version: {self.ap.ver_mgr.get_current_version()}', logging.INFO\n"
  },
  {
    "path": "src/langbot/pkg/core/stage.py",
    "content": "from __future__ import annotations\n\nimport abc\nimport typing\n\nfrom . import app\n\n\npreregistered_stages: dict[str, typing.Type[BootingStage]] = {}\n\"\"\"Pre-registered request processing stages. All request processing stage classes are registered in this dictionary during initialization.\n\nCurrently not supported for extension\n\"\"\"\n\n\ndef stage_class(name: str):\n    def decorator(cls: typing.Type[BootingStage]) -> typing.Type[BootingStage]:\n        preregistered_stages[name] = cls\n        return cls\n\n    return decorator\n\n\nclass BootingStage(abc.ABC):\n    \"\"\"Booting stage\"\"\"\n\n    name: str = None\n\n    @abc.abstractmethod\n    async def run(self, ap: app.Application):\n        \"\"\"Run\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/core/stages/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/core/stages/build_app.py",
    "content": "from __future__ import annotations\n\nimport asyncio\n\nfrom .. import stage, app\nfrom ...utils import version, proxy\nfrom ...pipeline import pool, controller, pipelinemgr\nfrom ...pipeline import aggregator as message_aggregator\nfrom ...plugin import connector as plugin_connector\nfrom ...command import cmdmgr\nfrom ...provider.session import sessionmgr as llm_session_mgr\nfrom ...provider.modelmgr import modelmgr as llm_model_mgr\nfrom ...provider.tools import toolmgr as llm_tool_mgr\nfrom ...rag.knowledge import kbmgr as rag_mgr\nfrom ...rag.service import RAGRuntimeService\nfrom ...platform import botmgr as im_mgr\nfrom ...platform.webhook_pusher import WebhookPusher\nfrom ...persistence import mgr as persistencemgr\nfrom ...api.http.controller import main as http_controller\nfrom ...api.http.service import user as user_service\nfrom ...api.http.service import space as space_service\nfrom ...api.http.service import model as model_service\nfrom ...api.http.service import provider as provider_service\nfrom ...api.http.service import pipeline as pipeline_service\nfrom ...api.http.service import bot as bot_service\nfrom ...api.http.service import knowledge as knowledge_service\nfrom ...api.http.service import mcp as mcp_service\nfrom ...api.http.service import apikey as apikey_service\nfrom ...api.http.service import webhook as webhook_service\nfrom ...api.http.service import monitoring as monitoring_service\nfrom ...discover import engine as discover_engine\nfrom ...storage import mgr as storagemgr\nfrom ...utils import logcache\nfrom ...vector import mgr as vectordb_mgr\nfrom .. import taskmgr\nfrom ...telemetry import telemetry as telemetry_module\nfrom ...survey import manager as survey_module\n\n\n@stage.stage_class('BuildAppStage')\nclass BuildAppStage(stage.BootingStage):\n    \"\"\"Build LangBot application\"\"\"\n\n    async def run(self, ap: app.Application):\n        \"\"\"Build LangBot application\"\"\"\n        ap.task_mgr = taskmgr.AsyncTaskManager(ap)\n\n        discover = discover_engine.ComponentDiscoveryEngine(ap)\n        discover.discover_blueprint('templates/components.yaml')\n        ap.discover = discover\n\n        user_service_inst = user_service.UserService(ap)\n        ap.user_service = user_service_inst\n\n        space_service_inst = space_service.SpaceService(ap)\n        ap.space_service = space_service_inst\n\n        llm_model_service_inst = model_service.LLMModelsService(ap)\n        ap.llm_model_service = llm_model_service_inst\n\n        embedding_models_service_inst = model_service.EmbeddingModelsService(ap)\n        ap.embedding_models_service = embedding_models_service_inst\n\n        provider_service_inst = provider_service.ModelProviderService(ap)\n        ap.provider_service = provider_service_inst\n\n        pipeline_service_inst = pipeline_service.PipelineService(ap)\n        ap.pipeline_service = pipeline_service_inst\n\n        bot_service_inst = bot_service.BotService(ap)\n        ap.bot_service = bot_service_inst\n\n        knowledge_service_inst = knowledge_service.KnowledgeService(ap)\n        ap.knowledge_service = knowledge_service_inst\n\n        mcp_service_inst = mcp_service.MCPService(ap)\n        ap.mcp_service = mcp_service_inst\n\n        apikey_service_inst = apikey_service.ApiKeyService(ap)\n        ap.apikey_service = apikey_service_inst\n\n        webhook_service_inst = webhook_service.WebhookService(ap)\n        ap.webhook_service = webhook_service_inst\n\n        proxy_mgr = proxy.ProxyManager(ap)\n        await proxy_mgr.initialize()\n        ap.proxy_mgr = proxy_mgr\n\n        ver_mgr = version.VersionManager(ap)\n        await ver_mgr.initialize()\n        ap.ver_mgr = ver_mgr\n\n        ap.query_pool = pool.QueryPool()\n\n        log_cache = logcache.LogCache()\n        ap.log_cache = log_cache\n\n        storage_mgr_inst = storagemgr.StorageMgr(ap)\n        await storage_mgr_inst.initialize()\n        ap.storage_mgr = storage_mgr_inst\n\n        persistence_mgr_inst = persistencemgr.PersistenceManager(ap)\n        ap.persistence_mgr = persistence_mgr_inst\n        await persistence_mgr_inst.initialize()\n\n        # Telemetry manager: attach to app so other components can call via self.ap.telemetry\n        telemetry_inst = telemetry_module.TelemetryManager(ap)\n        await telemetry_inst.initialize()\n        ap.telemetry = telemetry_inst\n\n        # Survey manager\n        survey_inst = survey_module.SurveyManager(ap)\n        await survey_inst.initialize()\n        ap.survey = survey_inst\n\n        cmd_mgr_inst = cmdmgr.CommandManager(ap)\n        await cmd_mgr_inst.initialize()\n        ap.cmd_mgr = cmd_mgr_inst\n\n        llm_model_mgr_inst = llm_model_mgr.ModelManager(ap)\n        ap.model_mgr = llm_model_mgr_inst\n        await llm_model_mgr_inst.initialize()\n\n        llm_session_mgr_inst = llm_session_mgr.SessionManager(ap)\n        await llm_session_mgr_inst.initialize()\n        ap.sess_mgr = llm_session_mgr_inst\n\n        llm_tool_mgr_inst = llm_tool_mgr.ToolManager(ap)\n        await llm_tool_mgr_inst.initialize()\n        ap.tool_mgr = llm_tool_mgr_inst\n\n        im_mgr_inst = im_mgr.PlatformManager(ap=ap)\n        await im_mgr_inst.initialize()\n        ap.platform_mgr = im_mgr_inst\n\n        # Initialize webhook pusher\n        webhook_pusher_inst = WebhookPusher(ap)\n        ap.webhook_pusher = webhook_pusher_inst\n\n        pipeline_mgr = pipelinemgr.PipelineManager(ap)\n        await pipeline_mgr.initialize()\n        ap.pipeline_mgr = pipeline_mgr\n\n        # Initialize message aggregator (after pipeline_mgr, as it needs pipeline config)\n        msg_aggregator_inst = message_aggregator.MessageAggregator(ap)\n        ap.msg_aggregator = msg_aggregator_inst\n\n        rag_mgr_inst = rag_mgr.RAGManager(ap)\n        await rag_mgr_inst.initialize()\n        ap.rag_mgr = rag_mgr_inst\n\n        # Initialize RAG Runtime Service for plugins\n        ap.rag_runtime_service = RAGRuntimeService(ap)\n\n        # 初始化向量数据库管理器\n        vectordb_mgr_inst = vectordb_mgr.VectorDBManager(ap)\n        await vectordb_mgr_inst.initialize()\n        ap.vector_db_mgr = vectordb_mgr_inst\n\n        http_ctrl = http_controller.HTTPController(ap)\n        await http_ctrl.initialize()\n        ap.http_ctrl = http_ctrl\n\n        monitoring_service_inst = monitoring_service.MonitoringService(ap)\n        ap.monitoring_service = monitoring_service_inst\n\n        async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:\n            await asyncio.sleep(3)\n            await plugin_connector_inst.initialize()\n\n        plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)\n        await plugin_connector_inst.initialize()\n        ap.plugin_connector = plugin_connector_inst\n\n        ctrl = controller.Controller(ap)\n        ap.ctrl = ctrl\n"
  },
  {
    "path": "src/langbot/pkg/core/stages/genkeys.py",
    "content": "from __future__ import annotations\n\nimport secrets\n\nfrom .. import stage, app\n\n\n@stage.stage_class('GenKeysStage')\nclass GenKeysStage(stage.BootingStage):\n    \"\"\"Generate keys stage\"\"\"\n\n    async def run(self, ap: app.Application):\n        \"\"\"Generate keys\"\"\"\n\n        if not ap.instance_config.data['system']['jwt']['secret']:\n            ap.instance_config.data['system']['jwt']['secret'] = secrets.token_hex(16)\n            await ap.instance_config.dump_config()\n\n        if 'recovery_key' not in ap.instance_config.data['system']:\n            ap.instance_config.data['system']['recovery_key'] = ''\n\n        if not ap.instance_config.data['system']['recovery_key']:\n            ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()\n            await ap.instance_config.dump_config()\n"
  },
  {
    "path": "src/langbot/pkg/core/stages/load_config.py",
    "content": "from __future__ import annotations\n\nimport os\nfrom typing import Any\nfrom langbot.pkg.utils import constants\nimport yaml\nimport importlib.resources as resources\nimport uuid\nimport time\n\nfrom .. import stage, app\nfrom ..bootutils import config\n\n\ndef _apply_env_overrides_to_config(cfg: dict) -> dict:\n    \"\"\"Apply environment variable overrides to data/config.yaml\n\n    Environment variables should be uppercase and use __ (double underscore)\n    to represent nested keys. For example:\n    - CONCURRENCY__PIPELINE overrides concurrency.pipeline\n    - PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url\n\n    Arrays and dict types are ignored.\n\n    Args:\n        cfg: Configuration dictionary\n\n    Returns:\n        Updated configuration dictionary\n    \"\"\"\n\n    def convert_value(value: str, original_value: Any) -> Any:\n        \"\"\"Convert string value to appropriate type based on original value\n\n        Args:\n            value: String value from environment variable\n            original_value: Original value to infer type from\n\n        Returns:\n            Converted value (falls back to string if conversion fails)\n        \"\"\"\n        if isinstance(original_value, bool):\n            return value.lower() in ('true', '1', 'yes', 'on')\n        elif isinstance(original_value, int):\n            try:\n                return int(value)\n            except ValueError:\n                # If conversion fails, keep as string (user error, but non-breaking)\n                return value\n        elif isinstance(original_value, float):\n            try:\n                return float(value)\n            except ValueError:\n                # If conversion fails, keep as string (user error, but non-breaking)\n                return value\n        else:\n            return value\n\n    # Process environment variables\n    for env_key, env_value in os.environ.items():\n        # Check if the environment variable is uppercase and contains __\n        if not env_key.isupper():\n            continue\n        if '__' not in env_key:\n            continue\n\n        print(f'apply env overrides to config: env_key: {env_key}, env_value: {env_value}')\n\n        # Convert environment variable name to config path\n        # e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']\n        keys = [key.lower() for key in env_key.split('__')]\n\n        # Navigate to the target value and validate the path\n        current = cfg\n\n        for i, key in enumerate(keys):\n            if not isinstance(current, dict):\n                break\n\n            if i == len(keys) - 1:\n                # At the final key\n                if key in current:\n                    if isinstance(current[key], (dict, list)):\n                        # Skip dict and list types\n                        pass\n                    else:\n                        # Valid scalar value - convert and set it\n                        converted_value = convert_value(env_value, current[key])\n                        current[key] = converted_value\n                else:\n                    # Key doesn't exist yet - create it as string\n                    current[key] = env_value\n            else:\n                # Navigate deeper - create intermediate dict if needed\n                if key not in current:\n                    current[key] = {}\n                current = current[key]\n\n    return cfg\n\n\n@stage.stage_class('LoadConfigStage')\nclass LoadConfigStage(stage.BootingStage):\n    \"\"\"Load config file stage\"\"\"\n\n    async def run(self, ap: app.Application):\n        \"\"\"Load config file\"\"\"\n\n        # # ======= deprecated =======\n        # if os.path.exists('data/config/command.json'):\n        #     ap.command_cfg = await config.load_json_config(\n        #         'data/config/command.json',\n        #         'templates/legacy/command.json',\n        #         completion=False,\n        #     )\n\n        # if os.path.exists('data/config/pipeline.json'):\n        #     ap.pipeline_cfg = await config.load_json_config(\n        #         'data/config/pipeline.json',\n        #         'templates/legacy/pipeline.json',\n        #         completion=False,\n        #     )\n\n        # if os.path.exists('data/config/platform.json'):\n        #     ap.platform_cfg = await config.load_json_config(\n        #         'data/config/platform.json',\n        #         'templates/legacy/platform.json',\n        #         completion=False,\n        #     )\n\n        # if os.path.exists('data/config/provider.json'):\n        #     ap.provider_cfg = await config.load_json_config(\n        #         'data/config/provider.json',\n        #         'templates/legacy/provider.json',\n        #         completion=False,\n        #     )\n\n        # if os.path.exists('data/config/system.json'):\n        #     ap.system_cfg = await config.load_json_config(\n        #         'data/config/system.json',\n        #         'templates/legacy/system.json',\n        #         completion=False,\n        #     )\n\n        # # ======= deprecated =======\n\n        ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False)\n\n        # Apply environment variable overrides to data/config.yaml\n        ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data)\n\n        await ap.instance_config.dump_config()\n\n        # load or generate instance id\n        # Priority:\n        # 1. system.instance_id from config.yaml (can be set via SYSTEM__INSTANCE_ID env var)\n        # 2. data/labels/instance_id.json (if file exists)\n        # 3. Generate new and save to file\n        config_instance_id = ap.instance_config.data.get('system', {}).get('instance_id', '')\n\n        if config_instance_id:\n            # Use the instance_id from config.yaml\n            constants.instance_id = config_instance_id\n            # Still load/create the file for backward compat, but don't use its value\n            ap.instance_id = await config.load_json_config(\n                'data/labels/instance_id.json',\n                template_data={\n                    'instance_id': f'instance_{str(uuid.uuid4())}',\n                    'instance_create_ts': int(time.time()),\n                },\n                completion=False,\n            )\n        else:\n            # Try loading file-based instance id\n            instance_id_path = os.path.join('data', 'labels', 'instance_id.json')\n            if os.path.exists(instance_id_path):\n                # File exists, read it\n                ap.instance_id = await config.load_json_config(\n                    'data/labels/instance_id.json',\n                    template_data={\n                        'instance_id': '',\n                        'instance_create_ts': 0,\n                    },\n                    completion=False,\n                )\n                constants.instance_id = ap.instance_id.data['instance_id']\n            else:\n                # Neither config nor file, generate new and save to file\n                new_id = f'instance_{str(uuid.uuid4())}'\n                ap.instance_id = await config.load_json_config(\n                    'data/labels/instance_id.json',\n                    template_data={\n                        'instance_id': new_id,\n                        'instance_create_ts': int(time.time()),\n                    },\n                    completion=False,\n                )\n                constants.instance_id = new_id\n        constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')\n\n        print(f'LangBot instance id: {constants.instance_id}')\n        print(f'LangBot edition: {constants.edition}')\n\n        await ap.instance_id.dump_config()\n\n        ap.sensitive_meta = await config.load_json_config(\n            'data/metadata/sensitive-words.json',\n            'metadata/sensitive-words.json',\n        )\n        await ap.sensitive_meta.dump_config()\n\n        async def load_resource_yaml_template_data(resource_name: str) -> dict:\n            with resources.files('langbot.templates').joinpath(resource_name).open('r', encoding='utf-8') as f:\n                return yaml.load(f, Loader=yaml.FullLoader)\n\n        ap.pipeline_config_meta_trigger = await load_resource_yaml_template_data('metadata/pipeline/trigger.yaml')\n        ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml')\n        ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml')\n        ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml')\n"
  },
  {
    "path": "src/langbot/pkg/core/stages/migrate.py",
    "content": "from __future__ import annotations\n\n\nfrom .. import stage, app\nfrom .. import migration\nfrom ...utils import importutil\nfrom .. import migrations\n\nimportutil.import_modules_in_pkg(migrations)\n\n\n@stage.stage_class('MigrationStage')\nclass MigrationStage(stage.BootingStage):\n    \"\"\"Migration stage\n\n    These migrations are legacy, only performed in version 3.x\n    \"\"\"\n\n    async def run(self, ap: app.Application):\n        \"\"\"Run migration\"\"\"\n\n        if any(\n            [\n                ap.command_cfg is None,\n                ap.pipeline_cfg is None,\n                ap.platform_cfg is None,\n                ap.provider_cfg is None,\n                ap.system_cfg is None,\n            ]\n        ):  # only run migration when version is 3.x\n            return\n\n        migrations = migration.preregistered_migrations\n\n        # Sort by migration number\n        migrations.sort(key=lambda x: x.number)\n\n        for migration_cls in migrations:\n            migration_instance = migration_cls(ap)\n\n            if await migration_instance.need_migrate():\n                await migration_instance.run()\n                print(f'Migration {migration_instance.name} executed')\n"
  },
  {
    "path": "src/langbot/pkg/core/stages/setup_logger.py",
    "content": "from __future__ import annotations\n\nimport logging\n\nfrom .. import stage, app\nfrom ..bootutils import log\n\n\nclass PersistenceHandler(logging.Handler, object):\n    \"\"\"\n    Save logs to database\n    \"\"\"\n\n    ap: app.Application\n\n    def __init__(self, name, ap: app.Application):\n        logging.Handler.__init__(self)\n        self.ap = ap\n\n    def emit(self, record):\n        \"\"\"\n        emit function is a required function for custom handler classes, here you can process the log messages as needed, such as sending logs to the server\n\n        Emit a record\n        \"\"\"\n        try:\n            msg = self.format(record)\n            if self.ap.log_cache is not None:\n                self.ap.log_cache.add_log(msg)\n\n        except Exception:\n            self.handleError(record)\n\n\n@stage.stage_class('SetupLoggerStage')\nclass SetupLoggerStage(stage.BootingStage):\n    \"\"\"Setup logger stage\"\"\"\n\n    async def run(self, ap: app.Application):\n        \"\"\"Setup logger\"\"\"\n        persistence_handler = PersistenceHandler('LoggerHandler', ap)\n\n        extra_handlers = []\n        extra_handlers = [persistence_handler]\n\n        ap.logger = await log.init_logging(extra_handlers)\n"
  },
  {
    "path": "src/langbot/pkg/core/stages/show_notes.py",
    "content": "from __future__ import annotations\n\nimport asyncio\n\nfrom .. import stage, app, note\nfrom ...utils import importutil\n\nfrom .. import notes\n\nimportutil.import_modules_in_pkg(notes)\n\n\n@stage.stage_class('ShowNotesStage')\nclass ShowNotesStage(stage.BootingStage):\n    \"\"\"Show notes stage\"\"\"\n\n    async def run(self, ap: app.Application):\n        # Sort\n        note.preregistered_notes.sort(key=lambda x: x.number)\n\n        for note_cls in note.preregistered_notes:\n            try:\n                note_inst = note_cls(ap)\n                if await note_inst.need_show():\n\n                    async def ayield_note(note_inst: note.LaunchNote):\n                        async for ret in note_inst.yield_note():\n                            if not ret:\n                                continue\n                            msg, level = ret\n                            if msg:\n                                ap.logger.log(level, msg)\n\n                    asyncio.create_task(ayield_note(note_inst))\n            except Exception:\n                continue\n"
  },
  {
    "path": "src/langbot/pkg/core/taskmgr.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport typing\nimport datetime\n\nfrom . import app\nfrom . import entities as core_entities\n\n\nclass TaskContext:\n    \"\"\"Task tracking context\"\"\"\n\n    current_action: str\n    \"\"\"Current action being executed\"\"\"\n\n    log: str\n    \"\"\"Log\"\"\"\n\n    def __init__(self):\n        self.current_action = 'default'\n        self.log = ''\n\n    def _log(self, msg: str):\n        self.log += msg + '\\n'\n\n    def set_current_action(self, action: str):\n        self.current_action = action\n\n    def trace(\n        self,\n        msg: str,\n        action: str = None,\n    ):\n        if action is not None:\n            self.set_current_action(action)\n\n        self._log(f'{datetime.datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")} | {self.current_action} | {msg}')\n\n    def to_dict(self) -> dict:\n        return {'current_action': self.current_action, 'log': self.log}\n\n    @staticmethod\n    def new() -> TaskContext:\n        return TaskContext()\n\n    @staticmethod\n    def placeholder() -> TaskContext:\n        global placeholder_context\n\n        if placeholder_context is None:\n            placeholder_context = TaskContext()\n\n        return placeholder_context\n\n\nplaceholder_context: TaskContext | None = None\n\n\nclass TaskWrapper:\n    \"\"\"Task wrapper\"\"\"\n\n    _id_index: int = 0\n    \"\"\"Task ID index\"\"\"\n\n    id: int\n    \"\"\"Task ID\"\"\"\n\n    task_type: str = 'system'  # Task type: system or user\n    \"\"\"Task type\"\"\"\n\n    kind: str = 'system_task'  # Task type determined by the initiator, usually the same task type\n    \"\"\"Task type\"\"\"\n\n    name: str = ''\n    \"\"\"Task unique name\"\"\"\n\n    label: str = ''\n    \"\"\"Task display name\"\"\"\n\n    task_context: TaskContext\n    \"\"\"Task context\"\"\"\n\n    task: asyncio.Task\n    \"\"\"Task\"\"\"\n\n    task_stack: list = None\n    \"\"\"Task stack\"\"\"\n\n    ap: app.Application\n    \"\"\"Application instance\"\"\"\n\n    scopes: list[core_entities.LifecycleControlScope]\n    \"\"\"Task scope\"\"\"\n\n    def __init__(\n        self,\n        ap: app.Application,\n        coro: typing.Coroutine,\n        task_type: str = 'system',\n        kind: str = 'system_task',\n        name: str = '',\n        label: str = '',\n        context: TaskContext = None,\n        scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],\n    ):\n        self.id = TaskWrapper._id_index\n        TaskWrapper._id_index += 1\n        self.ap = ap\n        self.task_context = context or TaskContext()\n        self.task = self.ap.event_loop.create_task(coro)\n        self.task_type = task_type\n        self.kind = kind\n        self.name = name\n        self.label = label if label != '' else name\n        self.task.set_name(name)\n        self.scopes = scopes\n\n    def assume_exception(self):\n        try:\n            exception = self.task.exception()\n            if self.task_stack is None:\n                self.task_stack = self.task.get_stack()\n            return exception\n        except Exception:\n            return None\n\n    def assume_result(self):\n        try:\n            return self.task.result()\n        except Exception:\n            return None\n\n    def to_dict(self) -> dict:\n        exception_traceback = None\n        if self.assume_exception() is not None:\n            exception_traceback = 'Traceback (most recent call last):\\n'\n\n            for frame in self.task_stack:\n                exception_traceback += (\n                    f'  File \"{frame.f_code.co_filename}\", line {frame.f_lineno}, in {frame.f_code.co_name}\\n'\n                )\n\n            exception_traceback += f'    {self.assume_exception().__str__()}\\n'\n\n        return {\n            'id': self.id,\n            'task_type': self.task_type,\n            'kind': self.kind,\n            'name': self.name,\n            'label': self.label,\n            'scopes': [scope.value for scope in self.scopes],\n            'task_context': self.task_context.to_dict(),\n            'runtime': {\n                'done': self.task.done(),\n                'state': self.task._state,\n                'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None,\n                'exception_traceback': exception_traceback,\n                'result': self.assume_result() if self.assume_result() is not None else None,\n            },\n        }\n\n    def cancel(self):\n        self.task.cancel()\n\n\nclass AsyncTaskManager:\n    \"\"\"Save all asynchronous tasks in the app\n    Include system-level and user-level (plugin installation, update, etc. initiated by users directly)\"\"\"\n\n    ap: app.Application\n\n    tasks: list[TaskWrapper]\n    \"\"\"All tasks\"\"\"\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        self.tasks = []\n\n    def create_task(\n        self,\n        coro: typing.Coroutine,\n        task_type: str = 'system',\n        kind: str = 'system-task',\n        name: str = '',\n        label: str = '',\n        context: TaskContext = None,\n        scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],\n    ) -> TaskWrapper:\n        wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)\n        self.tasks.append(wrapper)\n        return wrapper\n\n    def create_user_task(\n        self,\n        coro: typing.Coroutine,\n        kind: str = 'user-task',\n        name: str = '',\n        label: str = '',\n        context: TaskContext = None,\n        scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],\n    ) -> TaskWrapper:\n        return self.create_task(coro, 'user', kind, name, label, context, scopes)\n\n    async def wait_all(self):\n        await asyncio.gather(*[t.task for t in self.tasks], return_exceptions=True)\n\n    def get_all_tasks(self) -> list[TaskWrapper]:\n        return self.tasks\n\n    def get_tasks_dict(\n        self,\n        type: str = None,\n    ) -> dict:\n        return {\n            'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type],\n            'id_index': TaskWrapper._id_index,\n        }\n\n    def get_task_by_id(self, id: int) -> TaskWrapper | None:\n        for t in self.tasks:\n            if t.id == id:\n                return t\n        return None\n\n    def cancel_by_scope(self, scope: core_entities.LifecycleControlScope):\n        for wrapper in self.tasks:\n            if not wrapper.task.done() and scope in wrapper.scopes:\n                wrapper.task.cancel()\n\n    def cancel_task(self, task_id: int):\n        for wrapper in self.tasks:\n            if wrapper.id == task_id:\n                if not wrapper.task.done():\n                    wrapper.task.cancel()\n                return\n"
  },
  {
    "path": "src/langbot/pkg/discover/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/discover/engine.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport importlib\nimport os\nimport yaml\nimport pydantic\n\nfrom langbot.pkg.core import app\nfrom langbot.pkg.utils import importutil\n\n\nclass I18nString(pydantic.BaseModel):\n    \"\"\"国际化字符串\"\"\"\n\n    en_US: str\n    \"\"\"英文\"\"\"\n\n    zh_Hans: typing.Optional[str] = None\n    \"\"\"中文\"\"\"\n\n    ja_JP: typing.Optional[str] = None\n    \"\"\"日文\"\"\"\n\n    def to_dict(self) -> dict:\n        \"\"\"转换为字典\"\"\"\n        dic = {}\n        if self.en_US is not None:\n            dic['en_US'] = self.en_US\n        if self.zh_Hans is not None:\n            dic['zh_Hans'] = self.zh_Hans\n        if self.ja_JP is not None:\n            dic['ja_JP'] = self.ja_JP\n        return dic\n\n\nclass Metadata(pydantic.BaseModel):\n    \"\"\"元数据\"\"\"\n\n    name: str\n    \"\"\"名称\"\"\"\n\n    label: I18nString\n    \"\"\"标签\"\"\"\n\n    description: typing.Optional[I18nString] = None\n    \"\"\"描述\"\"\"\n\n    version: typing.Optional[str] = None\n    \"\"\"版本\"\"\"\n\n    icon: typing.Optional[str] = None\n    \"\"\"图标\"\"\"\n\n    author: typing.Optional[str] = None\n    \"\"\"作者\"\"\"\n\n    repository: typing.Optional[str] = None\n    \"\"\"仓库\"\"\"\n\n    def __init__(self, **kwargs):\n        super().__init__(**kwargs)\n\n        if self.description is None:\n            self.description = I18nString(en_US='')\n\n        if self.icon is None:\n            self.icon = ''\n\n\nclass PythonExecution(pydantic.BaseModel):\n    \"\"\"Python执行\"\"\"\n\n    path: str\n    \"\"\"路径\"\"\"\n\n    attr: str\n    \"\"\"属性\"\"\"\n\n    def __init__(self, **kwargs):\n        super().__init__(**kwargs)\n\n        if self.path.startswith('./'):\n            self.path = self.path[2:]\n\n\nclass Execution(pydantic.BaseModel):\n    \"\"\"执行\"\"\"\n\n    python: PythonExecution\n    \"\"\"Python执行\"\"\"\n\n\nclass Component(pydantic.BaseModel):\n    \"\"\"组件清单\"\"\"\n\n    owner: str\n    \"\"\"组件所属\"\"\"\n\n    manifest: typing.Dict[str, typing.Any]\n    \"\"\"组件清单内容\"\"\"\n\n    rel_path: str\n    \"\"\"组件清单相对main.py的路径\"\"\"\n\n    rel_dir: str\n    \"\"\"组件清单相对main.py的目录\"\"\"\n\n    _metadata: Metadata\n    \"\"\"组件元数据\"\"\"\n\n    _spec: typing.Dict[str, typing.Any]\n    \"\"\"组件规格\"\"\"\n\n    _execution: Execution\n    \"\"\"组件执行\"\"\"\n\n    def __init__(self, owner: str, manifest: typing.Dict[str, typing.Any], rel_path: str):\n        super().__init__(\n            owner=owner,\n            manifest=manifest,\n            rel_path=rel_path,\n            rel_dir=os.path.dirname(rel_path),\n        )\n        self._metadata = Metadata(**manifest['metadata'])\n        self._spec = manifest['spec']\n        self._execution = Execution(**manifest['execution']) if 'execution' in manifest else None\n\n    @classmethod\n    def is_component_manifest(cls, manifest: typing.Dict[str, typing.Any]) -> bool:\n        \"\"\"判断是否为组件清单\"\"\"\n        return 'apiVersion' in manifest and 'kind' in manifest and 'metadata' in manifest and 'spec' in manifest\n\n    @property\n    def kind(self) -> str:\n        \"\"\"组件类型\"\"\"\n        return self.manifest['kind']\n\n    @property\n    def metadata(self) -> Metadata:\n        \"\"\"组件元数据\"\"\"\n        return self._metadata\n\n    @property\n    def spec(self) -> typing.Dict[str, typing.Any]:\n        \"\"\"组件规格\"\"\"\n        return self._spec\n\n    @property\n    def execution(self) -> Execution:\n        \"\"\"组件可执行文件信息\"\"\"\n        return self._execution\n\n    @property\n    def icon_rel_path(self) -> str:\n        \"\"\"图标相对路径\"\"\"\n        return (\n            os.path.join(self.rel_dir, self.metadata.icon)\n            if self.metadata.icon is not None and self.metadata.icon.strip() != ''\n            else None\n        )\n\n    def get_python_component_class(self) -> typing.Type[typing.Any]:\n        \"\"\"获取Python组件类\"\"\"\n        module_path = os.path.join(self.rel_dir, self.execution.python.path)\n        if module_path.endswith('.py'):\n            module_path = module_path[:-3]\n        module_path = module_path.replace('/', '.').replace('\\\\', '.')\n        module = importlib.import_module(f'langbot.{module_path}')\n        return getattr(module, self.execution.python.attr)\n\n    def to_plain_dict(self) -> dict:\n        \"\"\"转换为平铺字典\"\"\"\n        return {\n            'name': self.metadata.name,\n            'label': self.metadata.label.to_dict(),\n            'description': self.metadata.description.to_dict(),\n            'icon': self.metadata.icon,\n            'spec': self.spec,\n        }\n\n\nclass ComponentDiscoveryEngine:\n    \"\"\"组件发现引擎\"\"\"\n\n    ap: app.Application\n    \"\"\"应用实例\"\"\"\n\n    components: typing.Dict[str, typing.List[Component]] = {}\n    \"\"\"组件列表\"\"\"\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    def load_component_manifest(self, path: str, owner: str = 'builtin', no_save: bool = False) -> Component | None:\n        \"\"\"加载组件清单\"\"\"\n        # with open(path, 'r', encoding='utf-8') as f:\n        #     manifest = yaml.safe_load(f)\n        manifest = yaml.safe_load(importutil.read_resource_file(path))\n        if not Component.is_component_manifest(manifest):\n            return None\n        comp = Component(owner=owner, manifest=manifest, rel_path=path)\n        if not no_save:\n            if comp.kind not in self.components:\n                self.components[comp.kind] = []\n            self.components[comp.kind].append(comp)\n        return comp\n\n    def load_component_manifests_in_dir(\n        self,\n        path: str,\n        owner: str = 'builtin',\n        no_save: bool = False,\n        max_depth: int = 1,\n    ) -> typing.List[Component]:\n        \"\"\"加载目录中的组件清单\"\"\"\n        components: typing.List[Component] = []\n\n        def recursive_load_component_manifests_in_dir(path: str, depth: int = 1):\n            if depth > max_depth:\n                return\n\n            for file in importutil.list_resource_files(path):\n                if (not os.path.isdir(os.path.join(path, file))) and (file.endswith('.yaml') or file.endswith('.yml')):\n                    comp = self.load_component_manifest(os.path.join(path, file), owner, no_save)\n                    if comp is not None:\n                        components.append(comp)\n                elif os.path.isdir(os.path.join(path, file)):\n                    recursive_load_component_manifests_in_dir(os.path.join(path, file), depth + 1)\n\n        recursive_load_component_manifests_in_dir(path)\n        return components\n\n    def load_blueprint_comp_group(\n        self, group: dict, owner: str = 'builtin', no_save: bool = False\n    ) -> typing.List[Component]:\n        \"\"\"加载蓝图组件组\"\"\"\n        components: typing.List[Component] = []\n        if 'fromFiles' in group:\n            for file in group['fromFiles']:\n                comp = self.load_component_manifest(file, owner, no_save)\n                if comp is not None:\n                    components.append(comp)\n        if 'fromDirs' in group:\n            for dir in group['fromDirs']:\n                path = dir['path']\n                max_depth = dir['maxDepth'] if 'maxDepth' in dir else 1\n                components.extend(self.load_component_manifests_in_dir(path, owner, no_save, max_depth))\n        return components\n\n    def discover_blueprint(self, blueprint_manifest_path: str, owner: str = 'builtin'):\n        \"\"\"发现蓝图\"\"\"\n        blueprint_manifest = self.load_component_manifest(blueprint_manifest_path, owner, no_save=True)\n        if blueprint_manifest is None:\n            raise ValueError(f'Invalid blueprint manifest: {blueprint_manifest_path}')\n        assert blueprint_manifest.kind == 'Blueprint', '`Kind` must be `Blueprint`'\n        components: typing.Dict[str, typing.List[Component]] = {}\n\n        # load ComponentTemplate first\n        if 'ComponentTemplate' in blueprint_manifest.spec['components']:\n            components['ComponentTemplate'] = self.load_blueprint_comp_group(\n                blueprint_manifest.spec['components']['ComponentTemplate'], owner\n            )\n\n        for name, component in blueprint_manifest.spec['components'].items():\n            if name == 'ComponentTemplate':\n                continue\n            components[name] = self.load_blueprint_comp_group(component, owner)\n\n        self.ap.logger.debug(f'Components: {components}')\n\n        return blueprint_manifest, components\n\n    def get_components_by_kind(self, kind: str) -> typing.List[Component]:\n        \"\"\"获取指定类型的组件\"\"\"\n        if kind not in self.components:\n            return []\n        return self.components[kind]\n\n    def find_components(self, kind: str, component_list: typing.List[Component]) -> typing.List[Component]:\n        \"\"\"查找组件\"\"\"\n        result: typing.List[Component] = []\n        for component in component_list:\n            if component.kind == kind:\n                result.append(component)\n        return result\n"
  },
  {
    "path": "src/langbot/pkg/entity/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/entity/dto/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/entity/dto/space_model.py",
    "content": "# [\n#   {\n#     \"uuid\": \"7652ebdb-54dc-412c-a830-e9268ac88471\",\n#     \"model_id\": \"claude-opus-4-5-20251101\",\n#     \"display_name\": {\n#       \"en_US\": \"claude-opus-4-5-20251101\",\n#       \"zh_Hans\": \"claude-opus-4-5-20251101\"\n#     },\n#     \"description\": {},\n#     \"provider\": \"anthropic\",\n#     \"category\": \"chat\",\n#     \"icon_url\": \"Claude.Color\",\n#     \"tags\": {},\n#     \"is_featured\": true,\n#     \"featured_order\": 999,\n#     \"model_ratio\": 2.5,\n#     \"completion_ratio\": 5,\n#     \"quota_type\": 0,\n#     \"model_price\": 0,\n#     \"input_credits\": 500,\n#     \"output_credits\": 2500,\n#     \"vendor_id\": 1,\n#     \"vendor_name\": \"Anthropic\",\n#     \"vendor_icon\": \"Claude.Color\",\n#     \"supported_endpoints\": [\n#       \"anthropic\",\n#       \"openai\"\n#     ],\n#     \"status\": \"active\",\n#     \"metadata\": null,\n#     \"created_at\": \"2025-12-30T22:23:38.337207+08:00\",\n#     \"updated_at\": \"2025-12-30T22:23:38.337207+08:00\"\n#   }\n# ]\n\nimport pydantic\n\n\nclass SpaceModel(pydantic.BaseModel):\n    uuid: str\n    model_id: str\n    provider: str\n    category: str  # chat / embedding\n    llm_abilities: list[str] | None = None\n    is_featured: bool = False\n    featured_order: int = 0\n    status: str\n    created_at: str | None = None\n    updated_at: str | None = None\n"
  },
  {
    "path": "src/langbot/pkg/entity/errors/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/entity/errors/account.py",
    "content": "from __future__ import annotations\n\n\nclass AccountEmailMismatchError(Exception):\n    def __str__(self):\n        return 'Account email mismatch'\n"
  },
  {
    "path": "src/langbot/pkg/entity/errors/platform.py",
    "content": "from __future__ import annotations\n\n\nclass AdapterNotFoundError(Exception):\n    def __init__(self, adapter_name: str):\n        self.adapter_name = adapter_name\n\n    def __str__(self):\n        return f'Adapter {self.adapter_name} not found'\n"
  },
  {
    "path": "src/langbot/pkg/entity/errors/provider.py",
    "content": "from __future__ import annotations\n\n\nclass RequesterNotFoundError(Exception):\n    def __init__(self, requester_name: str):\n        self.requester_name = requester_name\n\n    def __str__(self):\n        return f'Requester {self.requester_name} not found'\n\n\nclass ProviderNotFoundError(Exception):\n    def __init__(self, provider_name: str):\n        self.provider_name = provider_name\n\n    def __str__(self):\n        return f'Provider {self.provider_name} not found'\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/entity/persistence/apikey.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\n\n\nclass ApiKey(Base):\n    \"\"\"API Key for external service authentication\"\"\"\n\n    __tablename__ = 'api_keys'\n\n    id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)\n    name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True)\n    description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/base.py",
    "content": "import sqlalchemy.orm\n\n\nclass Base(sqlalchemy.orm.DeclarativeBase):\n    pass\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/bot.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\n\n\nclass Bot(Base):\n    \"\"\"Bot\"\"\"\n\n    __tablename__ = 'bots'\n\n    uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)\n    name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    adapter = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    adapter_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)\n    enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)\n    use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n    use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/bstorage.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\n\n\nclass BinaryStorage(Base):\n    \"\"\"Current for plugin use only\"\"\"\n\n    __tablename__ = 'binary_storages'\n\n    unique_key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)\n    key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    owner_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    owner = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    value = sqlalchemy.Column(sqlalchemy.LargeBinary, nullable=False)\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/mcp.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\n\n\nclass MCPServer(Base):\n    __tablename__ = 'mcp_servers'\n\n    uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)\n    name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)\n    mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)  # stdio, sse, http\n    extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/metadata.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\nfrom ...utils import constants\n\n\ninitial_metadata = [\n    {\n        'key': 'database_version',\n        'value': str(constants.required_database_version),\n    },\n]\n\n\nclass Metadata(Base):\n    \"\"\"Database metadata\"\"\"\n\n    __tablename__ = 'metadata'\n\n    key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)\n    value = sqlalchemy.Column(sqlalchemy.String(255))\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/model.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\n\n\nclass ModelProvider(Base):\n    \"\"\"Model provider\"\"\"\n\n    __tablename__ = 'model_providers'\n\n    uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)\n    name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    base_url = sqlalchemy.Column(sqlalchemy.String(512), nullable=False)\n    api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n\n\nclass LLMModel(Base):\n    \"\"\"LLM model\"\"\"\n\n    __tablename__ = 'llm_models'\n\n    uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)\n    name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])\n    extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})\n    prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n\n\nclass EmbeddingModel(Base):\n    \"\"\"Embedding model\"\"\"\n\n    __tablename__ = 'embedding_models'\n\n    uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)\n    name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})\n    prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/monitoring.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\n\n\nclass MonitoringMessage(Base):\n    \"\"\"Monitoring message records\"\"\"\n\n    __tablename__ = 'monitoring_messages'\n\n    id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)\n    timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)\n    bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)\n    bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)\n    pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    message_content = sqlalchemy.Column(sqlalchemy.Text, nullable=False)\n    session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)\n    status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)  # success, error, pending\n    level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)  # info, warning, error, debug\n    platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n    user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n    user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)  # User display name\n    runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)  # Runner name for this query\n    variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True)  # Query variables as JSON string\n    role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user')  # user, assistant\n\n\nclass MonitoringLLMCall(Base):\n    \"\"\"LLM call records\"\"\"\n\n    __tablename__ = 'monitoring_llm_calls'\n\n    id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)\n    timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)\n    model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    input_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)\n    output_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)\n    total_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)\n    duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)  # milliseconds\n    cost = sqlalchemy.Column(sqlalchemy.Float, nullable=True)\n    status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)  # success, error\n    bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)\n    bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)\n    pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)\n    message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)  # Associated message ID\n\n\nclass MonitoringSession(Base):\n    \"\"\"Session tracking records\"\"\"\n\n    __tablename__ = 'monitoring_sessions'\n\n    session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)\n    bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)\n    bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)\n    pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    message_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)\n    start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)\n    last_activity = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)\n    is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)\n    platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n    user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n    user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)  # User display name\n\n\nclass MonitoringError(Base):\n    \"\"\"Error log records\"\"\"\n\n    __tablename__ = 'monitoring_errors'\n\n    id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)\n    timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)\n    error_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=False)\n    bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)\n    bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)\n    pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n    stack_trace = sqlalchemy.Column(sqlalchemy.Text, nullable=True)\n    message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)  # Associated message ID\n\n\nclass MonitoringEmbeddingCall(Base):\n    \"\"\"Embedding call records\"\"\"\n\n    __tablename__ = 'monitoring_embedding_calls'\n\n    id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)\n    timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)\n    model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    prompt_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)\n    total_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)\n    duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)  # milliseconds\n    input_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)  # Number of input texts\n    status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)  # success, error\n    error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)\n    # Optional context fields\n    knowledge_base_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)\n    query_text = sqlalchemy.Column(sqlalchemy.Text, nullable=True)  # For retrieval calls\n    session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)\n    message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)\n    call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)  # embedding, retrieve\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/pipeline.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\n\n\nclass LegacyPipeline(Base):\n    \"\"\"Legacy pipeline\"\"\"\n\n    __tablename__ = 'legacy_pipelines'\n\n    uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)\n    name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='⚙️')\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n    for_version = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    is_default = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)\n    stages = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)\n    config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)\n    extensions_preferences = sqlalchemy.Column(\n        sqlalchemy.JSON,\n        nullable=False,\n        default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},\n    )\n\n\nclass PipelineRunRecord(Base):\n    \"\"\"Pipeline run record\"\"\"\n\n    __tablename__ = 'pipeline_run_records'\n\n    uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)\n    pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    status = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n    started_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)\n    finished_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)\n    result = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)\n    knowledge_base_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/plugin.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\n\n\nclass PluginSetting(Base):\n    \"\"\"Plugin setting\"\"\"\n\n    __tablename__ = 'plugin_settings'\n\n    plugin_author = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)\n    plugin_name = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)\n    enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)\n    priority = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)\n    config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict)\n    install_source = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, default='github')\n    install_info = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict)\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/rag.py",
    "content": "import sqlalchemy\nfrom .base import Base\n\n\nclass KnowledgeBase(Base):\n    __tablename__ = 'knowledge_bases'\n    uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)\n    name = sqlalchemy.Column(sqlalchemy.String, index=True)\n    description = sqlalchemy.Column(sqlalchemy.Text)\n    emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='📚')\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now())\n    # New fields for plugin-based RAG\n    knowledge_engine_plugin_id = sqlalchemy.Column(sqlalchemy.String, nullable=True)\n    collection_id = sqlalchemy.Column(sqlalchemy.String, nullable=True)\n    creation_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None)\n    retrieval_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None)\n\n    # Field sets for different operations\n    MUTABLE_FIELDS = {'name', 'description', 'retrieval_settings'}\n    \"\"\"Fields that can be updated after creation.\"\"\"\n\n    CREATE_FIELDS = MUTABLE_FIELDS | {'uuid', 'knowledge_engine_plugin_id', 'collection_id', 'creation_settings'}\n    \"\"\"Fields used when creating a new knowledge base.\"\"\"\n\n    ALL_DB_FIELDS = CREATE_FIELDS | {'emoji', 'created_at', 'updated_at'}\n    \"\"\"All fields stored in database (for loading from DB row).\"\"\"\n\n\nclass File(Base):\n    __tablename__ = 'knowledge_base_files'\n    uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)\n    kb_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n    file_name = sqlalchemy.Column(sqlalchemy.String)\n    extension = sqlalchemy.Column(sqlalchemy.String)\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())\n    status = sqlalchemy.Column(sqlalchemy.String, default='pending')  # pending, processing, completed, failed\n\n\nclass Chunk(Base):\n    __tablename__ = 'knowledge_base_chunks'\n    uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)\n    file_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n    text = sqlalchemy.Column(sqlalchemy.Text)\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/user.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\n\n\nclass User(Base):\n    __tablename__ = 'users'\n\n    id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)\n    user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n\n    # Account type: 'local' (default) or 'space'\n    account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')\n\n    # Space account fields (nullable, only used when account_type='space')\n    space_account_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n    space_access_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)\n    space_refresh_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)\n    space_access_token_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)\n    space_api_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)\n\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/vector.py",
    "content": "from sqlalchemy import Column, Integer, ForeignKey, LargeBinary\nfrom sqlalchemy.orm import declarative_base, relationship\n\nBase = declarative_base()\n\n\nclass Vector(Base):\n    __tablename__ = 'vectors'\n    id = Column(Integer, primary_key=True, index=True)\n    chunk_id = Column(Integer, ForeignKey('chunks.id'), unique=True)\n    embedding = Column(LargeBinary)  # Store embeddings as binary\n\n    chunk = relationship('Chunk', back_populates='vector')\n"
  },
  {
    "path": "src/langbot/pkg/entity/persistence/webhook.py",
    "content": "import sqlalchemy\n\nfrom .base import Base\n\n\nclass Webhook(Base):\n    \"\"\"Webhook for pushing bot events to external systems\"\"\"\n\n    __tablename__ = 'webhooks'\n\n    id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)\n    name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)\n    url = sqlalchemy.Column(sqlalchemy.String(1024), nullable=False)\n    description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')\n    enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)\n    created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())\n    updated_at = sqlalchemy.Column(\n        sqlalchemy.DateTime,\n        nullable=False,\n        server_default=sqlalchemy.func.now(),\n        onupdate=sqlalchemy.func.now(),\n    )\n"
  },
  {
    "path": "src/langbot/pkg/persistence/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/persistence/database.py",
    "content": "from __future__ import annotations\n\nimport abc\n\nimport sqlalchemy.ext.asyncio as sqlalchemy_asyncio\n\nfrom ..core import app\n\n\npreregistered_managers: list[type[BaseDatabaseManager]] = []\n\n\ndef manager_class(name: str) -> None:\n    \"\"\"Register a database manager class\"\"\"\n\n    def decorator(cls: type[BaseDatabaseManager]) -> type[BaseDatabaseManager]:\n        cls.name = name\n        preregistered_managers.append(cls)\n        return cls\n\n    return decorator\n\n\nclass BaseDatabaseManager(abc.ABC):\n    \"\"\"Base database manager class\"\"\"\n\n    name: str\n\n    ap: app.Application\n\n    engine: sqlalchemy_asyncio.AsyncEngine\n\n    def __init__(self, ap: app.Application) -> None:\n        self.ap = ap\n\n    @abc.abstractmethod\n    async def initialize(self) -> None:\n        pass\n\n    def get_engine(self) -> sqlalchemy_asyncio.AsyncEngine:\n        return self.engine\n"
  },
  {
    "path": "src/langbot/pkg/persistence/databases/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/persistence/databases/postgresql.py",
    "content": "from __future__ import annotations\n\nimport sqlalchemy.ext.asyncio as sqlalchemy_asyncio\n\nfrom .. import database\n\n\n@database.manager_class('postgresql')\nclass PostgreSQLDatabaseManager(database.BaseDatabaseManager):\n    \"\"\"PostgreSQL database manager\"\"\"\n\n    async def initialize(self) -> None:\n        postgresql_config = self.ap.instance_config.data.get('database', {}).get('postgresql', {})\n\n        host = postgresql_config.get('host', '127.0.0.1')\n        port = postgresql_config.get('port', 5432)\n        user = postgresql_config.get('user', 'postgres')\n        password = postgresql_config.get('password', 'postgres')\n        database = postgresql_config.get('database', 'postgres')\n        engine_url = f'postgresql+asyncpg://{user}:{password}@{host}:{port}/{database}'\n        self.engine = sqlalchemy_asyncio.create_async_engine(engine_url)\n"
  },
  {
    "path": "src/langbot/pkg/persistence/databases/sqlite.py",
    "content": "from __future__ import annotations\n\nimport sqlalchemy.ext.asyncio as sqlalchemy_asyncio\n\nfrom .. import database\n\n\n@database.manager_class('sqlite')\nclass SQLiteDatabaseManager(database.BaseDatabaseManager):\n    \"\"\"SQLite database manager\"\"\"\n\n    async def initialize(self) -> None:\n        db_file_path = self.ap.instance_config.data.get('database', {}).get('sqlite', {}).get('path', 'data/langbot.db')\n        engine_url = f'sqlite+aiosqlite:///{db_file_path}'\n        self.engine = sqlalchemy_asyncio.create_async_engine(engine_url)\n"
  },
  {
    "path": "src/langbot/pkg/persistence/mgr.py",
    "content": "from __future__ import annotations\n\nimport datetime\nimport typing\nimport json\nimport uuid\n\nimport sqlalchemy.ext.asyncio as sqlalchemy_asyncio\nimport sqlalchemy\n\nfrom . import database, migration\nfrom ..entity.persistence import base, pipeline, metadata, model as persistence_model\nfrom ..entity import persistence\nfrom ..core import app\nfrom ..utils import constants, importutil\nfrom ..api.http.service import pipeline as pipeline_service\nfrom . import databases, migrations\n\nimportutil.import_modules_in_pkg(databases)\nimportutil.import_modules_in_pkg(migrations)\nimportutil.import_modules_in_pkg(persistence)\n\n\nclass PersistenceManager:\n    \"\"\"Persistence module manager\"\"\"\n\n    ap: app.Application\n\n    db: database.BaseDatabaseManager\n    \"\"\"Database manager\"\"\"\n\n    meta: sqlalchemy.MetaData\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        self.meta = base.Base.metadata\n\n    async def initialize(self):\n        database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')\n        self.ap.logger.info(f'Initializing database type: {database_type}...')\n        for manager in database.preregistered_managers:\n            if manager.name == database_type:\n                self.db = manager(self.ap)\n                await self.db.initialize()\n                break\n\n        await self.create_tables()\n\n        # run migrations\n        database_version = await self.execute_async(\n            sqlalchemy.select(metadata.Metadata).where(metadata.Metadata.key == 'database_version')\n        )\n\n        database_version = int(database_version.fetchone()[1])\n        required_database_version = constants.required_database_version\n\n        if database_version < required_database_version:\n            migrations = migration.preregistered_db_migrations\n            migrations.sort(key=lambda x: x.number)\n\n            last_migration_number = database_version\n\n            for migration_cls in migrations:\n                migration_instance = migration_cls(self.ap)\n\n                if (\n                    migration_instance.number > database_version\n                    and migration_instance.number <= required_database_version\n                ):\n                    await migration_instance.upgrade()\n                    await self.execute_async(\n                        sqlalchemy.update(metadata.Metadata)\n                        .where(metadata.Metadata.key == 'database_version')\n                        .values({'value': str(migration_instance.number)})\n                    )\n                    last_migration_number = migration_instance.number\n                    self.ap.logger.info(f'Migration {migration_instance.number} completed.')\n\n            self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')\n\n        await self.write_default_pipeline()\n        await self.write_space_model_providers()\n\n    async def create_tables(self):\n        # create tables\n        async with self.get_db_engine().connect() as conn:\n            await conn.run_sync(self.meta.create_all)\n\n            await conn.commit()\n\n        # ======= write initial data =======\n\n        # write initial metadata\n        self.ap.logger.info('Creating initial metadata...')\n        for item in metadata.initial_metadata:\n            # check if the item exists\n            result = await self.execute_async(\n                sqlalchemy.select(metadata.Metadata).where(metadata.Metadata.key == item['key'])\n            )\n            row = result.first()\n            if row is None:\n                await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))\n\n    async def write_default_pipeline(self):\n        # write default pipeline\n        result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))\n        default_pipeline_uuid = None\n        if result.first() is None:\n            self.ap.logger.info('Creating default pipeline...')\n\n            pipeline_config = json.loads(importutil.read_resource_file('templates/default-pipeline-config.json'))\n\n            default_pipeline_uuid = str(uuid.uuid4())\n            pipeline_data = {\n                'uuid': default_pipeline_uuid,\n                'for_version': self.ap.ver_mgr.get_current_version(),\n                'stages': pipeline_service.default_stage_order,\n                'is_default': True,\n                'name': 'ChatPipeline',\n                'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线，您配置的机器人将自动绑定到此流水线',\n                'config': pipeline_config,\n                'extensions_preferences': {},\n            }\n\n            await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))\n\n    async def write_space_model_providers(self):\n        space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(\n            'models_gateway_api_url', 'https://api.langbot.cloud/v1'\n        )\n\n        # write space model providers\n        result = await self.execute_async(\n            sqlalchemy.select(persistence_model.ModelProvider).where(\n                persistence_model.ModelProvider.requester == 'space-chat-completions'\n            )\n        )\n        exists_space_chat_completions_model_provider = result.first()\n\n        # api keys will be set/updated when the oauth callback\n        if exists_space_chat_completions_model_provider is None:\n            self.ap.logger.info('Creating space model providers...')\n            space_chat_completions_model_provider = {\n                'uuid': '00000000-0000-0000-0000-000000000000',\n                'name': 'LangBot Models',\n                'requester': 'space-chat-completions',\n                'base_url': space_models_gateway_api_url,\n                'api_keys': [],\n            }\n\n            await self.execute_async(\n                sqlalchemy.insert(persistence_model.ModelProvider).values(space_chat_completions_model_provider)\n            )\n        else:\n            if exists_space_chat_completions_model_provider.base_url != space_models_gateway_api_url:\n                await self.execute_async(\n                    sqlalchemy.update(persistence_model.ModelProvider)\n                    .where(persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid)\n                    .values({'base_url': space_models_gateway_api_url})\n                )\n\n    # =================================\n\n    async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:\n        async with self.get_db_engine().connect() as conn:\n            result = await conn.execute(*args, **kwargs)\n            await conn.commit()\n            return result\n\n    def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine:\n        return self.db.get_engine()\n\n    def serialize_model(\n        self, model: typing.Type[sqlalchemy.Base], data: sqlalchemy.Base, masked_columns: list[str] = []\n    ) -> dict:\n        return {\n            column.name: getattr(data, column.name)\n            if not isinstance(getattr(data, column.name), (datetime.datetime))\n            else getattr(data, column.name).isoformat()\n            for column in model.__table__.columns\n            if column.name not in masked_columns\n        }\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migration.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport abc\n\nfrom ..core import app\n\n\npreregistered_db_migrations: list[typing.Type[DBMigration]] = []\n\n\ndef migration_class(number: int):\n    \"\"\"Migration class decorator\"\"\"\n\n    def wrapper(cls: typing.Type[DBMigration]) -> typing.Type[DBMigration]:\n        cls.number = number\n        preregistered_db_migrations.append(cls)\n        return cls\n\n    return wrapper\n\n\nclass DBMigration(abc.ABC):\n    \"\"\"Database migration\"\"\"\n\n    number: int\n    \"\"\"Migration number\"\"\"\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    @abc.abstractmethod\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py",
    "content": "from .. import migration\nfrom copy import deepcopy\nimport uuid\nimport os\nimport sqlalchemy\nimport shutil\n\nfrom ...config import manager as config_manager\nfrom ...entity.persistence import (\n    model as persistence_model,\n    pipeline as persistence_pipeline,\n    bot as persistence_bot,\n)\n\n\n@migration.migration_class(1)\nclass DBMigrateV3Config(migration.DBMigration):\n    \"\"\"Migrate v3 config to v4 database\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        \"\"\"\n        Migrate all config files under data/config.\n        After migration, all previous config files are saved under data/legacy/config.\n        After migration, all config files under data/metadata/ are saved under data/legacy/metadata.\n        \"\"\"\n\n        if self.ap.provider_cfg is None:\n            return\n\n        # ======= Migrate model =======\n        # Only migrate the currently selected model\n        model_name = self.ap.provider_cfg.data.get('model', 'gpt-4o')\n\n        model_requester = 'openai-chat-completions'\n        model_requester_config = {}\n        model_api_keys = ['sk-proj-1234567890']\n        model_abilities = []\n        model_extra_args = {}\n\n        if os.path.exists('data/metadata/llm-models.json'):\n            _llm_model_meta = await config_manager.load_json_config('data/metadata/llm-models.json', completion=False)\n\n            for item in _llm_model_meta.data.get('list', []):\n                if item.get('name') == model_name:\n                    if 'model_name' in item:\n                        model_name = item['model_name']\n                    if 'requester' in item:\n                        model_requester = item['requester']\n                    if 'token_mgr' in item:\n                        _token_mgr = item['token_mgr']\n\n                        if _token_mgr in self.ap.provider_cfg.data.get('keys', {}):\n                            model_api_keys = self.ap.provider_cfg.data.get('keys', {})[_token_mgr]\n\n                    if 'tool_call_supported' in item and item['tool_call_supported']:\n                        model_abilities.append('func_call')\n\n                    if 'vision_supported' in item and item['vision_supported']:\n                        model_abilities.append('vision')\n\n                    if (\n                        model_requester in self.ap.provider_cfg.data.get('requester', {})\n                        and 'args' in self.ap.provider_cfg.data.get('requester', {})[model_requester]\n                    ):\n                        model_extra_args = self.ap.provider_cfg.data.get('requester', {})[model_requester]['args']\n\n                    if model_requester in self.ap.provider_cfg.data.get('requester', {}):\n                        model_requester_config = self.ap.provider_cfg.data.get('requester', {})[model_requester]\n                        model_requester_config = {\n                            'base_url': model_requester_config['base-url'],\n                            'timeout': model_requester_config['timeout'],\n                        }\n\n                    break\n\n        model_uuid = str(uuid.uuid4())\n\n        llm_model_data = {\n            'uuid': model_uuid,\n            'name': model_name,\n            'description': '由 LangBot v3 迁移而来',\n            'requester': model_requester,\n            'requester_config': model_requester_config,\n            'api_keys': model_api_keys,\n            'abilities': model_abilities,\n            'extra_args': model_extra_args,\n        }\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.insert(persistence_model.LLMModel).values(**llm_model_data)\n        )\n\n        # ======= Migrate pipeline config =======\n        # Modify to default pipeline\n        default_pipeline = [\n            self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)\n            for pipeline in (\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(\n                        persistence_pipeline.LegacyPipeline.is_default == True\n                    )\n                )\n            ).all()\n        ][0]\n\n        pipeline_uuid = str(uuid.uuid4())\n        pipeline_name = 'ChatPipeline'\n\n        if default_pipeline:\n            pipeline_name = default_pipeline['name']\n            pipeline_uuid = default_pipeline['uuid']\n\n            pipeline_config = default_pipeline['config']\n\n            # ai\n            pipeline_config['ai']['runner'] = {\n                'runner': self.ap.provider_cfg.data['runner'],\n            }\n            pipeline_config['ai']['local-agent']['model'] = model_uuid\n            pipeline_config['ai']['local-agent']['max-round'] = self.ap.pipeline_cfg.data['msg-truncate']['round'][\n                'max-round'\n            ]\n\n            pipeline_config['ai']['local-agent']['prompt'] = [\n                {\n                    'role': 'system',\n                    'content': self.ap.provider_cfg.data['prompt']['default'],\n                }\n            ]\n            pipeline_config['ai']['dify-service-api'] = {\n                'base-url': self.ap.provider_cfg.data['dify-service-api']['base-url'],\n                'app-type': self.ap.provider_cfg.data['dify-service-api']['app-type'],\n                'api-key': self.ap.provider_cfg.data['dify-service-api'][\n                    self.ap.provider_cfg.data['dify-service-api']['app-type']\n                ]['api-key'],\n                'thinking-convert': self.ap.provider_cfg.data['dify-service-api']['options']['convert-thinking-tips'],\n                'timeout': self.ap.provider_cfg.data['dify-service-api'][\n                    self.ap.provider_cfg.data['dify-service-api']['app-type']\n                ]['timeout'],\n            }\n            pipeline_config['ai']['dashscope-app-api'] = {\n                'app-type': self.ap.provider_cfg.data['dashscope-app-api']['app-type'],\n                'api-key': self.ap.provider_cfg.data['dashscope-app-api']['api-key'],\n                'references_quote': self.ap.provider_cfg.data['dashscope-app-api'][\n                    self.ap.provider_cfg.data['dashscope-app-api']['app-type']\n                ]['references_quote'],\n            }\n\n            # trigger\n            pipeline_config['trigger']['group-respond-rules'] = self.ap.pipeline_cfg.data['respond-rules']['default']\n            pipeline_config['trigger']['access-control'] = self.ap.pipeline_cfg.data['access-control']\n            pipeline_config['trigger']['ignore-rules'] = self.ap.pipeline_cfg.data['ignore-rules']\n\n            # safety\n            pipeline_config['safety']['content-filter'] = {\n                'scope': 'all',\n                'check-sensitive-words': self.ap.pipeline_cfg.data['check-sensitive-words'],\n            }\n            pipeline_config['safety']['rate-limit'] = {\n                'window-length': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['window-size'],\n                'limitation': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['limit'],\n                'strategy': self.ap.pipeline_cfg.data['rate-limit']['strategy'],\n            }\n\n            # output\n            pipeline_config['output']['long-text-processing'] = self.ap.platform_cfg.data['long-text-process']\n            pipeline_config['output']['force-delay'] = self.ap.platform_cfg.data['force-delay']\n            pipeline_config['output']['misc'] = {\n                'hide-exception': self.ap.platform_cfg.data['hide-exception-info'],\n                'quote-origin': self.ap.platform_cfg.data['quote-origin'],\n                'at-sender': self.ap.platform_cfg.data['at-sender'],\n                'track-function-calls': self.ap.platform_cfg.data['track-function-calls'],\n            }\n\n            default_pipeline['description'] = default_pipeline['description'] + ' [已迁移 LangBot v3 配置]'\n            default_pipeline['config'] = pipeline_config\n            default_pipeline.pop('created_at')\n            default_pipeline.pop('updated_at')\n\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.update(persistence_pipeline.LegacyPipeline)\n                .values(default_pipeline)\n                .where(persistence_pipeline.LegacyPipeline.uuid == default_pipeline['uuid'])\n            )\n\n        # ======= Migrate bot =======\n        # Only migrate enabled bots\n        for adapter in self.ap.platform_cfg.data.get('platform-adapters', []):\n            if not adapter.get('enable'):\n                continue\n\n            args = deepcopy(adapter)\n            args.pop('adapter')\n            args.pop('enable')\n\n            bot_data = {\n                'uuid': str(uuid.uuid4()),\n                'name': adapter.get('adapter'),\n                'description': '由 LangBot v3 迁移而来',\n                'adapter': adapter.get('adapter'),\n                'adapter_config': args,\n                'enable': True,\n                'use_pipeline_uuid': pipeline_uuid,\n                'use_pipeline_name': pipeline_name,\n            }\n\n            await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(**bot_data))\n\n        # ======= Migrate system settings =======\n        self.ap.instance_config.data['admins'] = self.ap.system_cfg.data['admin-sessions']\n        self.ap.instance_config.data['api']['port'] = self.ap.system_cfg.data['http-api']['port']\n        self.ap.instance_config.data['command'] = {\n            'prefix': self.ap.command_cfg.data['command-prefix'],\n            'enable': self.ap.command_cfg.data['command-enable']\n            if 'command-enable' in self.ap.command_cfg.data\n            else True,\n            'privilege': self.ap.command_cfg.data['privilege'],\n        }\n        self.ap.instance_config.data['concurrency']['pipeline'] = self.ap.system_cfg.data['pipeline-concurrency']\n        self.ap.instance_config.data['concurrency']['session'] = self.ap.system_cfg.data['session-concurrency'][\n            'default'\n        ]\n        self.ap.instance_config.data['mcp'] = self.ap.provider_cfg.data['mcp']\n        self.ap.instance_config.data['proxy'] = self.ap.system_cfg.data['network-proxies']\n        await self.ap.instance_config.dump_config()\n\n        # ======= move files =======\n        # Migrate all config files under data/config\n        all_legacy_dir_name = [\n            'config',\n            # 'metadata',\n            'prompts',\n            'scenario',\n        ]\n\n        def move_legacy_files(dir_name: str):\n            if not os.path.exists(f'data/legacy/{dir_name}'):\n                os.makedirs(f'data/legacy/{dir_name}')\n\n            if os.path.exists(f'data/{dir_name}'):\n                for file in os.listdir(f'data/{dir_name}'):\n                    if file.endswith('.json'):\n                        shutil.move(f'data/{dir_name}/{file}', f'data/legacy/{dir_name}/{file}')\n\n                os.rmdir(f'data/{dir_name}')\n\n        for dir_name in all_legacy_dir_name:\n            move_legacy_files(dir_name)\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(2)\nclass DBMigrateCombineQuoteMsgConfig(migration.DBMigration):\n    \"\"\"Combine quote message config\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Read all pipelines using raw SQL\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            # Ensure 'trigger' exists\n            if 'trigger' not in config:\n                config['trigger'] = {}\n\n            # Ensure 'misc' exists in 'trigger'\n            if 'misc' not in config['trigger']:\n                config['trigger']['misc'] = {}\n\n            # Add 'combine-quote-message' if not exists\n            if 'combine-quote-message' not in config['trigger']['misc']:\n                config['trigger']['misc']['combine-quote-message'] = False\n\n            # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(3)\nclass DBMigrateN8nConfig(migration.DBMigration):\n    \"\"\"N8n config\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Read all pipelines using raw SQL\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            # Ensure 'ai' exists\n            if 'ai' not in config:\n                config['ai'] = {}\n\n            # Add 'n8n-service-api' if not exists\n            if 'n8n-service-api' not in config['ai']:\n                config['ai']['n8n-service-api'] = {\n                    'webhook-url': 'http://your-n8n-webhook-url',\n                    'auth-type': 'none',\n                    'basic-username': '',\n                    'basic-password': '',\n                    'jwt-secret': '',\n                    'jwt-algorithm': 'HS256',\n                    'header-name': '',\n                    'header-value': '',\n                    'timeout': 120,\n                    'output-key': 'response',\n                }\n\n            # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(4)\nclass DBMigrateRAGKBUUID(migration.DBMigration):\n    \"\"\"RAG知识库UUID\"\"\"\n\n    async def upgrade(self):\n        \"\"\"升级\"\"\"\n        # Read all pipelines using raw SQL\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            # Ensure nested structure exists\n            if 'ai' not in config:\n                config['ai'] = {}\n            if 'local-agent' not in config['ai']:\n                config['ai']['local-agent'] = {}\n\n            # Add 'knowledge-base' if not exists\n            if 'knowledge-base' not in config['ai']['local-agent']:\n                config['ai']['local-agent']['knowledge-base'] = ''\n\n            # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n\n    async def downgrade(self):\n        \"\"\"降级\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(5)\nclass DBMigratePipelineRemoveCotConfig(migration.DBMigration):\n    \"\"\"Pipeline remove cot config\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Read all pipelines using raw SQL\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            # Ensure nested structure exists\n            if 'output' not in config:\n                config['output'] = {}\n            if 'misc' not in config['output']:\n                config['output']['misc'] = {}\n\n            # Add 'remove-think' if not exists\n            if 'remove-think' not in config['output']['misc']:\n                config['output']['misc']['remove-think'] = False\n\n            # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(6)\nclass DBMigrateLangflowApiConfig(migration.DBMigration):\n    \"\"\"Langflow API config\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Read all pipelines using raw SQL\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            # Ensure 'ai' exists\n            if 'ai' not in config:\n                config['ai'] = {}\n\n            # Add 'langflow-api' if not exists\n            if 'langflow-api' not in config['ai']:\n                config['ai']['langflow-api'] = {\n                    'base-url': 'http://localhost:7860',\n                    'api-key': 'your-api-key',\n                    'flow-id': 'your-flow-id',\n                    'input-type': 'chat',\n                    'output-type': 'chat',\n                    'tweaks': '{}',\n                }\n\n            # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py",
    "content": "import sqlalchemy\nfrom .. import migration\n\n\n@migration.migration_class(7)\nclass DBMigratePluginInstallSource(migration.DBMigration):\n    \"\"\"插件安装来源\"\"\"\n\n    async def upgrade(self):\n        \"\"\"升级\"\"\"\n        # 查询表结构获取所有列名（异步执行 SQL）\n\n        columns = []\n\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\n                    \"SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_settings';\"\n                )\n            )\n            all_result = result.fetchall()\n            columns = [row[0] for row in all_result]\n        else:\n            result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(plugin_settings);'))\n            all_result = result.fetchall()\n            columns = [row[1] for row in all_result]\n\n        # 检查并添加 install_source 列\n        if 'install_source' not in columns:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\n                    \"ALTER TABLE plugin_settings ADD COLUMN install_source VARCHAR(255) NOT NULL DEFAULT 'github'\"\n                )\n            )\n\n        # 检查并添加 install_info 列\n        if 'install_info' not in columns:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\"ALTER TABLE plugin_settings ADD COLUMN install_info JSON NOT NULL DEFAULT '{}'\")\n            )\n\n    async def downgrade(self):\n        \"\"\"降级\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py",
    "content": "from .. import migration\n\n\n@migration.migration_class(8)\nclass DBMigratePluginConfig(migration.DBMigration):\n    \"\"\"插件配置\"\"\"\n\n    async def upgrade(self):\n        \"\"\"升级\"\"\"\n\n        if 'plugin' not in self.ap.instance_config.data:\n            self.ap.instance_config.data['plugin'] = {\n                'runtime_ws_url': 'ws://langbot_plugin_runtime:5400/control/ws',\n                'enable_marketplace': True,\n                'cloud_service_url': 'https://space.langbot.app',\n            }\n\n            await self.ap.instance_config.dump_config()\n\n    async def downgrade(self):\n        \"\"\"降级\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py",
    "content": "import sqlalchemy\nfrom .. import migration\n\n\n@migration.migration_class(9)\nclass DBMigratePipelineExtensionPreferences(migration.DBMigration):\n    \"\"\"Pipeline extension preferences\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n\n        sql_text = sqlalchemy.text(\n            \"ALTER TABLE legacy_pipelines ADD COLUMN extensions_preferences JSON NOT NULL DEFAULT '{}'\"\n        )\n        await self.ap.persistence_mgr.execute_async(sql_text)\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        sql_text = sqlalchemy.text('ALTER TABLE legacy_pipelines DROP COLUMN extensions_preferences')\n        await self.ap.persistence_mgr.execute_async(sql_text)\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(10)\nclass DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):\n    \"\"\"Pipeline support multiple knowledge base binding\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Read all pipelines using raw SQL\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            # Convert knowledge-base from string to array\n            if 'ai' in config and 'local-agent' in config['ai']:\n                current_kb = config['ai']['local-agent'].get('knowledge-base', '')\n\n                # If it's already a list, skip\n                if isinstance(current_kb, list):\n                    continue\n\n                # Convert string to list\n                if current_kb and current_kb != '__none__':\n                    config['ai']['local-agent']['knowledge-bases'] = [current_kb]\n                else:\n                    config['ai']['local-agent']['knowledge-bases'] = []\n\n                # Remove old field\n                if 'knowledge-base' in config['ai']['local-agent']:\n                    del config['ai']['local-agent']['knowledge-base']\n\n                # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n                if self.ap.persistence_mgr.db.name == 'postgresql':\n                    await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.text(\n                            'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                        ),\n                        {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                    )\n                else:\n                    await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.text(\n                            'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                        ),\n                        {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                    )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        # Read all pipelines using raw SQL\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            # Convert knowledge-bases from array back to string\n            if 'ai' in config and 'local-agent' in config['ai']:\n                current_kbs = config['ai']['local-agent'].get('knowledge-bases', [])\n\n                # If it's already a string, skip\n                if isinstance(current_kbs, str):\n                    continue\n\n                # Convert list to string (take first one or empty)\n                if current_kbs and len(current_kbs) > 0:\n                    config['ai']['local-agent']['knowledge-base'] = current_kbs[0]\n                else:\n                    config['ai']['local-agent']['knowledge-base'] = ''\n\n                # Remove new field\n                if 'knowledge-bases' in config['ai']['local-agent']:\n                    del config['ai']['local-agent']['knowledge-bases']\n\n                # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n                if self.ap.persistence_mgr.db.name == 'postgresql':\n                    await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.text(\n                            'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                        ),\n                        {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                    )\n                else:\n                    await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.text(\n                            'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                        ),\n                        {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                    )\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(11)\nclass DBMigrateDifyApiConfig(migration.DBMigration):\n    \"\"\"Dify base prompt config\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Read all pipelines using raw SQL\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            # Ensure nested structure exists\n            if 'ai' not in config:\n                config['ai'] = {}\n            if 'dify-service-api' not in config['ai']:\n                config['ai']['dify-service-api'] = {}\n\n            # Add 'base-prompt' if not exists\n            if 'base-prompt' not in config['ai']['dify-service-api']:\n                config['ai']['dify-service-api']['base-prompt'] = (\n                    'When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.',\n                )\n\n            # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(12)\nclass DBMigratePipelineExtensionsEnableAll(migration.DBMigration):\n    \"\"\"Pipeline extensions enable all\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Read all pipelines using raw SQL\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, extensions_preferences FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            extensions_preferences = (\n                json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n            )\n\n            # Ensure extensions_preferences is a dict\n            if extensions_preferences is None:\n                extensions_preferences = {}\n\n            # Add 'enable_all_plugins' if not exists\n            if 'enable_all_plugins' not in extensions_preferences:\n                if 'plugins' in extensions_preferences:\n                    extensions_preferences['enable_all_plugins'] = False\n                else:\n                    extensions_preferences['enable_all_plugins'] = True\n                    extensions_preferences['plugins'] = []\n\n            # Add 'enable_all_mcp_servers' if not exists\n            if 'enable_all_mcp_servers' not in extensions_preferences:\n                if 'mcp_servers' in extensions_preferences:\n                    extensions_preferences['enable_all_mcp_servers'] = False\n                else:\n                    extensions_preferences['enable_all_mcp_servers'] = True\n                    extensions_preferences['mcp_servers'] = []\n\n            # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {\n                        'extensions_preferences': json.dumps(extensions_preferences),\n                        'for_version': current_version,\n                        'uuid': uuid,\n                    },\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {\n                        'extensions_preferences': json.dumps(extensions_preferences),\n                        'for_version': current_version,\n                        'uuid': uuid,\n                    },\n                )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py",
    "content": "import sqlalchemy\nfrom .. import migration\n\n\n@migration.migration_class(13)\nclass DBMigrateKnowledgeBaseUpdatedAt(migration.DBMigration):\n    \"\"\"Add updated_at field to knowledge_bases table\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Get all column names from the table\n        columns = []\n\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\n                    \"SELECT column_name FROM information_schema.columns WHERE table_name = 'knowledge_bases';\"\n                )\n            )\n            all_result = result.fetchall()\n            columns = [row[0] for row in all_result]\n        else:\n            result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(knowledge_bases);'))\n            all_result = result.fetchall()\n            columns = [row[1] for row in all_result]\n\n        # Check and add updated_at column\n        if 'updated_at' not in columns:\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'ALTER TABLE knowledge_bases ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP'\n                    )\n                )\n            else:\n                # SQLite doesn't support DEFAULT CURRENT_TIMESTAMP in ALTER TABLE\n                # Add column without default first\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE knowledge_bases ADD COLUMN updated_at DATETIME')\n                )\n\n            # Set initial updated_at values to created_at for existing records\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('UPDATE knowledge_bases SET updated_at = created_at WHERE updated_at IS NULL')\n            )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py",
    "content": "import sqlalchemy\nfrom .. import migration\n\n\n@migration.migration_class(14)\nclass DBMigrateSpaceAccountSupport(migration.DBMigration):\n    \"\"\"Add Space account support fields to users table\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Get all column names from the users table\n        columns = []\n\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\"SELECT column_name FROM information_schema.columns WHERE table_name = 'users';\")\n            )\n            all_result = result.fetchall()\n            columns = [row[0] for row in all_result]\n        else:\n            result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(users);'))\n            all_result = result.fetchall()\n            columns = [row[1] for row in all_result]\n\n        # Add account_type column\n        if 'account_type' not in columns:\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\"ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL\")\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\"ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL\")\n                )\n\n        # Add space_account_uuid column\n        if 'space_account_uuid' not in columns:\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')\n                )\n\n        # Add space_access_token column\n        if 'space_access_token' not in columns:\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')\n                )\n\n        # Add space_refresh_token column\n        if 'space_refresh_token' not in columns:\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')\n                )\n\n        # Add space_access_token_expires_at column\n        if 'space_access_token_expires_at' not in columns:\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at TIMESTAMP')\n                )\n\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at DATETIME')\n                )\n\n        # Add space_api_key column\n        if 'space_api_key' not in columns:\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')\n                )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py",
    "content": "from .. import migration\n\n\n# this is a deprecated migration\n@migration.migration_class(15)\nclass DBMigrateModelSourceTracking(migration.DBMigration):\n    \"\"\"Add source tracking fields to models tables for Space integration\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        pass\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py",
    "content": "import uuid as uuid_lib\n\nimport sqlalchemy\nfrom .. import migration\n\n\n@migration.migration_class(16)\nclass DBMigrateModelProviderRefactor(migration.DBMigration):\n    \"\"\"Refactor model structure: create providers from existing models and update references\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Step 1: Create model_providers table if not exists\n        await self._create_providers_table()\n\n        # Step 2: Migrate existing models to use providers\n        await self._migrate_llm_models()\n        await self._migrate_embedding_models()\n\n        # Step 3: Remove deprecated columns\n        await self._cleanup_columns()\n\n    async def _create_providers_table(self):\n        \"\"\"Create model_providers table\"\"\"\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\"\"\"\n                    CREATE TABLE IF NOT EXISTS model_providers (\n                        uuid VARCHAR(255) PRIMARY KEY,\n                        name VARCHAR(255) NOT NULL,\n                        requester VARCHAR(255) NOT NULL,\n                        base_url VARCHAR(512) NOT NULL,\n                        api_keys JSONB NOT NULL DEFAULT '[]',\n                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n                        updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n                    )\n                \"\"\")\n            )\n        else:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\"\"\"\n                    CREATE TABLE IF NOT EXISTS model_providers (\n                        uuid VARCHAR(255) PRIMARY KEY,\n                        name VARCHAR(255) NOT NULL,\n                        requester VARCHAR(255) NOT NULL,\n                        base_url VARCHAR(512) NOT NULL,\n                        api_keys JSON NOT NULL DEFAULT '[]',\n                        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n                        updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n                    )\n                \"\"\")\n            )\n\n    async def _migrate_llm_models(self):\n        \"\"\"Migrate LLM models to use providers\"\"\"\n        llm_columns = await self._get_columns('llm_models')\n\n        # Add provider_uuid column if not exists\n        if 'provider_uuid' not in llm_columns:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN provider_uuid VARCHAR(255)')\n            )\n\n        # Add prefered_ranking column if not exists\n        if 'prefered_ranking' not in llm_columns:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0')\n            )\n\n        # Only migrate if old columns exist\n        if 'requester' not in llm_columns:\n            return\n\n        # Get all LLM models with old structure\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM llm_models')\n        )\n        models = result.fetchall()\n\n        # Create providers and update models\n        provider_cache = {}  # (requester, base_url, api_keys_str) -> provider_uuid\n\n        for model in models:\n            model_uuid, model_name, requester, requester_config, api_keys = model\n\n            # Extract base_url from requester_config\n            base_url = ''\n            if requester_config:\n                if isinstance(requester_config, str):\n                    import json\n\n                    requester_config = json.loads(requester_config)\n                base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '')\n\n            # Parse api_keys if it's a string\n            if isinstance(api_keys, str):\n                import json\n\n                try:\n                    api_keys = json.loads(api_keys)\n                except Exception:\n                    api_keys = []\n            if not api_keys:\n                api_keys = []\n\n            # Create cache key\n            api_keys_str = str(sorted(api_keys)) if api_keys else '[]'\n            cache_key = (requester, base_url, api_keys_str)\n\n            if cache_key in provider_cache:\n                provider_uuid = provider_cache[cache_key]\n            else:\n                # Create new provider\n                provider_uuid = str(uuid_lib.uuid4())\n                provider_name = f'{requester}'\n                if base_url:\n                    # Extract domain for name\n                    try:\n                        from urllib.parse import urlparse\n\n                        parsed = urlparse(base_url)\n                        provider_name = parsed.netloc or requester\n                    except Exception:\n                        pass\n\n                import json\n\n                api_keys_json = json.dumps(api_keys) if api_keys else '[]'\n\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\"\"\"\n                        INSERT INTO model_providers (uuid, name, requester, base_url, api_keys)\n                        VALUES (:uuid, :name, :requester, :base_url, :api_keys)\n                    \"\"\"),\n                    {\n                        'uuid': provider_uuid,\n                        'name': provider_name,\n                        'requester': requester,\n                        'base_url': base_url,\n                        'api_keys': api_keys_json,\n                    },\n                )\n                provider_cache[cache_key] = provider_uuid\n\n            # Update model with provider_uuid\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('UPDATE llm_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'),\n                {'provider_uuid': provider_uuid, 'uuid': model_uuid},\n            )\n\n    async def _migrate_embedding_models(self):\n        \"\"\"Migrate embedding models to use providers\"\"\"\n        embedding_columns = await self._get_columns('embedding_models')\n\n        # Add provider_uuid column if not exists\n        if 'provider_uuid' not in embedding_columns:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN provider_uuid VARCHAR(255)')\n            )\n\n        # Add prefered_ranking column if not exists\n        if 'prefered_ranking' not in embedding_columns:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0')\n            )\n\n        # Only migrate if old columns exist\n        if 'requester' not in embedding_columns:\n            return\n\n        # Get all embedding models with old structure\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM embedding_models')\n        )\n        models = result.fetchall()\n\n        # Get existing providers\n        provider_result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, requester, base_url, api_keys FROM model_providers')\n        )\n        existing_providers = provider_result.fetchall()\n\n        provider_cache = {}\n        for p in existing_providers:\n            p_uuid, p_requester, p_base_url, p_api_keys = p\n            api_keys_str = str(sorted(p_api_keys)) if p_api_keys else '[]'\n            provider_cache[(p_requester, p_base_url, api_keys_str)] = p_uuid\n\n        for model in models:\n            model_uuid, model_name, requester, requester_config, api_keys = model\n\n            base_url = ''\n            if requester_config:\n                if isinstance(requester_config, str):\n                    import json\n\n                    requester_config = json.loads(requester_config)\n                base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '')\n\n            # Parse api_keys if it's a string\n            if isinstance(api_keys, str):\n                import json\n\n                try:\n                    api_keys = json.loads(api_keys)\n                except Exception:\n                    api_keys = []\n            if not api_keys:\n                api_keys = []\n\n            api_keys_str = str(sorted(api_keys)) if api_keys else '[]'\n            cache_key = (requester, base_url, api_keys_str)\n\n            if cache_key in provider_cache:\n                provider_uuid = provider_cache[cache_key]\n            else:\n                provider_uuid = str(uuid_lib.uuid4())\n                provider_name = f'{requester}'\n                if base_url:\n                    try:\n                        from urllib.parse import urlparse\n\n                        parsed = urlparse(base_url)\n                        provider_name = parsed.netloc or requester\n                    except Exception:\n                        pass\n\n                import json\n\n                api_keys_json = json.dumps(api_keys) if api_keys else '[]'\n\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\"\"\"\n                        INSERT INTO model_providers (uuid, name, requester, base_url, api_keys)\n                        VALUES (:uuid, :name, :requester, :base_url, :api_keys)\n                    \"\"\"),\n                    {\n                        'uuid': provider_uuid,\n                        'name': provider_name,\n                        'requester': requester,\n                        'base_url': base_url,\n                        'api_keys': api_keys_json,\n                    },\n                )\n                provider_cache[cache_key] = provider_uuid\n\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('UPDATE embedding_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'),\n                {'provider_uuid': provider_uuid, 'uuid': model_uuid},\n            )\n\n    async def _cleanup_columns(self):\n        \"\"\"Remove deprecated columns from model tables\"\"\"\n\n        llm_columns = await self._get_columns('llm_models')\n        deprecated_llm_cols = ['requester', 'requester_config', 'api_keys', 'description', 'source', 'space_model_id']\n        for col in deprecated_llm_cols:\n            if col in llm_columns:\n                if self.ap.persistence_mgr.db.name == 'postgresql':\n                    await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN IF EXISTS {col}')\n                    )\n                else:\n                    await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN {col}')\n                    )\n\n        embedding_columns = await self._get_columns('embedding_models')\n        deprecated_embedding_cols = [\n            'requester',\n            'requester_config',\n            'api_keys',\n            'description',\n            'source',\n            'space_model_id',\n        ]\n        for col in deprecated_embedding_cols:\n            if col in embedding_columns:\n                if self.ap.persistence_mgr.db.name == 'postgresql':\n                    await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN IF EXISTS {col}')\n                    )\n                else:\n                    await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN {col}')\n                    )\n\n    async def _get_columns(self, table_name: str) -> list:\n        \"\"\"Get column names for a table\"\"\"\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\n                    f\"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';\"\n                )\n            )\n            all_result = result.fetchall()\n            return [row[0] for row in all_result]\n        else:\n            result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))\n            all_result = result.fetchall()\n            return [row[1] for row in all_result]\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py",
    "content": "from .. import migration\n\n\n@migration.migration_class(17)\nclass MoveCloudServiceUrl(migration.DBMigration):\n    \"\"\"迁移云服务 URL 配置\"\"\"\n\n    async def upgrade(self):\n        \"\"\"升级\"\"\"\n        if 'space' not in self.ap.instance_config.data:\n            self.ap.instance_config.data['space'] = {\n                'url': 'https://space.langbot.app',\n                'models_gateway_api_url': 'https://api.langbot.cloud/v1',\n                'oauth_authorize_url': 'https://space.langbot.app/auth/authorize',\n                'disable_models_service': False,\n            }\n\n        if 'plugin' in self.ap.instance_config.data:\n            self.ap.instance_config.data['plugin'].pop('cloud_service_url', None)\n\n        await self.ap.instance_config.dump_config()\n\n    async def downgrade(self):\n        \"\"\"降级\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py",
    "content": "import sqlalchemy\nfrom .. import migration\n\n\n@migration.migration_class(18)\nclass DBMigrateAddEmojiSupport(migration.DBMigration):\n    \"\"\"Add emoji field to knowledge_bases, external_knowledge_bases and legacy_pipelines tables\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        # Add emoji field to knowledge_bases\n        await self._add_emoji_to_table('knowledge_bases', '📚')\n\n        # Add emoji field to external_knowledge_bases\n        await self._add_emoji_to_table('external_knowledge_bases', '🔗')\n\n        # Add emoji field to legacy_pipelines\n        await self._add_emoji_to_table('legacy_pipelines', '⚙️')\n\n    async def _add_emoji_to_table(self, table_name: str, default_emoji: str):\n        \"\"\"Add emoji column to specified table if it doesn't exist\"\"\"\n        # Get all column names from the table\n        columns = []\n\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\n                    f\"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';\"\n                )\n            )\n            all_result = result.fetchall()\n            columns = [row[0] for row in all_result]\n        else:\n            result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))\n            all_result = result.fetchall()\n            columns = [row[1] for row in all_result]\n\n        # Check and add emoji column\n        if 'emoji' not in columns:\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(f\"ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10) DEFAULT '{default_emoji}'\")\n                )\n            else:\n                # SQLite doesn't support DEFAULT with emoji directly in ALTER TABLE\n                # Add column without default first\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10)')\n                )\n\n            # Set default emoji value for existing records\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(f\"UPDATE {table_name} SET emoji = '{default_emoji}' WHERE emoji IS NULL\")\n            )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py",
    "content": "import sqlalchemy\nfrom .. import migration\n\n\n@migration.migration_class(19)\nclass DBMigrateMonitoringMessageRole(migration.DBMigration):\n    \"\"\"Add role column to monitoring_messages table\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        try:\n            sql_text = sqlalchemy.text(\"ALTER TABLE monitoring_messages ADD COLUMN role VARCHAR(50) DEFAULT 'user'\")\n            await self.ap.persistence_mgr.execute_async(sql_text)\n        except Exception:\n            # Column may already exist\n            pass\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        try:\n            sql_text = sqlalchemy.text('ALTER TABLE monitoring_messages DROP COLUMN role')\n            await self.ap.persistence_mgr.execute_async(sql_text)\n        except Exception:\n            pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py",
    "content": "import sqlalchemy\nfrom .. import migration\n\n\n@migration.migration_class(20)\nclass DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):\n    \"\"\"Migrate to unified Knowledge Engine plugin architecture.\n\n    Changes:\n    - Backup existing knowledge_bases data to knowledge_bases_backup\n    - Clear knowledge_bases table and add new plugin architecture columns\n    - Drop old columns (PostgreSQL only; SQLite leaves them unmapped)\n    - Preserve external_knowledge_bases table as-is for future migration\n    - Set rag_plugin_migration_needed flag in metadata if old data exists\n    \"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        has_internal_data = await self._backup_knowledge_bases()\n        has_external_data = await self._check_external_knowledge_bases()\n        await self._clear_knowledge_bases()\n        await self._add_columns_to_knowledge_bases()\n        await self._drop_old_columns()\n        if has_internal_data or has_external_data:\n            await self._set_migration_flag()\n\n    async def _get_table_columns(self, table_name: str) -> list[str]:\n        \"\"\"Get column names from a table (works for both SQLite and PostgreSQL).\"\"\"\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\n                    'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;'\n                ).bindparams(table_name=table_name)\n            )\n            return [row[0] for row in result.fetchall()]\n        else:\n            # SQLite PRAGMA does not support bind parameters; validate identifier.\n            if not table_name.isidentifier():\n                raise ValueError(f'Invalid table name: {table_name}')\n            result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))\n            return [row[1] for row in result.fetchall()]\n\n    async def _table_exists(self, table_name: str) -> bool:\n        \"\"\"Check if a table exists.\"\"\"\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\n                    'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'\n                ).bindparams(table_name=table_name)\n            )\n            return result.scalar()\n        else:\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\"SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;\").bindparams(\n                    table_name=table_name\n                )\n            )\n            return result.first() is not None\n\n    async def _backup_knowledge_bases(self) -> bool:\n        \"\"\"Backup knowledge_bases data. Returns True if data was backed up.\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases;'))\n        count = result.scalar()\n        if count == 0:\n            return False\n\n        # Drop backup table if it already exists (from a previous failed migration)\n        if await self._table_exists('knowledge_bases_backup'):\n            await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE knowledge_bases_backup;'))\n\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('CREATE TABLE knowledge_bases_backup AS SELECT * FROM knowledge_bases;')\n        )\n        self.ap.logger.info(\n            'Backed up %d knowledge base(s) to knowledge_bases_backup table.',\n            count,\n        )\n        return True\n\n    async def _check_external_knowledge_bases(self) -> bool:\n        \"\"\"Check if external_knowledge_bases table exists and has data.\n\n        The table is preserved as-is (not dropped) for future migration.\n        \"\"\"\n        if not await self._table_exists('external_knowledge_bases'):\n            return False\n\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')\n        )\n        count = result.scalar()\n        if count > 0:\n            self.ap.logger.info(\n                'Found %d external knowledge base(s) in external_knowledge_bases table. '\n                'Table preserved for future migration.',\n                count,\n            )\n        return count > 0\n\n    async def _clear_knowledge_bases(self):\n        \"\"\"Clear all rows from knowledge_bases table (preserve table structure).\"\"\"\n        await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DELETE FROM knowledge_bases;'))\n\n    async def _add_columns_to_knowledge_bases(self):\n        \"\"\"Add new RAG plugin architecture columns to knowledge_bases table.\"\"\"\n        columns = await self._get_table_columns('knowledge_bases')\n\n        new_columns = {\n            'knowledge_engine_plugin_id': 'VARCHAR',\n            'collection_id': 'VARCHAR',\n            'creation_settings': 'TEXT',  # JSON stored as TEXT for SQLite compatibility\n            'retrieval_settings': 'TEXT',\n        }\n\n        for col_name, col_type in new_columns.items():\n            if col_name not in columns:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};')\n                )\n\n    async def _drop_old_columns(self):\n        \"\"\"Drop embedding_model_uuid and top_k columns (PostgreSQL only).\n\n        SQLite does not support DROP COLUMN in older versions, so we leave the\n        columns in place — the SQLAlchemy entity simply won't map them.\n        \"\"\"\n        if self.ap.persistence_mgr.db.name != 'postgresql':\n            return\n\n        columns = await self._get_table_columns('knowledge_bases')\n\n        if 'embedding_model_uuid' in columns:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN embedding_model_uuid;')\n            )\n\n        if 'top_k' in columns:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;')\n            )\n\n    async def _set_migration_flag(self):\n        \"\"\"Set rag_plugin_migration_needed flag in metadata table.\"\"\"\n        # Check if the key already exists\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text(\"SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';\")\n        )\n        row = result.first()\n        if row is not None:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\"UPDATE metadata SET value = 'true' WHERE key = 'rag_plugin_migration_needed';\")\n            )\n        else:\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\"INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');\")\n            )\n        self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.')\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(21)\nclass DBMigrateMergeExceptionHandling(migration.DBMigration):\n    \"\"\"Merge hide-exception and block-failed-request-output into a single exception-handling select option,\n    and add failure-hint field.\n\n    Conversion logic:\n    - block-failed-request-output=true  ->  exception-handling: hide\n    - hide-exception=true               ->  exception-handling: show-hint\n    - hide-exception=false              ->  exception-handling: show-error\n    \"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            if 'output' not in config:\n                config['output'] = {}\n            if 'misc' not in config['output']:\n                config['output']['misc'] = {}\n\n            misc = config['output']['misc']\n\n            # Determine new exception-handling value from legacy fields\n            hide_exception = misc.get('hide-exception', True)\n            block_failed = misc.get('block-failed-request-output', False)\n\n            if block_failed:\n                exception_handling = 'hide'\n            elif hide_exception:\n                exception_handling = 'show-hint'\n            else:\n                exception_handling = 'show-error'\n\n            misc['exception-handling'] = exception_handling\n\n            # Add failure-hint with default value\n            misc['failure-hint'] = 'Request failed.'\n\n            # Remove legacy fields\n            misc.pop('hide-exception', None)\n\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py",
    "content": "import sqlalchemy\nfrom .. import migration\n\n\n@migration.migration_class(22)\nclass DBMigrateMonitoringUserId(migration.DBMigration):\n    \"\"\"Add user_id and user_name columns to monitoring_sessions table\n\n    This migration adds the missing user_id column and also ensures user_name\n    column exists (in case migration 21 failed or was skipped).\n    \"\"\"\n\n    async def _table_exists(self, table_name: str) -> bool:\n        \"\"\"Check if a table exists (works for both SQLite and PostgreSQL).\"\"\"\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\n                    'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'\n                ).bindparams(table_name=table_name)\n            )\n            return bool(result.scalar())\n        else:\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\"SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;\").bindparams(\n                    table_name=table_name\n                )\n            )\n            return result.first() is not None\n\n    async def _get_table_columns(self, table_name: str) -> list[str]:\n        \"\"\"Get column names from a table (works for both SQLite and PostgreSQL).\"\"\"\n        if self.ap.persistence_mgr.db.name == 'postgresql':\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.text(\n                    'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;'\n                ).bindparams(table_name=table_name)\n            )\n            return [row[0] for row in result.fetchall()]\n        else:\n            if not table_name.isidentifier():\n                raise ValueError(f'Invalid table name: {table_name}')\n            result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))\n            return [row[1] for row in result.fetchall()]\n\n    async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str):\n        \"\"\"Add a column to a table if it does not already exist.\"\"\"\n        columns = await self._get_table_columns(table_name)\n        if column_name in columns:\n            self.ap.logger.debug('%s column already exists in %s.', column_name, table_name)\n            return\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};')\n        )\n        self.ap.logger.info('Added %s column to %s table.', column_name, table_name)\n\n    async def upgrade(self):\n        # Check if monitoring_sessions table exists\n        if not await self._table_exists('monitoring_sessions'):\n            self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.')\n            return\n\n        # Add user_id column to monitoring_sessions table\n        await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)')\n\n        # Add user_name column to monitoring_sessions table (in case migration 21 failed)\n        await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)')\n\n        # Add user_name column to monitoring_messages table (in case migration 21 failed)\n        if await self._table_exists('monitoring_messages'):\n            await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)')\n\n    async def downgrade(self):\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(23)\nclass DBMigrateModelFallbackConfig(migration.DBMigration):\n    \"\"\"Convert model field from plain UUID string to object with primary/fallbacks\"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            if 'ai' not in config or 'local-agent' not in config['ai']:\n                continue\n\n            local_agent = config['ai']['local-agent']\n            changed = False\n\n            # Convert model from string to object\n            model_value = local_agent.get('model', '')\n            if isinstance(model_value, str):\n                local_agent['model'] = {\n                    'primary': model_value,\n                    'fallbacks': [],\n                }\n                changed = True\n\n            # Remove leftover fallback-models field if present\n            if 'fallback-models' in local_agent:\n                del local_agent['fallback-models']\n                changed = True\n\n            if not changed:\n                continue\n\n            # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')\n        )\n        pipelines = result.fetchall()\n\n        current_version = self.ap.ver_mgr.get_current_version()\n\n        for pipeline_row in pipelines:\n            uuid = pipeline_row[0]\n            config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]\n\n            if 'ai' not in config or 'local-agent' not in config['ai']:\n                continue\n\n            local_agent = config['ai']['local-agent']\n\n            # Convert model from object back to string\n            model_value = local_agent.get('model', '')\n            if isinstance(model_value, dict):\n                local_agent['model'] = model_value.get('primary', '')\n            else:\n                continue\n\n            # Update using raw SQL with compatibility for both SQLite and PostgreSQL\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text(\n                        'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'\n                    ),\n                    {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},\n                )\n"
  },
  {
    "path": "src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py",
    "content": "from .. import migration\n\nimport sqlalchemy\nimport json\n\n\n@migration.migration_class(24)\nclass DBMigrateWecomBotWebSocketMode(migration.DBMigration):\n    \"\"\"Add enable-webhook field to existing wecombot adapter configs.\n\n    Existing wecombot bots were all using webhook mode, so we set\n    enable-webhook=true to preserve their behavior after the new\n    WebSocket long connection mode is introduced as default.\n    \"\"\"\n\n    async def upgrade(self):\n        \"\"\"Upgrade\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.text(\"SELECT uuid, adapter_config FROM bots WHERE adapter = 'wecombot'\")\n        )\n        bots = result.fetchall()\n\n        for bot_row in bots:\n            bot_uuid = bot_row[0]\n            adapter_config = json.loads(bot_row[1]) if isinstance(bot_row[1], str) else bot_row[1]\n\n            if 'enable-webhook' in adapter_config:\n                continue\n\n            # Determine mode based on existing config: if webhook fields are present, keep webhook mode\n            has_webhook_config = bool(\n                adapter_config.get('Token') and adapter_config.get('EncodingAESKey') and adapter_config.get('Corpid')\n            )\n            adapter_config['enable-webhook'] = has_webhook_config\n\n            if self.ap.persistence_mgr.db.name == 'postgresql':\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('UPDATE bots SET adapter_config = :config::jsonb WHERE uuid = :uuid'),\n                    {'config': json.dumps(adapter_config), 'uuid': bot_uuid},\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.text('UPDATE bots SET adapter_config = :config WHERE uuid = :uuid'),\n                    {'config': json.dumps(adapter_config), 'uuid': bot_uuid},\n                )\n\n    async def downgrade(self):\n        \"\"\"Downgrade\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/aggregator.py",
    "content": "\"\"\"Message Aggregator Module\n\nThis module provides message aggregation/debounce functionality.\nWhen users send multiple messages consecutively, the aggregator will wait\nfor a configurable delay period and merge them into a single message\nbefore processing.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport time\nimport typing\nfrom dataclasses import dataclass, field\n\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\n\nif typing.TYPE_CHECKING:\n    from ..core import app\n\n# Maximum number of messages to buffer before forcing a flush\nMAX_BUFFER_MESSAGES = 10\n\n\n@dataclass\nclass PendingMessage:\n    \"\"\"A pending message waiting to be aggregated\"\"\"\n\n    bot_uuid: str\n    launcher_type: provider_session.LauncherTypes\n    launcher_id: typing.Union[int, str]\n    sender_id: typing.Union[int, str]\n    message_event: platform_events.MessageEvent\n    message_chain: platform_message.MessageChain\n    adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter\n    pipeline_uuid: typing.Optional[str]\n    timestamp: float = field(default_factory=time.time)\n\n\n@dataclass\nclass SessionBuffer:\n    \"\"\"Buffer for a single session's pending messages\"\"\"\n\n    session_id: str\n    messages: list[PendingMessage] = field(default_factory=list)\n    timer_task: typing.Optional[asyncio.Task] = None\n    last_message_time: float = field(default_factory=time.time)\n\n\nclass MessageAggregator:\n    \"\"\"Message aggregator that buffers and merges consecutive messages\n\n    This class implements a debounce mechanism for incoming messages.\n    When a message arrives, it starts a timer. If more messages arrive\n    before the timer expires, they are buffered. When the timer expires,\n    all buffered messages are merged and sent to the query pool.\n    \"\"\"\n\n    ap: app.Application\n\n    buffers: dict[str, SessionBuffer]\n    \"\"\"Session ID -> SessionBuffer mapping\"\"\"\n\n    lock: asyncio.Lock\n    \"\"\"Lock for thread-safe buffer operations\"\"\"\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        self.buffers = {}\n        self.lock = asyncio.Lock()\n\n    def _get_session_id(\n        self,\n        bot_uuid: str,\n        launcher_type: provider_session.LauncherTypes,\n        launcher_id: typing.Union[int, str],\n    ) -> str:\n        \"\"\"Generate a unique session ID\"\"\"\n        return f'{bot_uuid}:{launcher_type.value}:{launcher_id}'\n\n    async def _get_aggregation_config(self, pipeline_uuid: typing.Optional[str]) -> tuple[bool, float]:\n        \"\"\"Get aggregation configuration for a pipeline\n\n        Returns:\n            tuple: (enabled, delay_seconds)\n        \"\"\"\n        default_enabled = False\n        default_delay = 1.5\n\n        if pipeline_uuid is None:\n            return default_enabled, default_delay\n\n        # Get pipeline from pipeline manager\n        pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)\n        if pipeline is None:\n            return default_enabled, default_delay\n\n        config = pipeline.pipeline_entity.config or {}\n        trigger_config = config.get('trigger', {})\n        aggregation_config = trigger_config.get('message-aggregation', {})\n\n        enabled = aggregation_config.get('enabled', default_enabled)\n\n        delay_raw = aggregation_config.get('delay', default_delay)\n        try:\n            delay = float(delay_raw)\n        except (TypeError, ValueError):\n            delay = default_delay\n\n        # Clamp delay to valid range\n        delay = max(1.0, min(10.0, delay))\n\n        return enabled, delay\n\n    async def add_message(\n        self,\n        bot_uuid: str,\n        launcher_type: provider_session.LauncherTypes,\n        launcher_id: typing.Union[int, str],\n        sender_id: typing.Union[int, str],\n        message_event: platform_events.MessageEvent,\n        message_chain: platform_message.MessageChain,\n        adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,\n        pipeline_uuid: typing.Optional[str] = None,\n    ) -> None:\n        \"\"\"Add a message to the aggregation buffer\n\n        If aggregation is disabled for the pipeline, the message is sent\n        directly to the query pool. Otherwise, it's buffered and will be\n        merged with other messages from the same session.\n        \"\"\"\n        enabled, delay = await self._get_aggregation_config(pipeline_uuid)\n\n        if not enabled:\n            # Aggregation disabled, send directly to query pool\n            await self.ap.query_pool.add_query(\n                bot_uuid=bot_uuid,\n                launcher_type=launcher_type,\n                launcher_id=launcher_id,\n                sender_id=sender_id,\n                message_event=message_event,\n                message_chain=message_chain,\n                adapter=adapter,\n                pipeline_uuid=pipeline_uuid,\n            )\n            return\n\n        session_id = self._get_session_id(bot_uuid, launcher_type, launcher_id)\n\n        pending_msg = PendingMessage(\n            bot_uuid=bot_uuid,\n            launcher_type=launcher_type,\n            launcher_id=launcher_id,\n            sender_id=sender_id,\n            message_event=message_event,\n            message_chain=message_chain,\n            adapter=adapter,\n            pipeline_uuid=pipeline_uuid,\n        )\n\n        force_flush = False\n        async with self.lock:\n            if session_id in self.buffers:\n                buffer = self.buffers[session_id]\n                # Cancel existing timer (just cancel, don't await inside lock)\n                if buffer.timer_task and not buffer.timer_task.done():\n                    buffer.timer_task.cancel()\n                buffer.messages.append(pending_msg)\n            else:\n                buffer = SessionBuffer(\n                    session_id=session_id,\n                    messages=[pending_msg],\n                )\n                self.buffers[session_id] = buffer\n\n            buffer.last_message_time = time.time()\n\n            # Check if buffer reached max capacity\n            if len(buffer.messages) >= MAX_BUFFER_MESSAGES:\n                force_flush = True\n            else:\n                # Start new timer\n                buffer.timer_task = asyncio.create_task(self._delayed_flush(session_id, delay))\n\n        if force_flush:\n            await self._flush_buffer(session_id)\n\n    async def _delayed_flush(self, session_id: str, delay: float) -> None:\n        \"\"\"Wait for delay then flush the buffer\"\"\"\n        try:\n            await asyncio.sleep(delay)\n            await self._flush_buffer(session_id)\n        except asyncio.CancelledError:\n            # Timer was cancelled, new message arrived\n            pass\n\n    async def _flush_buffer(self, session_id: str) -> None:\n        \"\"\"Flush the buffer for a session, merging all messages\"\"\"\n        async with self.lock:\n            buffer = self.buffers.pop(session_id, None)\n\n        if buffer is None or not buffer.messages:\n            return\n\n        if len(buffer.messages) == 1:\n            # Only one message, no need to merge\n            msg = buffer.messages[0]\n            await self.ap.query_pool.add_query(\n                bot_uuid=msg.bot_uuid,\n                launcher_type=msg.launcher_type,\n                launcher_id=msg.launcher_id,\n                sender_id=msg.sender_id,\n                message_event=msg.message_event,\n                message_chain=msg.message_chain,\n                adapter=msg.adapter,\n                pipeline_uuid=msg.pipeline_uuid,\n            )\n            return\n\n        # Merge multiple messages\n        merged_msg = self._merge_messages(buffer.messages)\n        await self.ap.query_pool.add_query(\n            bot_uuid=merged_msg.bot_uuid,\n            launcher_type=merged_msg.launcher_type,\n            launcher_id=merged_msg.launcher_id,\n            sender_id=merged_msg.sender_id,\n            message_event=merged_msg.message_event,\n            message_chain=merged_msg.message_chain,\n            adapter=merged_msg.adapter,\n            pipeline_uuid=merged_msg.pipeline_uuid,\n        )\n\n    def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:\n        \"\"\"Merge multiple messages into one\n\n        The merged message uses the first message as base and combines\n        all message chains with newline separators.\n        The original message_event is kept unmodified to preserve\n        message metadata (message_id, etc.) for reply/quote.\n        \"\"\"\n        if len(messages) == 1:\n            return messages[0]\n\n        base_msg = messages[0]\n\n        # Build merged message chain\n        merged_chain = platform_message.MessageChain([])\n\n        for i, msg in enumerate(messages):\n            if i > 0:\n                # Add newline separator between messages\n                merged_chain.append(platform_message.Plain(text='\\n'))\n\n            # Copy all components from this message\n            for component in msg.message_chain:\n                merged_chain.append(component)\n\n        # Keep message_event unmodified (preserves original message_id and\n        # metadata for reply/quote), only pass merged chain separately\n        return PendingMessage(\n            bot_uuid=base_msg.bot_uuid,\n            launcher_type=base_msg.launcher_type,\n            launcher_id=base_msg.launcher_id,\n            sender_id=base_msg.sender_id,\n            message_event=base_msg.message_event,\n            message_chain=merged_chain,\n            adapter=base_msg.adapter,\n            pipeline_uuid=base_msg.pipeline_uuid,\n        )\n\n    async def flush_all(self) -> None:\n        \"\"\"Flush all pending buffers immediately\n\n        This is useful during shutdown to ensure no messages are lost.\n        \"\"\"\n        # Snapshot session IDs and cancel all timers under lock\n        async with self.lock:\n            session_ids = list(self.buffers.keys())\n            for sid in session_ids:\n                buffer = self.buffers.get(sid)\n                if buffer and buffer.timer_task and not buffer.timer_task.done():\n                    buffer.timer_task.cancel()\n\n        # Flush each buffer outside the lock\n        for session_id in session_ids:\n            await self._flush_buffer(session_id)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/bansess/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/bansess/bansess.py",
    "content": "from __future__ import annotations\n\nfrom .. import stage, entities\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n@stage.stage_class('BanSessionCheckStage')\nclass BanSessionCheckStage(stage.PipelineStage):\n    \"\"\"Access control processing stage\n\n    Only check if the group or personal number in the query is in the access control list.\n    \"\"\"\n\n    async def initialize(self, pipeline_config: dict):\n        pass\n\n    async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult:\n        found = False\n\n        mode = query.pipeline_config['trigger']['access-control']['mode']\n\n        sess_list = query.pipeline_config['trigger']['access-control'][mode]\n\n        if (query.launcher_type.value == 'group' and 'group_*' in sess_list) or (\n            query.launcher_type.value == 'person' and 'person_*' in sess_list\n        ):\n            found = True\n        else:\n            for sess in sess_list:\n                if sess == f'{query.launcher_type.value}_{query.launcher_id}':\n                    found = True\n                    break\n                # 使用 *_id 来表示加白/拉黑某用户的私聊和群聊场景\n                if sess.startswith('*_') and (sess[2:] == query.launcher_id or sess[2:] == query.sender_id):\n                    found = True\n                    break\n\n        ctn = False\n\n        if mode == 'whitelist':\n            ctn = found\n        else:\n            ctn = not found\n\n        return entities.StageProcessResult(\n            result_type=entities.ResultType.CONTINUE if ctn else entities.ResultType.INTERRUPT,\n            new_query=query,\n            console_notice=f'Ignore message according to access control: {query.launcher_type.value}_{query.launcher_id}'\n            if not ctn\n            else '',\n        )\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/cntfilter/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/cntfilter/cntfilter.py",
    "content": "from __future__ import annotations\n\nfrom ...core import app\n\nfrom .. import stage, entities\nfrom . import filter as filter_model, entities as filter_entities\nfrom langbot_plugin.api.entities.builtin.provider import message as provider_message\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nfrom ...utils import importutil\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nfrom . import filters\n\nimportutil.import_modules_in_pkg(filters)\n\n\n@stage.stage_class('PostContentFilterStage')\n@stage.stage_class('PreContentFilterStage')\nclass ContentFilterStage(stage.PipelineStage):\n    \"\"\"内容过滤阶段\n\n    前置：\n        检查消息是否符合规则，不符合则拦截。\n        改写：\n            message_chain\n\n    后置：\n        检查AI回复消息是否符合规则，可能进行改写，不符合则拦截。\n        改写：\n            query.resp_messages\n    \"\"\"\n\n    filter_chain: list[filter_model.ContentFilter]\n\n    def __init__(self, ap: app.Application):\n        self.filter_chain = []\n        super().__init__(ap)\n\n    async def initialize(self, pipeline_config: dict):\n        filters_required = [\n            'content-ignore',\n        ]\n\n        if pipeline_config['safety']['content-filter']['check-sensitive-words']:\n            filters_required.append('ban-word-filter')\n\n        # TODO revert it\n        # if self.ap.pipeline_cfg.data['baidu-cloud-examine']['enable']:\n        #     filters_required.append(\"baidu-cloud-examine\")\n\n        for filter in filter_model.preregistered_filters:\n            if filter.name in filters_required:\n                self.filter_chain.append(filter(self.ap))\n\n        for filter in self.filter_chain:\n            await filter.initialize()\n\n    async def _pre_process(\n        self,\n        message: str,\n        query: pipeline_query.Query,\n    ) -> entities.StageProcessResult:\n        \"\"\"请求llm前处理消息\n        只要有一个不通过就不放行，只放行 PASS 的消息\n        \"\"\"\n\n        if query.pipeline_config['safety']['content-filter']['scope'] == 'output-msg':\n            return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n        if not message.strip():\n            return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n        else:\n            for filter in self.filter_chain:\n                if filter_entities.EnableStage.PRE in filter.enable_stages:\n                    result = await filter.process(query, message)\n\n                    if result.level in [\n                        filter_entities.ResultLevel.BLOCK,\n                        filter_entities.ResultLevel.MASKED,\n                    ]:\n                        return entities.StageProcessResult(\n                            result_type=entities.ResultType.INTERRUPT,\n                            new_query=query,\n                            user_notice=result.user_notice,\n                            console_notice=result.console_notice,\n                        )\n                    elif result.level == filter_entities.ResultLevel.PASS:  # 传到下一个\n                        message = result.replacement\n\n            query.message_chain = platform_message.MessageChain([platform_message.Plain(text=message)])\n\n            return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n\n    async def _post_process(\n        self,\n        message: str,\n        query: pipeline_query.Query,\n    ) -> entities.StageProcessResult:\n        \"\"\"请求llm后处理响应\n        只要是 PASS 或者 MASKED 的就通过此 filter，将其 replacement 设置为message，进入下一个 filter\n        \"\"\"\n        if query.pipeline_config['safety']['content-filter']['scope'] == 'income-msg':\n            return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n        else:\n            message = message.strip()\n            for filter in self.filter_chain:\n                if filter_entities.EnableStage.POST in filter.enable_stages:\n                    result = await filter.process(query, message)\n\n                    if result.level == filter_entities.ResultLevel.BLOCK:\n                        return entities.StageProcessResult(\n                            result_type=entities.ResultType.INTERRUPT,\n                            new_query=query,\n                            user_notice=result.user_notice,\n                            console_notice=result.console_notice,\n                        )\n                    elif result.level in [\n                        filter_entities.ResultLevel.PASS,\n                        filter_entities.ResultLevel.MASKED,\n                    ]:\n                        message = result.replacement\n\n            query.resp_messages[-1].content = message\n\n            return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n\n    async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult:\n        \"\"\"处理\"\"\"\n        if stage_inst_name == 'PreContentFilterStage':\n            contain_non_text = False\n\n            text_components = [platform_message.Plain, platform_message.Source]\n\n            for me in query.message_chain:\n                if type(me) not in text_components:\n                    contain_non_text = True\n                    break\n\n            if contain_non_text:\n                self.ap.logger.debug('消息中包含非文本消息，跳过内容过滤器检查。')\n                return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n\n            return await self._pre_process(str(query.message_chain).strip(), query)\n        elif stage_inst_name == 'PostContentFilterStage':\n            # 仅处理 query.resp_messages[-1].content 是 str 的情况\n            if isinstance(query.resp_messages[-1], provider_message.Message) and isinstance(\n                query.resp_messages[-1].content, str\n            ):\n                return await self._post_process(query.resp_messages[-1].content, query)\n            else:\n                self.ap.logger.debug(\n                    'resp_messages[-1] 不是 Message 类型或 query.resp_messages[-1].content 不是 str 类型，跳过内容过滤器检查。'\n                )\n                return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n        else:\n            raise ValueError(f'未知的 stage_inst_name: {stage_inst_name}')\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/cntfilter/entities.py",
    "content": "import enum\n\nimport pydantic\n\n\nclass ResultLevel(enum.Enum):\n    \"\"\"结果等级\"\"\"\n\n    PASS = enum.auto()\n    \"\"\"通过\"\"\"\n\n    WARN = enum.auto()\n    \"\"\"警告\"\"\"\n\n    MASKED = enum.auto()\n    \"\"\"已掩去\"\"\"\n\n    BLOCK = enum.auto()\n    \"\"\"阻止\"\"\"\n\n\nclass EnableStage(enum.Enum):\n    \"\"\"启用阶段\"\"\"\n\n    PRE = enum.auto()\n    \"\"\"预处理\"\"\"\n\n    POST = enum.auto()\n    \"\"\"后处理\"\"\"\n\n\nclass FilterResult(pydantic.BaseModel):\n    level: ResultLevel\n    \"\"\"结果等级\n\n    对于前置处理阶段，只要有任意一个返回 非PASS 的内容过滤器结果，就会中断处理。\n    对于后置处理阶段，当且内容过滤器返回 BLOCK 时，会中断处理。\n    \"\"\"\n\n    replacement: str\n    \"\"\"替换后的文本消息\n    \n    内容过滤器可以进行一些遮掩处理，然后把遮掩后的消息返回。\n    若没有修改内容，也需要返回原消息。\n    \"\"\"\n\n    user_notice: str\n    \"\"\"不通过时，若此值不为空，将对用户提示消息\"\"\"\n\n    console_notice: str\n    \"\"\"不通过时，若此值不为空，将在控制台提示消息\"\"\"\n\n\nclass ManagerResultLevel(enum.Enum):\n    \"\"\"处理器结果等级\"\"\"\n\n    CONTINUE = enum.auto()\n    \"\"\"继续\"\"\"\n\n    INTERRUPT = enum.auto()\n    \"\"\"中断\"\"\"\n\n\nclass FilterManagerResult(pydantic.BaseModel):\n    level: ManagerResultLevel\n\n    replacement: str\n    \"\"\"替换后的消息\"\"\"\n\n    user_notice: str\n    \"\"\"用户提示消息\"\"\"\n\n    console_notice: str\n    \"\"\"控制台提示消息\"\"\"\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/cntfilter/filter.py",
    "content": "# 内容过滤器的抽象类\nfrom __future__ import annotations\nimport abc\nimport typing\n\nfrom ...core import app\nfrom . import entities\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\npreregistered_filters: list[typing.Type[ContentFilter]] = []\n\n\ndef filter_class(\n    name: str,\n) -> typing.Callable[[typing.Type[ContentFilter]], typing.Type[ContentFilter]]:\n    \"\"\"Content filter class decorator\n\n    Args:\n        name (str): Filter name\n\n    Returns:\n        typing.Callable[[typing.Type[ContentFilter]], typing.Type[ContentFilter]]: Decorator\n    \"\"\"\n\n    def decorator(cls: typing.Type[ContentFilter]) -> typing.Type[ContentFilter]:\n        assert issubclass(cls, ContentFilter)\n\n        cls.name = name\n\n        preregistered_filters.append(cls)\n\n        return cls\n\n    return decorator\n\n\nclass ContentFilter(metaclass=abc.ABCMeta):\n    \"\"\"Content filter abstract class\"\"\"\n\n    name: str\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    @property\n    def enable_stages(self):\n        \"\"\"Enabled stages\n\n        Default is the two stages before and after the message request to AI.\n\n        entity.EnableStage.PRE: Before message request to AI, the content to check is the user's input message.\n        entity.EnableStage.POST: After message request to AI, the content to check is the AI's reply message.\n        \"\"\"\n        return [entities.EnableStage.PRE, entities.EnableStage.POST]\n\n    async def initialize(self):\n        \"\"\"Initialize filter\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def process(self, query: pipeline_query.Query, message: str = None, image_url=None) -> entities.FilterResult:\n        \"\"\"处理消息\n\n        It is divided into two stages, depending on the value of enable_stages.\n        For content filters, you do not need to consider the stage of the message, you only need to check the message content.\n\n        Args:\n            message (str): Content to check\n            image_url (str): URL of the image to check\n\n        Returns:\n            entities.FilterResult: Filter result, please refer to the documentation of entities.FilterResult class\n        \"\"\"\n        raise NotImplementedError\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/cntfilter/filters/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py",
    "content": "from __future__ import annotations\n\nfrom .. import entities\nfrom .. import filter as filter_model\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nfrom langbot.pkg.utils import httpclient\n\nBAIDU_EXAMINE_URL = 'https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined?access_token={}'\nBAIDU_EXAMINE_TOKEN_URL = 'https://aip.baidubce.com/oauth/2.0/token'\n\n\n@filter_model.filter_class('baidu-cloud-examine')\nclass BaiduCloudExamine(filter_model.ContentFilter):\n    \"\"\"百度云内容审核\"\"\"\n\n    async def _get_token(self) -> str:\n        session = httpclient.get_session()\n        async with session.post(\n            BAIDU_EXAMINE_TOKEN_URL,\n            params={\n                'grant_type': 'client_credentials',\n                'client_id': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-key'],\n                'client_secret': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-secret'],\n            },\n        ) as resp:\n            return (await resp.json())['access_token']\n\n    async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:\n        session = httpclient.get_session()\n        async with session.post(\n            BAIDU_EXAMINE_URL.format(await self._get_token()),\n            headers={\n                'Content-Type': 'application/x-www-form-urlencoded',\n                'Accept': 'application/json',\n            },\n            data=f'text={message}'.encode('utf-8'),\n        ) as resp:\n            result = await resp.json()\n\n            if 'error_code' in result:\n                return entities.FilterResult(\n                    level=entities.ResultLevel.BLOCK,\n                    replacement=message,\n                    user_notice='',\n                    console_notice=f'百度云判定出错，错误信息：{result[\"error_msg\"]}',\n                )\n            else:\n                conclusion = result['conclusion']\n\n                if conclusion in ('合规'):\n                    return entities.FilterResult(\n                        level=entities.ResultLevel.PASS,\n                        replacement=message,\n                        user_notice='',\n                        console_notice=f'百度云判定结果：{conclusion}',\n                    )\n                else:\n                    return entities.FilterResult(\n                        level=entities.ResultLevel.BLOCK,\n                        replacement=message,\n                        user_notice='消息中存在不合适的内容, 请修改',\n                        console_notice=f'百度云判定结果：{conclusion}',\n                    )\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/cntfilter/filters/banwords.py",
    "content": "from __future__ import annotations\nimport re\n\nfrom .. import filter as filter_model\nfrom .. import entities\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n@filter_model.filter_class('ban-word-filter')\nclass BanWordFilter(filter_model.ContentFilter):\n    \"\"\"Filter content\"\"\"\n\n    async def initialize(self):\n        pass\n\n    async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:\n        found = False\n\n        for word in self.ap.sensitive_meta.data['words']:\n            match = re.findall(word, message)\n\n            if len(match) > 0:\n                found = True\n\n                for i in range(len(match)):\n                    if self.ap.sensitive_meta.data['mask_word'] == '':\n                        message = message.replace(\n                            match[i],\n                            self.ap.sensitive_meta.data['mask'] * len(match[i]),\n                        )\n                    else:\n                        message = message.replace(match[i], self.ap.sensitive_meta.data['mask_word'])\n\n        return entities.FilterResult(\n            level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,\n            replacement=message,\n            user_notice='消息中存在不合适的内容, 请修改' if found else '',\n            console_notice='',\n        )\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py",
    "content": "from __future__ import annotations\nimport re\n\nfrom .. import entities\nfrom .. import filter as filter_model\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n@filter_model.filter_class('content-ignore')\nclass ContentIgnore(filter_model.ContentFilter):\n    \"\"\"Ignore message according to content\"\"\"\n\n    @property\n    def enable_stages(self):\n        return [\n            entities.EnableStage.PRE,\n        ]\n\n    async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:\n        if 'prefix' in query.pipeline_config['trigger']['ignore-rules']:\n            for rule in query.pipeline_config['trigger']['ignore-rules']['prefix']:\n                if message.startswith(rule):\n                    return entities.FilterResult(\n                        level=entities.ResultLevel.BLOCK,\n                        replacement='',\n                        user_notice='',\n                        console_notice='Ignore message according to prefix rule in ignore_rules',\n                    )\n\n        if 'regexp' in query.pipeline_config['trigger']['ignore-rules']:\n            for rule in query.pipeline_config['trigger']['ignore-rules']['regexp']:\n                if re.search(rule, message):\n                    return entities.FilterResult(\n                        level=entities.ResultLevel.BLOCK,\n                        replacement='',\n                        user_notice='',\n                        console_notice='Ignore message according to regexp rule in ignore_rules',\n                    )\n\n        return entities.FilterResult(\n            level=entities.ResultLevel.PASS,\n            replacement=message,\n            user_notice='',\n            console_notice='',\n        )\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/config_coercion.py",
    "content": "from __future__ import annotations\n\nimport logging\n\nlogger = logging.getLogger(__name__)\n\n# metadata type -> coercion function\n_COERCE_MAP = {\n    'integer': lambda v: int(v),\n    'number': lambda v: float(v),\n    'float': lambda v: float(v),\n}\n\n\ndef _coerce_bool(v):\n    if isinstance(v, bool):\n        return v\n    if isinstance(v, str):\n        if v.lower() == 'true':\n            return True\n        if v.lower() == 'false':\n            return False\n        raise ValueError(f'Cannot convert string {v!r} to bool')\n    return bool(v)\n\n\ndef _coerce_value(value, expected_type: str):\n    \"\"\"Convert a single value to the expected type.\n\n    Returns the converted value, or the original value if no conversion needed.\n    \"\"\"\n    if value is None:\n        return value\n\n    if expected_type == 'boolean':\n        if isinstance(value, bool):\n            return value\n        return _coerce_bool(value)\n\n    coerce_fn = _COERCE_MAP.get(expected_type)\n    if coerce_fn is None:\n        return value\n\n    # Already the correct type\n    if expected_type == 'integer' and isinstance(value, int) and not isinstance(value, bool):\n        return value\n    if expected_type in ('number', 'float') and isinstance(value, (int, float)) and not isinstance(value, bool):\n        return float(value)\n\n    return coerce_fn(value)\n\n\ndef coerce_pipeline_config(\n    config: dict,\n    *metadata_list: dict,\n) -> None:\n    \"\"\"Coerce pipeline config values according to metadata type definitions.\n\n    Walks each metadata dict (trigger, safety, ai, output) and converts\n    config values in-place so that strings coming from the JSON column are\n    cast to their declared types (integer, number/float, boolean).\n\n    Args:\n        config: The pipeline config dict to modify in-place.\n        *metadata_list: Metadata dicts loaded from the YAML templates.\n    \"\"\"\n    for meta in metadata_list:\n        section_name = meta.get('name')\n        if not section_name or section_name not in config:\n            continue\n\n        section = config[section_name]\n        if not isinstance(section, dict):\n            continue\n\n        for stage_def in meta.get('stages', []):\n            stage_name = stage_def.get('name')\n            if not stage_name or stage_name not in section:\n                continue\n\n            stage_config = section[stage_name]\n            if not isinstance(stage_config, dict):\n                continue\n\n            for field_def in stage_def.get('config', []):\n                field_name = field_def.get('name')\n                field_type = field_def.get('type')\n                if not field_name or not field_type or field_name not in stage_config:\n                    continue\n\n                old_value = stage_config[field_name]\n                try:\n                    new_value = _coerce_value(old_value, field_type)\n                    if new_value is not old_value:\n                        stage_config[field_name] = new_value\n                except (ValueError, TypeError) as e:\n                    logger.warning(\n                        'Failed to coerce config %s.%s.%s (%r) to %s: %s',\n                        section_name,\n                        stage_name,\n                        field_name,\n                        old_value,\n                        field_type,\n                        e,\n                    )\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/controller.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport traceback\n\nfrom ..core import app\nfrom ..core import entities as core_entities\n\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\nclass Controller:\n    \"\"\"总控制器\"\"\"\n\n    ap: app.Application\n\n    semaphore: asyncio.Semaphore = None\n    \"\"\"请求并发控制信号量\"\"\"\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        self.semaphore = asyncio.Semaphore(self.ap.instance_config.data['concurrency']['pipeline'])\n\n    async def consumer(self):\n        \"\"\"事件处理循环\"\"\"\n        try:\n            while True:\n                selected_query: pipeline_query.Query = None\n\n                # 取请求\n                async with self.ap.query_pool:\n                    queries: list[pipeline_query.Query] = self.ap.query_pool.queries\n\n                    for query in queries:\n                        session = await self.ap.sess_mgr.get_session(query)\n                        # Debug logging removed from tight loop to prevent excessive log generation\n                        # that can cause memory overflow in high-traffic scenarios\n\n                        if not session._semaphore.locked():\n                            selected_query = query\n                            await session._semaphore.acquire()\n                            # Only log when actually selecting a query\n                            self.ap.logger.debug(f'Selected query {query.query_id} for processing')\n\n                            break\n\n                    if selected_query:  # 找到了\n                        queries.remove(selected_query)\n                    else:  # 没找到 说明：没有请求 或者 所有query对应的session都已达到并发上限\n                        await self.ap.query_pool.condition.wait()\n                        continue\n\n                if selected_query:\n\n                    async def _process_query(selected_query: pipeline_query.Query):\n                        async with self.semaphore:  # 总并发上限\n                            # find pipeline\n                            # Here firstly find the bot, then find the pipeline, in case the bot adapter's config is not the latest one.\n                            # Like aiocqhttp, once a client is connected, even the adapter was updated and restarted, the existing client connection will not be affected.\n                            pipeline_uuid = selected_query.pipeline_uuid\n\n                            if pipeline_uuid:\n                                pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)\n                                if pipeline:\n                                    await pipeline.run(selected_query)\n\n                        async with self.ap.query_pool:\n                            (await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()\n                            # 通知其他协程，有新的请求可以处理了\n                            self.ap.query_pool.condition.notify_all()\n\n                    self.ap.task_mgr.create_task(\n                        _process_query(selected_query),\n                        kind='query',\n                        name=f'query-{selected_query.query_id}',\n                        scopes=[\n                            core_entities.LifecycleControlScope.APPLICATION,\n                            core_entities.LifecycleControlScope.PLATFORM,\n                        ],\n                    )\n\n        except Exception as e:\n            # traceback.print_exc()\n            self.ap.logger.error(f'控制器循环出错: {e}')\n            self.ap.logger.error(f'Traceback: {traceback.format_exc()}')\n\n    async def run(self):\n        \"\"\"运行控制器\"\"\"\n        await self.consumer()\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/entities.py",
    "content": "from __future__ import annotations\n\nimport enum\nimport typing\n\nimport pydantic\n\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\n\n\nclass ResultType(enum.Enum):\n    CONTINUE = enum.auto()\n    \"\"\"继续流水线\"\"\"\n\n    INTERRUPT = enum.auto()\n    \"\"\"中断流水线\"\"\"\n\n\nclass StageProcessResult(pydantic.BaseModel):\n    result_type: ResultType\n\n    new_query: pipeline_query.Query\n\n    user_notice: typing.Optional[\n        typing.Union[\n            str,\n            list[platform_message.MessageComponent],\n            platform_message.MessageChain,\n            None,\n        ]\n    ] = []\n    \"\"\"只要设置了就会发送给用户\"\"\"\n\n    console_notice: typing.Optional[str] = ''\n    \"\"\"只要设置了就会输出到控制台\"\"\"\n\n    debug_notice: typing.Optional[str] = ''\n\n    error_notice: typing.Optional[str] = ''\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/longtext/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/longtext/longtext.py",
    "content": "from __future__ import annotations\nimport os\nimport traceback\n\n\nfrom . import strategy\nfrom .. import stage, entities\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nfrom ...utils import importutil\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nfrom . import strategies\n\nimportutil.import_modules_in_pkg(strategies)\n\n\n@stage.stage_class('LongTextProcessStage')\nclass LongTextProcessStage(stage.PipelineStage):\n    \"\"\"Long message processing stage\n\n    Rewrite:\n        - resp_message_chain\n    \"\"\"\n\n    strategy_impl: strategy.LongTextStrategy | None\n\n    async def initialize(self, pipeline_config: dict):\n        config = pipeline_config['output']['long-text-processing']\n\n        if config['strategy'] == 'none':\n            self.strategy_impl = None\n            return\n\n        if config['strategy'] == 'image':\n            use_font = config['font-path']\n            try:\n                # 检查是否存在\n                if not os.path.exists(use_font):\n                    # 若是windows系统，使用微软雅黑\n                    if os.name == 'nt':\n                        use_font = 'C:/Windows/Fonts/msyh.ttc'\n                        if not os.path.exists(use_font):\n                            self.ap.logger.warn(\n                                'Font file not found, and Windows system font cannot be used, switch to forward message component to send long messages, you can adjust the related settings in the configuration file.'\n                            )\n                            config['blob_message_strategy'] = 'forward'\n                        else:\n                            self.ap.logger.info('Using Windows system font: ' + use_font)\n                            config['font-path'] = use_font\n                    else:\n                        self.ap.logger.warn(\n                            'Font file not found, and system font cannot be used, switch to forward message component to send long messages, you can adjust the related settings in the configuration file.'\n                        )\n\n                        pipeline_config['output']['long-text-processing']['strategy'] = 'forward'\n            except Exception:\n                traceback.print_exc()\n                self.ap.logger.error(\n                    'Failed to load font file ({}), switch to forward message component to send long messages, you can adjust the related settings in the configuration file.'.format(\n                        use_font\n                    )\n                )\n\n                pipeline_config['output']['long-text-processing']['strategy'] = 'forward'\n\n        for strategy_cls in strategy.preregistered_strategies:\n            if strategy_cls.name == config['strategy']:\n                self.strategy_impl = strategy_cls(self.ap)\n                break\n        else:\n            raise ValueError(f'Long message processing strategy not found: {config[\"strategy\"]}')\n\n        await self.strategy_impl.initialize()\n\n    async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult:\n        if self.strategy_impl is None:\n            self.ap.logger.debug('Long message processing strategy is not set, skip long message processing.')\n            return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n\n        # 检查是否包含非 Plain 组件\n        contains_non_plain = False\n\n        for msg in query.resp_message_chain[-1]:\n            if not isinstance(msg, platform_message.Plain):\n                contains_non_plain = True\n                break\n\n        if contains_non_plain:\n            self.ap.logger.debug('Message contains non-Plain components, skip long message processing.')\n        elif (\n            len(str(query.resp_message_chain[-1]))\n            > query.pipeline_config['output']['long-text-processing']['threshold']\n        ):\n            query.resp_message_chain[-1] = platform_message.MessageChain(\n                await self.strategy_impl.process(str(query.resp_message_chain[-1]), query)\n            )\n\n        return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/longtext/strategies/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/longtext/strategies/forward.py",
    "content": "# 转发消息组件\nfrom __future__ import annotations\n\n\nfrom .. import strategy as strategy_model\n\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\n\nForwardMessageDiaplay = platform_message.ForwardMessageDiaplay\nForward = platform_message.Forward\n\n\n@strategy_model.strategy_class('forward')\nclass ForwardComponentStrategy(strategy_model.LongTextStrategy):\n    async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]:\n        display = ForwardMessageDiaplay(\n            title='Group chat history',\n            brief='[Chat history]',\n            source='Chat history',\n            preview=['User: ' + message],\n            summary='View 1 forwarded message',\n        )\n\n        node_list = [\n            platform_message.ForwardMessageNode(\n                sender_id=query.adapter.bot_account_id,\n                sender_name='User',\n                message_chain=platform_message.MessageChain([platform_message.Plain(text=message)]),\n            )\n        ]\n\n        forward = Forward(display=display, node_list=node_list)\n\n        return [forward]\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/longtext/strategies/image.py",
    "content": "from __future__ import annotations\n\nimport os\nimport base64\nimport time\nimport re\n\nfrom PIL import Image, ImageDraw, ImageFont\n\nimport functools\n\nfrom .. import strategy as strategy_model\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\n\n\n@strategy_model.strategy_class('image')\nclass Text2ImageStrategy(strategy_model.LongTextStrategy):\n    async def initialize(self):\n        pass\n\n    @functools.lru_cache(maxsize=16)\n    def get_font(self, font_path: str):\n        return ImageFont.truetype(\n            font_path,\n            32,\n            encoding='utf-8',\n        )\n\n    async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]:\n        img_path = self.text_to_image(\n            text_str=message,\n            save_as='temp/{}.png'.format(int(time.time())),\n            query=query,\n        )\n\n        compressed_path, size = self.compress_image(img_path, outfile='temp/{}_compressed.png'.format(int(time.time())))\n\n        with open(compressed_path, 'rb') as f:\n            img = f.read()\n\n        b64 = base64.b64encode(img)\n\n        # 删除图片\n        os.remove(img_path)\n\n        if os.path.exists(compressed_path):\n            os.remove(compressed_path)\n\n        return [\n            platform_message.Image(\n                base64=b64.decode('utf-8'),\n            )\n        ]\n\n    def indexNumber(self, path=''):\n        \"\"\"\n        查找字符串中数字所在串中的位置\n        :param path:目标字符串\n        :return:<class 'list'>: <class 'list'>: [['1', 16], ['2', 35], ['1', 51]]\n        \"\"\"\n        kv = []\n        nums = []\n        beforeDatas = re.findall('[\\\\d]+', path)\n        for num in beforeDatas:\n            indexV = []\n            times = path.count(num)\n            if times > 1:\n                if num not in nums:\n                    indexs = re.finditer(num, path)\n                    for index in indexs:\n                        iV = []\n                        i = index.span()[0]\n                        iV.append(num)\n                        iV.append(i)\n                        kv.append(iV)\n                nums.append(num)\n            else:\n                index = path.find(num)\n                indexV.append(num)\n                indexV.append(index)\n                kv.append(indexV)\n        # 根据数字位置排序\n        indexSort = []\n        resultIndex = []\n        for vi in kv:\n            indexSort.append(vi[1])\n        indexSort.sort()\n        for i in indexSort:\n            for v in kv:\n                if i == v[1]:\n                    resultIndex.append(v)\n        return resultIndex\n\n    def get_size(self, file):\n        # 获取文件大小:KB\n        size = os.path.getsize(file)\n        return size / 1024\n\n    def get_outfile(self, infile, outfile):\n        if outfile:\n            return outfile\n        dir, suffix = os.path.splitext(infile)\n        outfile = '{}-out{}'.format(dir, suffix)\n        return outfile\n\n    def compress_image(self, infile, outfile='', kb=100, step=20, quality=90):\n        \"\"\"不改变图片尺寸压缩到指定大小\n        :param infile: 压缩源文件\n        :param outfile: 压缩文件保存地址\n        :param mb: 压缩目标,KB\n        :param step: 每次调整的压缩比率\n        :param quality: 初始压缩比率\n        :return: 压缩文件地址，压缩文件大小\n        \"\"\"\n        o_size = self.get_size(infile)\n        if o_size <= kb:\n            return infile, o_size\n        outfile = self.get_outfile(infile, outfile)\n        while o_size > kb:\n            im = Image.open(infile)\n            im.save(outfile, quality=quality)\n            if quality - step < 0:\n                break\n            quality -= step\n            o_size = self.get_size(outfile)\n        return outfile, self.get_size(outfile)\n\n    def text_to_image(\n        self,\n        text_str: str,\n        save_as='temp.png',\n        width=800,\n        query: pipeline_query.Query = None,\n    ):\n        text_str = text_str.replace('\\t', '    ')\n\n        # 分行\n        lines = text_str.split('\\n')\n\n        # 计算并分割\n        final_lines = []\n\n        text_width = width - 80\n\n        self.ap.logger.debug('lines: {}, text_width: {}'.format(lines, text_width))\n        for line in lines:\n            # 如果长了就分割\n            line_width = self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']).getlength(\n                line\n            )\n            self.ap.logger.debug('line_width: {}'.format(line_width))\n            if line_width < text_width:\n                final_lines.append(line)\n                continue\n            else:\n                rest_text = line\n                while True:\n                    # 分割最前面的一行\n                    point = int(len(rest_text) * (text_width / line_width))\n\n                    # 检查断点是否在数字中间\n                    numbers = self.indexNumber(rest_text)\n\n                    for number in numbers:\n                        if number[1] < point < number[1] + len(number[0]) and number[1] != 0:\n                            point = number[1]\n                            break\n\n                    final_lines.append(rest_text[:point])\n                    rest_text = rest_text[point:]\n                    line_width = self.get_font(\n                        query.pipeline_config['output']['long-text-processing']['font-path']\n                    ).getlength(rest_text)\n                    if line_width < text_width:\n                        final_lines.append(rest_text)\n                        break\n                    else:\n                        continue\n        # 准备画布\n        img = Image.new('RGBA', (width, max(280, len(final_lines) * 35 + 65)), (255, 255, 255, 255))\n        draw = ImageDraw.Draw(img, mode='RGBA')\n\n        self.ap.logger.debug('正在绘制图片...')\n        # 绘制正文\n        line_number = 0\n        offset_x = 20\n        offset_y = 30\n        for final_line in final_lines:\n            draw.text(\n                (offset_x, offset_y + 35 * line_number),\n                final_line,\n                fill=(0, 0, 0),\n                font=self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']),\n            )\n            # 遍历此行,检查是否有emoji\n            idx_in_line = 0\n            for ch in final_line:\n                # 检查字符占位宽\n                char_code = ord(ch)\n                if char_code >= 127:\n                    idx_in_line += 1\n                else:\n                    idx_in_line += 0.5\n\n            line_number += 1\n\n        self.ap.logger.debug('正在保存图片...')\n        img.save(save_as)\n\n        return save_as\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/longtext/strategy.py",
    "content": "from __future__ import annotations\nimport abc\nimport typing\n\n\nfrom ...core import app\n\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\npreregistered_strategies: list[typing.Type[LongTextStrategy]] = []\n\n\ndef strategy_class(\n    name: str,\n) -> typing.Callable[[typing.Type[LongTextStrategy]], typing.Type[LongTextStrategy]]:\n    \"\"\"Long text processing strategy class decorator\n\n    Args:\n        name (str): Strategy name\n\n    Returns:\n        typing.Callable[[typing.Type[LongTextStrategy]], typing.Type[LongTextStrategy]]: Decorator\n    \"\"\"\n\n    def decorator(cls: typing.Type[LongTextStrategy]) -> typing.Type[LongTextStrategy]:\n        assert issubclass(cls, LongTextStrategy)\n\n        cls.name = name\n\n        preregistered_strategies.append(cls)\n\n        return cls\n\n    return decorator\n\n\nclass LongTextStrategy(metaclass=abc.ABCMeta):\n    \"\"\"Long text processing strategy abstract class\"\"\"\n\n    name: str\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        pass\n\n    @abc.abstractmethod\n    async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]:\n        \"\"\"处理长文本\n\n        If the text length exceeds the threshold, this method will be called.\n\n        Args:\n            message (str): Message\n            query (core_entities.Query): Query object\n\n        Returns:\n            list[platform_message.MessageComponent]: Converted platform message components\n        \"\"\"\n        return []\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/monitoring_helper.py",
    "content": "\"\"\"\nMonitoring helper for recording events during pipeline execution.\nThis module provides convenient methods to record monitoring data\nwithout cluttering the main pipeline code.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport traceback\nimport typing\nimport time\nimport json\n\nif typing.TYPE_CHECKING:\n    from ..core import app\n    import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\nclass MonitoringHelper:\n    \"\"\"Helper class for monitoring operations\"\"\"\n\n    @staticmethod\n    async def record_query_start(\n        ap: app.Application,\n        query: pipeline_query.Query,\n        bot_id: str,\n        bot_name: str,\n        pipeline_id: str,\n        pipeline_name: str,\n        runner_name: str | None = None,\n    ) -> str:\n        \"\"\"Record the start of query processing, returns message_id\"\"\"\n        try:\n            # Check if session exists, if not, record session start\n            session_id = f'{query.launcher_type}_{query.launcher_id}'\n\n            # Get sender name from message event\n            sender_name = None\n            if hasattr(query, 'message_event'):\n                if hasattr(query.message_event, 'sender'):\n                    if hasattr(query.message_event.sender, 'nickname'):\n                        sender_name = query.message_event.sender.nickname\n                    elif hasattr(query.message_event.sender, 'member_name'):\n                        sender_name = query.message_event.sender.member_name\n\n            # Try to record message\n            # Use JSON serialization to preserve message chain structure (including image URLs, etc.)\n            if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):\n                message_content = json.dumps(query.message_chain.model_dump(), ensure_ascii=False)\n            else:\n                message_content = str(query)\n\n            # Variables will be updated in record_query_success after preproc stage sets them\n            # Here we just record None, the full variables will be set when query completes\n\n            message_id = await ap.monitoring_service.record_message(\n                bot_id=bot_id,\n                bot_name=bot_name,\n                pipeline_id=pipeline_id,\n                pipeline_name=pipeline_name,\n                message_content=message_content,\n                session_id=session_id,\n                status='pending',\n                level='info',\n                platform=query.launcher_type.value\n                if hasattr(query.launcher_type, 'value')\n                else str(query.launcher_type),\n                user_id=query.sender_id,\n                user_name=sender_name,\n                runner_name=runner_name,\n                variables=None,  # Will be updated in record_query_success\n            )\n\n            # Update session activity or create new session if it doesn't exist\n            # Always pass pipeline info to handle pipeline switches\n            session_updated = await ap.monitoring_service.update_session_activity(\n                session_id,\n                pipeline_id=pipeline_id,\n                pipeline_name=pipeline_name,\n            )\n            if not session_updated:\n                # Session doesn't exist, create it\n                await ap.monitoring_service.record_session_start(\n                    session_id=session_id,\n                    bot_id=bot_id,\n                    bot_name=bot_name,\n                    pipeline_id=pipeline_id,\n                    pipeline_name=pipeline_name,\n                    platform=query.launcher_type.value\n                    if hasattr(query.launcher_type, 'value')\n                    else str(query.launcher_type),\n                    user_id=query.sender_id,\n                    user_name=sender_name,\n                )\n\n            return message_id\n        except Exception as e:\n            ap.logger.error(f'Failed to record query start: {e}')\n            return ''\n\n    @staticmethod\n    async def record_query_success(\n        ap: app.Application,\n        message_id: str,\n        query: pipeline_query.Query | None = None,\n    ):\n        \"\"\"Record successful query processing by updating message status and variables\"\"\"\n        try:\n            if message_id:\n                # Serialize query.variables (filtering out internal variables)\n                query_variables_str = None\n                if query and hasattr(query, 'variables') and query.variables:\n                    filtered_vars = {k: v for k, v in query.variables.items() if not k.startswith('_')}\n                    if filtered_vars:\n                        try:\n                            query_variables_str = json.dumps(filtered_vars, ensure_ascii=False, default=str)\n                        except Exception:\n                            pass\n\n                await ap.monitoring_service.update_message_status(\n                    message_id=message_id,\n                    status='success',\n                    variables=query_variables_str,\n                )\n        except Exception as e:\n            ap.logger.error(f'Failed to record query success: {e}')\n\n    @staticmethod\n    async def record_query_response(\n        ap: app.Application,\n        query: pipeline_query.Query,\n        bot_id: str,\n        bot_name: str,\n        pipeline_id: str,\n        pipeline_name: str,\n        runner_name: str | None = None,\n    ):\n        \"\"\"Record bot response message to monitoring\"\"\"\n        try:\n            session_id = f'{query.launcher_type}_{query.launcher_id}'\n\n            # Get sender name from message event\n            sender_name = None\n            if hasattr(query, 'message_event'):\n                if hasattr(query.message_event, 'sender'):\n                    if hasattr(query.message_event.sender, 'nickname'):\n                        sender_name = query.message_event.sender.nickname\n                    elif hasattr(query.message_event.sender, 'member_name'):\n                        sender_name = query.message_event.sender.member_name\n\n            # Extract response content from resp_message_chain\n            if hasattr(query, 'resp_message_chain') and query.resp_message_chain:\n                # Serialize the last response message chain\n                last_resp = query.resp_message_chain[-1]\n                if hasattr(last_resp, 'model_dump'):\n                    message_content = json.dumps(last_resp.model_dump(), ensure_ascii=False)\n                else:\n                    message_content = str(last_resp)\n            elif hasattr(query, 'resp_messages') and query.resp_messages:\n                last_resp = query.resp_messages[-1]\n                if hasattr(last_resp, 'get_content_platform_message_chain'):\n                    chain = last_resp.get_content_platform_message_chain()\n                    if hasattr(chain, 'model_dump'):\n                        message_content = json.dumps(chain.model_dump(), ensure_ascii=False)\n                    else:\n                        message_content = str(chain)\n                else:\n                    message_content = str(last_resp)\n            else:\n                return  # No response to record\n\n            await ap.monitoring_service.record_message(\n                bot_id=bot_id,\n                bot_name=bot_name,\n                pipeline_id=pipeline_id,\n                pipeline_name=pipeline_name,\n                message_content=message_content,\n                session_id=session_id,\n                status='success',\n                level='info',\n                platform=query.launcher_type.value\n                if hasattr(query.launcher_type, 'value')\n                else str(query.launcher_type),\n                user_id=query.sender_id,\n                user_name=sender_name,\n                runner_name=runner_name,\n                role='assistant',\n            )\n        except Exception as e:\n            ap.logger.error(f'Failed to record query response: {e}')\n\n    @staticmethod\n    async def record_query_error(\n        ap: app.Application,\n        query: pipeline_query.Query,\n        bot_id: str,\n        bot_name: str,\n        pipeline_id: str,\n        pipeline_name: str,\n        error: Exception,\n        runner_name: str | None = None,\n    ) -> str:\n        \"\"\"Record query processing error, returns message_id\"\"\"\n        try:\n            session_id = f'{query.launcher_type}_{query.launcher_id}'\n\n            # Get sender name from message event\n            sender_name = None\n            if hasattr(query, 'message_event'):\n                if hasattr(query.message_event, 'sender'):\n                    if hasattr(query.message_event.sender, 'nickname'):\n                        sender_name = query.message_event.sender.nickname\n                    elif hasattr(query.message_event.sender, 'member_name'):\n                        sender_name = query.message_event.sender.member_name\n\n            # Record error message\n            message_id = await ap.monitoring_service.record_message(\n                bot_id=bot_id,\n                bot_name=bot_name,\n                pipeline_id=pipeline_id,\n                pipeline_name=pipeline_name,\n                message_content=f'Error: {str(error)}',\n                session_id=session_id,\n                status='error',\n                level='error',\n                platform=query.launcher_type.value\n                if hasattr(query.launcher_type, 'value')\n                else str(query.launcher_type),\n                user_id=query.sender_id,\n                user_name=sender_name,\n                runner_name=runner_name,\n            )\n\n            # Record error log\n            await ap.monitoring_service.record_error(\n                bot_id=bot_id,\n                bot_name=bot_name,\n                pipeline_id=pipeline_id,\n                pipeline_name=pipeline_name,\n                error_type=type(error).__name__,\n                error_message=str(error),\n                session_id=session_id,\n                stack_trace=traceback.format_exc(),\n                message_id=message_id,\n            )\n\n            return message_id\n        except Exception as e:\n            ap.logger.error(f'Failed to record query error: {e}')\n            return ''\n\n    @staticmethod\n    async def record_llm_call(\n        ap: app.Application,\n        query: pipeline_query.Query,\n        bot_id: str,\n        bot_name: str,\n        pipeline_id: str,\n        pipeline_name: str,\n        model_name: str,\n        input_tokens: int,\n        output_tokens: int,\n        duration_ms: int,\n        status: str = 'success',\n        cost: float | None = None,\n        error_message: str | None = None,\n        message_id: str | None = None,\n    ):\n        \"\"\"Record LLM call\"\"\"\n        try:\n            session_id = f'{query.launcher_type}_{query.launcher_id}'\n\n            await ap.monitoring_service.record_llm_call(\n                bot_id=bot_id,\n                bot_name=bot_name,\n                pipeline_id=pipeline_id,\n                pipeline_name=pipeline_name,\n                session_id=session_id,\n                model_name=model_name,\n                input_tokens=input_tokens,\n                output_tokens=output_tokens,\n                duration=duration_ms,\n                status=status,\n                cost=cost,\n                error_message=error_message,\n                message_id=message_id,\n            )\n        except Exception as e:\n            ap.logger.error(f'Failed to record LLM call: {e}')\n\n\nclass LLMCallMonitor:\n    \"\"\"Context manager for monitoring LLM calls\"\"\"\n\n    def __init__(\n        self,\n        ap: app.Application,\n        query: pipeline_query.Query,\n        bot_id: str,\n        bot_name: str,\n        pipeline_id: str,\n        pipeline_name: str,\n        model_name: str,\n    ):\n        self.ap = ap\n        self.query = query\n        self.bot_id = bot_id\n        self.bot_name = bot_name\n        self.pipeline_id = pipeline_id\n        self.pipeline_name = pipeline_name\n        self.model_name = model_name\n        self.start_time = None\n        self.input_tokens = 0\n        self.output_tokens = 0\n\n    async def __aenter__(self):\n        self.start_time = time.time()\n        return self\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        duration_ms = int((time.time() - self.start_time) * 1000)\n\n        if exc_type is not None:\n            # Error occurred\n            await MonitoringHelper.record_llm_call(\n                ap=self.ap,\n                query=self.query,\n                bot_id=self.bot_id,\n                bot_name=self.bot_name,\n                pipeline_id=self.pipeline_id,\n                pipeline_name=self.pipeline_name,\n                model_name=self.model_name,\n                input_tokens=self.input_tokens,\n                output_tokens=self.output_tokens,\n                duration_ms=duration_ms,\n                status='error',\n                error_message=str(exc_val) if exc_val else None,\n            )\n        else:\n            # Success\n            await MonitoringHelper.record_llm_call(\n                ap=self.ap,\n                query=self.query,\n                bot_id=self.bot_id,\n                bot_name=self.bot_name,\n                pipeline_id=self.pipeline_id,\n                pipeline_name=self.pipeline_name,\n                model_name=self.model_name,\n                input_tokens=self.input_tokens,\n                output_tokens=self.output_tokens,\n                duration_ms=duration_ms,\n                status='success',\n            )\n\n        return False  # Don't suppress exceptions\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/msgtrun/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/msgtrun/msgtrun.py",
    "content": "from __future__ import annotations\n\nfrom .. import stage, entities\nfrom . import truncator\nfrom ...utils import importutil\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nfrom . import truncators\n\nimportutil.import_modules_in_pkg(truncators)\n\n\n@stage.stage_class('ConversationMessageTruncator')\nclass ConversationMessageTruncator(stage.PipelineStage):\n    \"\"\"Conversation message truncator\n\n    Used to truncate the conversation message chain to adapt to the LLM message length limit.\n    \"\"\"\n\n    trun: truncator.Truncator\n\n    async def initialize(self, pipeline_config: dict):\n        use_method = 'round'\n\n        for trun in truncator.preregistered_truncators:\n            if trun.name == use_method:\n                self.trun = trun(self.ap)\n                break\n        else:\n            raise ValueError(f'Unknown truncator: {use_method}')\n\n    async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult:\n        \"\"\"处理\"\"\"\n        query = await self.trun.truncate(query)\n\n        return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/msgtrun/truncator.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport abc\n\nfrom ...core import app\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\npreregistered_truncators: list[typing.Type[Truncator]] = []\n\n\ndef truncator_class(\n    name: str,\n) -> typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]:\n    \"\"\"截断器类装饰器\n\n    Args:\n        name (str): 截断器名称\n\n    Returns:\n        typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: 装饰器\n    \"\"\"\n\n    def decorator(cls: typing.Type[Truncator]) -> typing.Type[Truncator]:\n        assert issubclass(cls, Truncator)\n\n        cls.name = name\n\n        preregistered_truncators.append(cls)\n\n        return cls\n\n    return decorator\n\n\nclass Truncator(abc.ABC):\n    \"\"\"消息截断器基类\"\"\"\n\n    name: str\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        pass\n\n    @abc.abstractmethod\n    async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:\n        \"\"\"截断\n\n        一般只需要操作query.messages，也可以扩展操作query.prompt, query.user_message。\n        请勿操作其他字段。\n        \"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/msgtrun/truncators/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/msgtrun/truncators/round.py",
    "content": "from __future__ import annotations\n\nfrom .. import truncator\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n@truncator.truncator_class('round')\nclass RoundTruncator(truncator.Truncator):\n    \"\"\"Truncate the conversation message chain to adapt to the LLM message length limit.\"\"\"\n\n    async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:\n        \"\"\"截断\"\"\"\n        max_round = query.pipeline_config['ai']['local-agent']['max-round']\n\n        temp_messages = []\n\n        current_round = 0\n\n        # Traverse from back to front\n        for msg in query.messages[::-1]:\n            if current_round < max_round:\n                temp_messages.append(msg)\n                if msg.role == 'user':\n                    current_round += 1\n            else:\n                break\n\n        query.messages = temp_messages[::-1]\n\n        return query\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/pipelinemgr.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport traceback\n\nimport sqlalchemy\n\nfrom ..core import app\nfrom . import entities as pipeline_entities\nfrom ..entity.persistence import pipeline as persistence_pipeline\nfrom . import stage\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.events as events\nfrom ..utils import importutil\nfrom .config_coercion import coerce_pipeline_config\n\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\nfrom . import (\n    resprule,\n    bansess,\n    cntfilter,\n    process,\n    longtext,\n    respback,\n    wrapper,\n    preproc,\n    ratelimit,\n    msgtrun,\n)\n\nimportutil.import_modules_in_pkgs(\n    [\n        resprule,\n        bansess,\n        cntfilter,\n        process,\n        longtext,\n        respback,\n        wrapper,\n        preproc,\n        ratelimit,\n        msgtrun,\n    ]\n)\n\n\nclass StageInstContainer:\n    \"\"\"阶段实例容器\"\"\"\n\n    inst_name: str\n\n    inst: stage.PipelineStage\n\n    def __init__(self, inst_name: str, inst: stage.PipelineStage):\n        self.inst_name = inst_name\n        self.inst = inst\n\n\nclass RuntimePipeline:\n    \"\"\"运行时流水线\"\"\"\n\n    ap: app.Application\n\n    pipeline_entity: persistence_pipeline.LegacyPipeline\n    \"\"\"流水线实体\"\"\"\n\n    stage_containers: list[StageInstContainer]\n    \"\"\"阶段实例容器\"\"\"\n\n    bound_plugins: list[str] | None\n    \"\"\"绑定到此流水线的插件列表（格式：author/plugin_name），None表示启用所有\"\"\"\n\n    bound_mcp_servers: list[str] | None\n    \"\"\"绑定到此流水线的MCP服务器列表（格式：uuid），None表示启用所有\"\"\"\n\n    enable_all_plugins: bool\n    \"\"\"是否启用所有插件\"\"\"\n\n    enable_all_mcp_servers: bool\n    \"\"\"是否启用所有MCP服务器\"\"\"\n\n    def __init__(\n        self,\n        ap: app.Application,\n        pipeline_entity: persistence_pipeline.LegacyPipeline,\n        stage_containers: list[StageInstContainer],\n    ):\n        self.ap = ap\n        self.pipeline_entity = pipeline_entity\n        self.stage_containers = stage_containers\n\n        # Extract bound plugins and MCP servers from extensions_preferences\n        extensions_prefs = pipeline_entity.extensions_preferences or {}\n        self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)\n        self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)\n\n        if self.enable_all_plugins:\n            # None indicates to use all available plugins\n            self.bound_plugins = None\n        else:\n            plugin_list = extensions_prefs.get('plugins', [])\n            self.bound_plugins = [f'{p[\"author\"]}/{p[\"name\"]}' for p in plugin_list] if plugin_list else []\n\n        if self.enable_all_mcp_servers:\n            # None indicates to use all available MCP servers\n            self.bound_mcp_servers = None\n        else:\n            mcp_server_list = extensions_prefs.get('mcp_servers', [])\n            self.bound_mcp_servers = mcp_server_list if mcp_server_list else []\n\n    async def run(self, query: pipeline_query.Query):\n        query.pipeline_config = self.pipeline_entity.config\n        # Store bound plugins and MCP servers in query for filtering\n        query.variables['_pipeline_bound_plugins'] = self.bound_plugins\n        query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers\n\n        # Record query start for monitoring\n        try:\n            # Get bot name from bot_uuid\n            bot_name = 'WebChat'\n            if query.bot_uuid:\n                try:\n                    bot = await self.ap.bot_service.get_bot(query.bot_uuid, include_secret=False)\n                    if bot:\n                        bot_name = bot.get('name', 'Unknown')\n                except Exception:\n                    pass\n\n            # Store for later use in process_query\n            query.variables['_monitoring_bot_name'] = bot_name\n            query.variables['_monitoring_pipeline_name'] = self.pipeline_entity.name\n        except Exception as e:\n            self.ap.logger.error(f'Failed to prepare monitoring data: {e}')\n\n        await self.process_query(query)\n\n    async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult):\n        \"\"\"检查输出\"\"\"\n        if result.user_notice:\n            # 处理str类型\n\n            if isinstance(result.user_notice, str):\n                result.user_notice = platform_message.MessageChain([platform_message.Plain(text=result.user_notice)])\n            elif isinstance(result.user_notice, list):\n                result.user_notice = platform_message.MessageChain(*result.user_notice)\n\n            if query.pipeline_config['output']['misc']['at-sender'] and isinstance(\n                query.message_event, platform_events.GroupMessage\n            ):\n                result.user_notice.insert(0, platform_message.At(target=query.message_event.sender.id))\n            if await query.adapter.is_stream_output_supported() and query.resp_messages:\n                await query.adapter.reply_message_chunk(\n                    message_source=query.message_event,\n                    bot_message=query.resp_messages[-1],\n                    message=result.user_notice,\n                    quote_origin=query.pipeline_config['output']['misc']['quote-origin'],\n                    is_final=[msg.is_final for msg in query.resp_messages][0],\n                )\n            else:\n                await query.adapter.reply_message(\n                    message_source=query.message_event,\n                    message=result.user_notice,\n                    quote_origin=query.pipeline_config['output']['misc']['quote-origin'],\n                )\n        if result.debug_notice:\n            self.ap.logger.debug(result.debug_notice)\n        if result.console_notice:\n            self.ap.logger.info(result.console_notice)\n        if result.error_notice:\n            self.ap.logger.error(result.error_notice)\n            # Mark query as having error\n            query.variables['_monitoring_has_error'] = True\n            # Record error to monitoring system\n            try:\n                bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')\n                pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')\n                message_id = query.variables.get('_monitoring_message_id', '')\n                session_id = f'{query.launcher_type}_{query.launcher_id}'\n\n                # Update message status to error\n                if message_id:\n                    await self.ap.monitoring_service.update_message_status(\n                        message_id=message_id,\n                        status='error',\n                        level='error',\n                    )\n\n                # Record error log\n                await self.ap.monitoring_service.record_error(\n                    bot_id=query.bot_uuid or 'unknown',\n                    bot_name=bot_name,\n                    pipeline_id=self.pipeline_entity.uuid,\n                    pipeline_name=pipeline_name,\n                    error_type='PipelineError',\n                    error_message=result.error_notice,\n                    session_id=session_id,\n                    stack_trace=result.debug_notice if result.debug_notice else None,\n                    message_id=message_id,\n                )\n            except Exception as e:\n                self.ap.logger.error(f'Failed to record error to monitoring: {e}')\n\n    async def _execute_from_stage(\n        self,\n        stage_index: int,\n        query: pipeline_query.Query,\n    ):\n        \"\"\"从指定阶段开始执行，实现了责任链模式和基于生成器的阶段分叉功能。\n\n        如何看懂这里为什么这么写？\n        去问 GPT-4:\n            Q1: 现在有一个责任链，其中有多个stage，query对象在其中传递，stage.process可能返回Result也有可能返回typing.AsyncGenerator[Result, None]，\n                如果返回的是生成器，需要挨个生成result，检查是否result中是否要求继续，如果要求继续就进行下一个stage。如果此次生成器产生的result处理完了，就继续生成下一个result，\n                调用后续的stage，直到该生成器全部生成完。责任链中可能有多个stage会返回生成器\n            Q2: 不是这样的，你可能理解有误。如果我们责任链上有这些Stage：\n\n                A B C D E F G\n\n                如果所有的stage都返回Result，且所有Result都要求继续，那么执行顺序是：\n\n                A B C D E F G\n\n                现在假设C返回的是AsyncGenerator，那么执行顺序是：\n\n                A B C D E F G C D E F G C D E F G ...\n            Q3: 但是如果不止一个stage会返回生成器呢？\n        \"\"\"\n        i = stage_index\n\n        while i < len(self.stage_containers):\n            stage_container = self.stage_containers[i]\n\n            query.current_stage_name = stage_container.inst_name  # 标记到 Query 对象里\n\n            result = stage_container.inst.process(query, stage_container.inst_name)\n\n            if isinstance(result, typing.Coroutine):\n                result = await result\n\n            if isinstance(result, pipeline_entities.StageProcessResult):  # 直接返回结果\n                self.ap.logger.debug(\n                    f'Stage {stage_container.inst_name} processed query {query.query_id} res {result.result_type}'\n                )\n                await self._check_output(query, result)\n\n                if result.result_type == pipeline_entities.ResultType.INTERRUPT:\n                    self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')\n                    break\n                elif result.result_type == pipeline_entities.ResultType.CONTINUE:\n                    query = result.new_query\n            elif isinstance(result, typing.AsyncGenerator):  # 生成器\n                self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen')\n\n                async for sub_result in result:\n                    self.ap.logger.debug(\n                        f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}'\n                    )\n                    await self._check_output(query, sub_result)\n\n                    if sub_result.result_type == pipeline_entities.ResultType.INTERRUPT:\n                        self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')\n                        break\n                    elif sub_result.result_type == pipeline_entities.ResultType.CONTINUE:\n                        query = sub_result.new_query\n                        await self._execute_from_stage(i + 1, query)\n                break\n\n            i += 1\n\n    async def process_query(self, query: pipeline_query.Query):\n        \"\"\"处理请求\"\"\"\n        # Get monitoring metadata\n        bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')\n        pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')\n\n        # Get runner name from pipeline config\n        runner_name = None\n        if query.pipeline_config and 'ai' in query.pipeline_config and 'runner' in query.pipeline_config['ai']:\n            runner_name = query.pipeline_config['ai']['runner'].get('runner')\n\n        # Record query start and store message_id\n        message_id = ''\n        try:\n            from . import monitoring_helper\n\n            message_id = await monitoring_helper.MonitoringHelper.record_query_start(\n                ap=self.ap,\n                query=query,\n                bot_id=query.bot_uuid or 'unknown',\n                bot_name=bot_name,\n                pipeline_id=self.pipeline_entity.uuid,\n                pipeline_name=pipeline_name,\n                runner_name=runner_name,\n            )\n            # Store message_id in query variables for LLM call monitoring\n            query.variables['_monitoring_message_id'] = message_id\n        except Exception as e:\n            self.ap.logger.error(f'Failed to record query start: {e}')\n\n        try:\n            # Get bound plugins for this pipeline\n            bound_plugins = query.variables.get('_pipeline_bound_plugins', None)\n\n            # ======== 触发 MessageReceived 事件 ========\n            event_type = (\n                events.PersonMessageReceived\n                if query.launcher_type == provider_session.LauncherTypes.PERSON\n                else events.GroupMessageReceived\n            )\n\n            event_obj = event_type(\n                query=query,\n                launcher_type=query.launcher_type.value,\n                launcher_id=query.launcher_id,\n                sender_id=query.sender_id,\n                message_event=query.message_event,\n                message_chain=query.message_chain,\n            )\n\n            event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)\n\n            if event_ctx.is_prevented_default():\n                return\n\n            self.ap.logger.debug(f'Processing query {query.query_id}')\n\n            await self._execute_from_stage(0, query)\n\n            # Record query success only if no error occurred during processing\n            if not query.variables.get('_monitoring_has_error', False):\n                try:\n                    await monitoring_helper.MonitoringHelper.record_query_success(\n                        ap=self.ap,\n                        message_id=message_id,\n                        query=query,\n                    )\n                except Exception as e:\n                    self.ap.logger.error(f'Failed to record query success: {e}')\n\n                # Record bot response message\n                try:\n                    await monitoring_helper.MonitoringHelper.record_query_response(\n                        ap=self.ap,\n                        query=query,\n                        bot_id=query.bot_uuid or 'unknown',\n                        bot_name=bot_name,\n                        pipeline_id=self.pipeline_entity.uuid,\n                        pipeline_name=pipeline_name,\n                        runner_name=runner_name,\n                    )\n                except Exception as e:\n                    self.ap.logger.error(f'Failed to record query response: {e}')\n\n        except Exception as e:\n            inst_name = query.current_stage_name if query.current_stage_name else 'unknown'\n            self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')\n            self.ap.logger.error(f'Traceback: {traceback.format_exc()}')\n\n            # Record query error\n            try:\n                from . import monitoring_helper\n\n                await monitoring_helper.MonitoringHelper.record_query_error(\n                    ap=self.ap,\n                    query=query,\n                    bot_id=query.bot_uuid or 'unknown',\n                    bot_name=bot_name,\n                    pipeline_id=self.pipeline_entity.uuid,\n                    pipeline_name=pipeline_name,\n                    error=e,\n                    runner_name=runner_name,\n                )\n            except Exception as me:\n                self.ap.logger.error(f'Failed to record query error: {me}')\n\n        finally:\n            self.ap.logger.debug(f'Query {query.query_id} processed')\n            del self.ap.query_pool.cached_queries[query.query_id]\n\n\nclass PipelineManager:\n    \"\"\"流水线管理器\"\"\"\n\n    ap: app.Application\n\n    pipelines: list[RuntimePipeline]\n\n    stage_dict: dict[str, type[stage.PipelineStage]]\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        self.pipelines = []\n\n    async def initialize(self):\n        self.stage_dict = {name: cls for name, cls in stage.preregistered_stages.items()}\n\n        await self.load_pipelines_from_db()\n\n    async def load_pipelines_from_db(self):\n        self.ap.logger.info('Loading pipelines from db...')\n\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))\n\n        pipelines = result.all()\n\n        # load pipelines\n        for pipeline in pipelines:\n            await self.load_pipeline(pipeline)\n\n    async def load_pipeline(\n        self,\n        pipeline_entity: persistence_pipeline.LegacyPipeline\n        | sqlalchemy.Row[persistence_pipeline.LegacyPipeline]\n        | dict,\n    ):\n        if isinstance(pipeline_entity, sqlalchemy.Row):\n            pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity._mapping)\n        elif isinstance(pipeline_entity, dict):\n            pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)\n\n        coerce_pipeline_config(\n            pipeline_entity.config,\n            getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),\n            getattr(self.ap, 'pipeline_config_meta_safety', {'name': 'safety', 'stages': []}),\n            getattr(self.ap, 'pipeline_config_meta_ai', {'name': 'ai', 'stages': []}),\n            getattr(self.ap, 'pipeline_config_meta_output', {'name': 'output', 'stages': []}),\n        )\n\n        # initialize stage containers according to pipeline_entity.stages\n        stage_containers: list[StageInstContainer] = []\n        for stage_name in pipeline_entity.stages:\n            stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap)))\n\n        for stage_container in stage_containers:\n            await stage_container.inst.initialize(pipeline_entity.config)\n\n        runtime_pipeline = RuntimePipeline(self.ap, pipeline_entity, stage_containers)\n        self.pipelines.append(runtime_pipeline)\n\n    async def get_pipeline_by_uuid(self, uuid: str) -> RuntimePipeline | None:\n        for pipeline in self.pipelines:\n            if pipeline.pipeline_entity.uuid == uuid:\n                return pipeline\n        return None\n\n    async def remove_pipeline(self, uuid: str):\n        for pipeline in self.pipelines:\n            if pipeline.pipeline_entity.uuid == uuid:\n                self.pipelines.remove(pipeline)\n                return\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/pool.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport typing\n\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\n\n\nclass QueryPool:\n    \"\"\"请求池，请求获得调度进入pipeline之前，保存在这里\"\"\"\n\n    query_id_counter: int = 0\n\n    pool_lock: asyncio.Lock\n\n    queries: list[pipeline_query.Query]\n\n    cached_queries: dict[int, pipeline_query.Query]\n    \"\"\"Cached queries, used for plugin backward api call, will be removed after the query completely processed\"\"\"\n\n    condition: asyncio.Condition\n\n    def __init__(self):\n        self.query_id_counter = 0\n        self.pool_lock = asyncio.Lock()\n        self.queries = []\n        self.cached_queries = {}\n        self.condition = asyncio.Condition(self.pool_lock)\n\n    async def add_query(\n        self,\n        bot_uuid: str,\n        launcher_type: provider_session.LauncherTypes,\n        launcher_id: typing.Union[int, str],\n        sender_id: typing.Union[int, str],\n        message_event: platform_events.MessageEvent,\n        message_chain: platform_message.MessageChain,\n        adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,\n        pipeline_uuid: typing.Optional[str] = None,\n    ) -> pipeline_query.Query:\n        async with self.condition:\n            query_id = self.query_id_counter\n            query = pipeline_query.Query(\n                bot_uuid=bot_uuid,\n                query_id=query_id,\n                launcher_type=launcher_type,\n                launcher_id=launcher_id,\n                sender_id=sender_id,\n                message_event=message_event,\n                message_chain=message_chain,\n                variables={},\n                resp_messages=[],\n                resp_message_chain=[],\n                adapter=adapter,\n                pipeline_uuid=pipeline_uuid,\n            )\n            self.queries.append(query)\n            self.cached_queries[query_id] = query\n            self.query_id_counter += 1\n            self.condition.notify_all()\n\n    async def __aenter__(self):\n        await self.pool_lock.acquire()\n        return self\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        self.pool_lock.release()\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/preproc/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/preproc/preproc.py",
    "content": "from __future__ import annotations\n\nimport datetime\n\nfrom .. import stage, entities\nfrom langbot_plugin.api.entities.builtin.provider import message as provider_message\nimport langbot_plugin.api.entities.events as events\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\n\n\n@stage.stage_class('PreProcessor')\nclass PreProcessor(stage.PipelineStage):\n    \"\"\"Request pre-processing stage\n\n    Check out session, prompt, context, model, and content functions.\n\n    Rewrite:\n        - session\n        - prompt\n        - messages\n        - user_message\n        - use_model\n        - use_funcs\n    \"\"\"\n\n    async def process(\n        self,\n        query: pipeline_query.Query,\n        stage_inst_name: str,\n    ) -> entities.StageProcessResult:\n        \"\"\"Process\"\"\"\n        selected_runner = query.pipeline_config['ai']['runner']['runner']\n\n        session = await self.ap.sess_mgr.get_session(query)\n\n        # When not local-agent, llm_model is None\n        llm_model = None\n        if selected_runner == 'local-agent':\n            # Read model config — new format is { primary: str, fallbacks: [str] },\n            # but handle legacy plain string for backward compatibility\n            model_config = query.pipeline_config['ai']['local-agent'].get('model', {})\n            if isinstance(model_config, str):\n                # Legacy format: plain UUID string\n                primary_uuid = model_config\n                fallback_uuids = []\n            else:\n                primary_uuid = model_config.get('primary', '')\n                fallback_uuids = model_config.get('fallbacks', [])\n\n            if primary_uuid:\n                try:\n                    llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)\n                except ValueError:\n                    self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')\n\n            # Resolve fallback model UUIDs\n            if fallback_uuids:\n                valid_fallbacks = []\n                for fb_uuid in fallback_uuids:\n                    try:\n                        await self.ap.model_mgr.get_model_by_uuid(fb_uuid)\n                        valid_fallbacks.append(fb_uuid)\n                    except ValueError:\n                        self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')\n                if valid_fallbacks:\n                    query.variables['_fallback_model_uuids'] = valid_fallbacks\n\n        conversation = await self.ap.sess_mgr.get_conversation(\n            query,\n            session,\n            query.pipeline_config['ai']['local-agent']['prompt'],\n            query.pipeline_uuid,\n            query.bot_uuid,\n        )\n\n        # 设置query\n        query.session = session\n        query.prompt = conversation.prompt.copy()\n        query.messages = conversation.messages.copy()\n\n        if selected_runner == 'local-agent':\n            query.use_funcs = []\n            if llm_model:\n                query.use_llm_model_uuid = llm_model.model_entity.uuid\n\n                if llm_model.model_entity.abilities.__contains__('func_call'):\n                    # Get bound plugins and MCP servers for filtering tools\n                    bound_plugins = query.variables.get('_pipeline_bound_plugins', None)\n                    bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)\n                    query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)\n\n                    self.ap.logger.debug(f'Bound plugins: {bound_plugins}')\n                    self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')\n                    self.ap.logger.debug(f'Use funcs: {query.use_funcs}')\n\n            # If primary model doesn't support func_call but fallback models exist,\n            # load tools anyway since fallback models may support them\n            if not query.use_funcs and query.variables.get('_fallback_model_uuids'):\n                bound_plugins = query.variables.get('_pipeline_bound_plugins', None)\n                bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)\n                query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)\n\n        sender_name = ''\n\n        if isinstance(query.message_event, platform_events.GroupMessage):\n            sender_name = query.message_event.sender.member_name\n        elif isinstance(query.message_event, platform_events.FriendMessage):\n            sender_name = query.message_event.sender.nickname\n\n        variables = {\n            'launcher_type': query.session.launcher_type.value,\n            'launcher_id': query.session.launcher_id,\n            'sender_id': query.sender_id,\n            'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',\n            'conversation_id': conversation.uuid,\n            'msg_create_time': (\n                int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp())\n            ),\n            'group_name': query.message_event.group.name\n            if isinstance(query.message_event, platform_events.GroupMessage)\n            else '',\n            'sender_name': sender_name,\n        }\n        query.variables.update(variables)\n\n        # Check if this model supports vision, if not, remove all images\n        # TODO this checking should be performed in runner, and in this stage, the image should be reserved\n        if (\n            selected_runner == 'local-agent'\n            and llm_model\n            and not llm_model.model_entity.abilities.__contains__('vision')\n        ):\n            for msg in query.messages:\n                if isinstance(msg.content, list):\n                    for me in msg.content:\n                        if me.type == 'image_url':\n                            msg.content.remove(me)\n\n        content_list: list[provider_message.ContentElement] = []\n\n        plain_text = ''\n        quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')\n\n        for me in query.message_chain:\n            if isinstance(me, platform_message.Plain):\n                content_list.append(provider_message.ContentElement.from_text(me.text))\n                plain_text += me.text\n            elif isinstance(me, platform_message.Image):\n                if selected_runner != 'local-agent' or (\n                    llm_model and llm_model.model_entity.abilities.__contains__('vision')\n                ):\n                    if me.base64 is not None:\n                        content_list.append(provider_message.ContentElement.from_image_base64(me.base64))\n            elif isinstance(me, platform_message.Voice):\n                # 转成文件链接，让下游 runner 上传到目标模型\n                if me.base64:\n                    content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk'))\n                elif me.url:\n                    content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))\n            elif isinstance(me, platform_message.File):\n                # if me.url is not None:\n                content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))\n            elif isinstance(me, platform_message.Quote) and quote_msg:\n                for msg in me.origin:\n                    if isinstance(msg, platform_message.Plain):\n                        content_list.append(provider_message.ContentElement.from_text(msg.text))\n                    elif isinstance(msg, platform_message.Image):\n                        if selected_runner != 'local-agent' or (\n                            llm_model and llm_model.model_entity.abilities.__contains__('vision')\n                        ):\n                            if msg.base64 is not None:\n                                content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))\n\n        query.variables['user_message_text'] = plain_text\n\n        query.user_message = provider_message.Message(role='user', content=content_list)\n\n        # Extract knowledge base UUIDs into query variables so plugins can modify them\n        # during PromptPreProcessing before the runner performs retrieval.\n        kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])\n        if not kb_uuids:\n            old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')\n            if old_kb_uuid and old_kb_uuid != '__none__':\n                kb_uuids = [old_kb_uuid]\n        query.variables['_knowledge_base_uuids'] = list(kb_uuids)\n\n        # =========== 触发事件 PromptPreProcessing\n\n        event = events.PromptPreProcessing(\n            session_name=f'{query.session.launcher_type.value}_{query.session.launcher_id}',\n            default_prompt=query.prompt.messages,\n            prompt=query.messages,\n            query=query,\n        )\n\n        # Get bound plugins for filtering\n        bound_plugins = query.variables.get('_pipeline_bound_plugins', None)\n        event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)\n\n        query.prompt.messages = event_ctx.event.default_prompt\n        query.messages = event_ctx.event.prompt\n\n        return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/process/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/process/handler.py",
    "content": "from __future__ import annotations\n\nimport abc\n\nfrom ...core import app\nfrom .. import entities\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\nclass MessageHandler(metaclass=abc.ABCMeta):\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        pass\n\n    @abc.abstractmethod\n    async def handle(\n        self,\n        query: pipeline_query.Query,\n    ) -> entities.StageProcessResult:\n        raise NotImplementedError\n\n    def cut_str(self, s: str) -> str:\n        \"\"\"\n        Take the first line of the string, up to 20 characters, if there are multiple lines, or more than 20 characters, add an ellipsis\n        \"\"\"\n        s0 = s.split('\\n')[0]\n        if len(s0) > 20 or '\\n' in s:\n            s0 = s0[:20] + '...'\n        return s0\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/process/handlers/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/process/handlers/chat.py",
    "content": "from __future__ import annotations\n\nimport uuid\nimport typing\nimport traceback\nimport time\nfrom datetime import datetime\n\n\nfrom .. import handler\nfrom ... import entities\nfrom ....provider import runner as runner_module\n\nimport langbot_plugin.api.entities.events as events\nfrom ....utils import importutil, constants, runner as runner_utils\nfrom ....provider import runners\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nimportutil.import_modules_in_pkg(runners)\n\n\nclass ChatMessageHandler(handler.MessageHandler):\n    async def handle(\n        self,\n        query: pipeline_query.Query,\n    ) -> typing.AsyncGenerator[entities.StageProcessResult, None]:\n        \"\"\"处理\"\"\"\n        # 调API\n        #   生成器\n\n        # 触发插件事件\n        event_class = (\n            events.PersonNormalMessageReceived\n            if query.launcher_type == provider_session.LauncherTypes.PERSON\n            else events.GroupNormalMessageReceived\n        )\n\n        event = event_class(\n            launcher_type=query.launcher_type.value,\n            launcher_id=query.launcher_id,\n            sender_id=query.sender_id,\n            text_message=str(query.message_chain),\n            message_event=query.message_event,\n            message_chain=query.message_chain,\n            query=query,\n        )\n\n        # Get bound plugins for filtering\n        bound_plugins = query.variables.get('_pipeline_bound_plugins', None)\n        event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)\n\n        is_create_card = False  # 判断下是否需要创建流式卡片\n\n        if event_ctx.is_prevented_default():\n            if event_ctx.event.reply_message_chain is not None:\n                mc = event_ctx.event.reply_message_chain\n                query.resp_messages.append(mc)\n\n                yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n            else:\n                yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)\n        else:\n            if event_ctx.event.user_message_alter is not None:\n                if isinstance(event_ctx.event.user_message_alter, list):\n                    query.user_message.content = event_ctx.event.user_message_alter\n                elif isinstance(event_ctx.event.user_message_alter, str):\n                    query.user_message.content = [\n                        provider_message.ContentElement.from_text(event_ctx.event.user_message_alter)\n                    ]\n                elif isinstance(event_ctx.event.user_message_alter, provider_message.ContentElement):\n                    query.user_message.content = [event_ctx.event.user_message_alter]\n\n            text_length = 0\n            try:\n                is_stream = await query.adapter.is_stream_output_supported()\n            except AttributeError:\n                is_stream = False\n\n            try:\n                for r in runner_module.preregistered_runners:\n                    if r.name == query.pipeline_config['ai']['runner']['runner']:\n                        runner = r(self.ap, query.pipeline_config)\n                        break\n                else:\n                    raise ValueError(f'Request Runner not found: {query.pipeline_config[\"ai\"][\"runner\"][\"runner\"]}')\n                # Mark start time for telemetry\n                start_ts = time.time()\n\n                if is_stream:\n                    resp_message_id = uuid.uuid4()\n                    chunk_count = 0  # Track streaming chunks to reduce excessive logging\n\n                    async for result in runner.run(query):\n                        result.resp_message_id = str(resp_message_id)\n                        if query.resp_messages:\n                            query.resp_messages.pop()\n                        if query.resp_message_chain:\n                            query.resp_message_chain.pop()\n                        # 此时连接外部 AI 服务正常,创建卡片\n                        if not is_create_card:  # 只有不是第一次才创建卡片\n                            await query.adapter.create_message_card(str(resp_message_id), query.message_event)\n                            is_create_card = True\n                        query.resp_messages.append(result)\n\n                        chunk_count += 1\n                        # Only log every 10th chunk to reduce excessive logging during streaming\n                        # This prevents memory overflow from thousands of log entries per conversation\n                        # First chunk uses INFO level to confirm connection establishment\n                        if chunk_count == 1:\n                            self.ap.logger.info(\n                                f'Conversation({query.query_id}) Streaming started: {self.cut_str(result.readable_str())}'\n                            )\n                        elif chunk_count % 10 == 0:\n                            self.ap.logger.debug(\n                                f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}'\n                            )\n\n                        if result.content is not None:\n                            text_length += len(result.content)\n\n                        yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n\n                    # Log final summary after streaming completes\n                    self.ap.logger.info(\n                        f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'\n                    )\n\n                else:\n                    async for result in runner.run(query):\n                        query.resp_messages.append(result)\n\n                        self.ap.logger.info(\n                            f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}'\n                        )\n\n                        if result.content is not None:\n                            text_length += len(result.content)\n\n                        yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n\n                query.session.using_conversation.messages.append(query.user_message)\n\n                query.session.using_conversation.messages.extend(query.resp_messages)\n            except Exception as e:\n                error_info = f'{traceback.format_exc()}'\n                self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')\n                traceback.print_exc()\n\n                exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')\n\n                if exception_handling == 'show-error':\n                    user_notice = f'{e}'\n                elif exception_handling == 'show-hint':\n                    user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')\n                else:  # hide\n                    user_notice = None\n\n                yield entities.StageProcessResult(\n                    result_type=entities.ResultType.INTERRUPT,\n                    new_query=query,\n                    user_notice=user_notice,\n                    error_notice=f'{e}',\n                    debug_notice=traceback.format_exc(),\n                )\n            finally:\n                # Telemetry reporting: collect minimal per-query execution info and send asynchronously\n                try:\n                    end_ts = time.time()\n                    duration_ms = None\n                    if 'start_ts' in locals():\n                        duration_ms = int((end_ts - start_ts) * 1000)\n\n                    adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None\n                    runner_name = (\n                        query.pipeline_config.get('ai', {}).get('runner', {}).get('runner')\n                        if query.pipeline_config\n                        else None\n                    )\n\n                    # Model name if using localagent\n                    model_name = None\n                    try:\n                        if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None):\n                            m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)\n                            if m and getattr(m, 'model_entity', None):\n                                model_name = getattr(m.model_entity, 'name', None)\n                    except Exception:\n                        model_name = None\n\n                    pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None)\n\n                    runner_category = runner_utils.get_runner_category_from_runner(\n                        runner_name, runner, query.pipeline_config\n                    )\n\n                    payload = {\n                        'query_id': query.query_id,\n                        'adapter': adapter_name,\n                        'runner': runner_name,\n                        'runner_category': runner_category,\n                        'duration_ms': duration_ms,\n                        'model_name': model_name,\n                        'version': constants.semantic_version,\n                        'instance_id': constants.instance_id,\n                        'pipeline_plugins': pipeline_plugins,\n                        'error': locals().get('error_info', None),\n                        'timestamp': datetime.utcnow().isoformat(),\n                    }\n\n                    # Send telemetry asynchronously and do not block pipeline via app's telemetry manager\n                    await self.ap.telemetry.start_send_task(payload)\n\n                    # Trigger survey event on first successful non-WebSocket response\n                    if not locals().get('error_info') and adapter_name and 'WebSocket' not in adapter_name:\n                        if self.ap.survey:\n                            await self.ap.survey.trigger_event('first_bot_response_success')\n                except Exception as ex:\n                    # Ensure telemetry issues do not affect normal flow\n                    self.ap.logger.warning(f'Failed to send telemetry: {ex}')\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/process/handlers/command.py",
    "content": "from __future__ import annotations\nimport typing\n\n\nfrom .. import handler\nfrom ... import entities\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.events as events\n\n\nclass CommandHandler(handler.MessageHandler):\n    async def handle(\n        self,\n        query: pipeline_query.Query,\n    ) -> typing.AsyncGenerator[entities.StageProcessResult, None]:\n        \"\"\"Process\"\"\"\n\n        full_command_text = str(query.message_chain).strip()\n\n        command_text = full_command_text[1:]\n\n        privilege = 1\n\n        if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:\n            privilege = 2\n\n        spt = command_text.split(' ')\n\n        event_class = (\n            events.PersonCommandSent\n            if query.launcher_type == provider_session.LauncherTypes.PERSON\n            else events.GroupCommandSent\n        )\n\n        event = event_class(\n            launcher_type=query.launcher_type.value,\n            launcher_id=query.launcher_id,\n            sender_id=query.sender_id,\n            command=spt[0],\n            params=spt[1:] if len(spt) > 1 else [],\n            text_message=full_command_text,\n            is_admin=(privilege == 2),\n            query=query,\n        )\n\n        # Get bound plugins for filtering\n        bound_plugins = query.variables.get('_pipeline_bound_plugins', None)\n        event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)\n\n        if event_ctx.is_prevented_default():\n            if event_ctx.event.reply_message_chain is not None:\n                mc = event_ctx.event.reply_message_chain\n\n                query.resp_messages.append(mc)\n\n                yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n            else:\n                yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)\n\n        else:\n            session = await self.ap.sess_mgr.get_session(query)\n\n            async for ret in self.ap.cmd_mgr.execute(\n                command_text=command_text, full_command_text=full_command_text, query=query, session=session\n            ):\n                if ret.error is not None:\n                    query.resp_messages.append(\n                        provider_message.Message(\n                            role='command',\n                            content=str(ret.error),\n                        )\n                    )\n\n                    self.ap.logger.info(f'Command({query.query_id}) error: {self.cut_str(str(ret.error))}')\n\n                    yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n                elif (\n                    ret.text is not None\n                    or ret.image_url is not None\n                    or ret.image_base64 is not None\n                    or ret.file_url is not None\n                ):\n                    content: list[provider_message.ContentElement] = []\n\n                    if ret.text is not None:\n                        content.append(provider_message.ContentElement.from_text(ret.text))\n\n                    if ret.image_url is not None:\n                        content.append(provider_message.ContentElement.from_image_url(ret.image_url))\n\n                    if ret.image_base64 is not None:\n                        content.append(provider_message.ContentElement.from_image_base64(ret.image_base64))\n\n                    if ret.file_url is not None:\n                        # 此时为 file 类型\n                        content.append(provider_message.ContentElement.from_file_url(ret.file_url, ret.file_name))\n                    query.resp_messages.append(\n                        provider_message.Message(\n                            role='command',\n                            content=content,\n                        )\n                    )\n\n                    self.ap.logger.info(f'Command returned: {self.cut_str(str(content[0]))}')\n\n                    yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n                else:\n                    yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/process/process.py",
    "content": "from __future__ import annotations\n\nfrom . import handler\nfrom .handlers import chat, command\nfrom .. import entities\nfrom .. import stage\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n@stage.stage_class('MessageProcessor')\nclass Processor(stage.PipelineStage):\n    \"\"\"请求实际处理阶段\n\n    通过命令处理器和聊天处理器处理消息。\n\n    改写：\n        - resp_messages\n    \"\"\"\n\n    cmd_handler: handler.MessageHandler\n\n    chat_handler: handler.MessageHandler\n\n    async def initialize(self, pipeline_config: dict):\n        self.cmd_handler = command.CommandHandler(self.ap)\n        self.chat_handler = chat.ChatMessageHandler(self.ap)\n\n        await self.cmd_handler.initialize()\n        await self.chat_handler.initialize()\n\n    async def process(\n        self,\n        query: pipeline_query.Query,\n        stage_inst_name: str,\n    ) -> entities.StageProcessResult:\n        \"\"\"Process\"\"\"\n        message_text = str(query.message_chain).strip()\n\n        self.ap.logger.info(\n            f'Processing request from {query.launcher_type.value}_{query.launcher_id} ({query.query_id}): {message_text}'\n        )\n\n        async def generator():\n            cmd_prefix = self.ap.instance_config.data['command']['prefix']\n            cmd_enable = self.ap.instance_config.data['command'].get('enable', True)\n\n            if cmd_enable and any(message_text.startswith(prefix) for prefix in cmd_prefix):\n                handler_to_use = self.cmd_handler\n            else:\n                handler_to_use = self.chat_handler\n\n            async for result in handler_to_use.handle(query):\n                yield result\n\n        return generator()\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/ratelimit/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/ratelimit/algo.py",
    "content": "from __future__ import annotations\nimport abc\nimport typing\n\nfrom ...core import app\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\npreregistered_algos: list[typing.Type[ReteLimitAlgo]] = []\n\n\ndef algo_class(name: str):\n    def decorator(cls: typing.Type[ReteLimitAlgo]) -> typing.Type[ReteLimitAlgo]:\n        cls.name = name\n        preregistered_algos.append(cls)\n        return cls\n\n    return decorator\n\n\nclass ReteLimitAlgo(metaclass=abc.ABCMeta):\n    \"\"\"限流算法抽象类\"\"\"\n\n    name: str = None\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        pass\n\n    @abc.abstractmethod\n    async def require_access(\n        self,\n        query: pipeline_query.Query,\n        launcher_type: str,\n        launcher_id: typing.Union[int, str],\n    ) -> bool:\n        \"\"\"进入处理流程\n\n        这个方法对等待是友好的，意味着算法可以实现在这里等待一段时间以控制速率。\n\n        Args:\n            launcher_type (str): 请求者类型 群聊为 group 私聊为 person\n            launcher_id (int): 请求者ID\n\n        Returns:\n            bool: 是否允许进入处理流程，若返回false，则直接丢弃该请求\n        \"\"\"\n        raise NotImplementedError\n\n    @abc.abstractmethod\n    async def release_access(\n        self,\n        query: pipeline_query.Query,\n        launcher_type: str,\n        launcher_id: typing.Union[int, str],\n    ):\n        \"\"\"退出处理流程\n\n        Args:\n            launcher_type (str): 请求者类型 群聊为 group 私聊为 person\n            launcher_id (int): 请求者ID\n        \"\"\"\n\n        raise NotImplementedError\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/ratelimit/algos/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py",
    "content": "from __future__ import annotations\nimport asyncio\nimport time\nimport typing\nfrom .. import algo\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n# 固定窗口算法\nclass SessionContainer:\n    wait_lock: asyncio.Lock\n\n    records: dict[int, int]\n    \"\"\"访问记录，key为每窗口长度的起始时间戳，value为访问次数\"\"\"\n\n    def __init__(self):\n        self.wait_lock = asyncio.Lock()\n        self.records = {}\n\n\n@algo.algo_class('fixwin')\nclass FixedWindowAlgo(algo.ReteLimitAlgo):\n    containers_lock: asyncio.Lock\n    \"\"\"访问记录容器锁\"\"\"\n\n    containers: dict[str, SessionContainer]\n    \"\"\"访问记录容器，key为launcher_type launcher_id\"\"\"\n\n    async def initialize(self):\n        self.containers_lock = asyncio.Lock()\n        self.containers = {}\n\n    async def require_access(\n        self,\n        query: pipeline_query.Query,\n        launcher_type: str,\n        launcher_id: typing.Union[int, str],\n    ) -> bool:\n        # 加锁，找容器\n        container: SessionContainer = None\n\n        session_name = f'{launcher_type}_{launcher_id}'\n\n        async with self.containers_lock:\n            container = self.containers.get(session_name)\n\n            if container is None:\n                container = SessionContainer()\n                self.containers[session_name] = container\n\n        # 等待锁\n        async with container.wait_lock:\n            # 获取窗口大小和限制\n            window_size = query.pipeline_config['safety']['rate-limit']['window-length']\n            limitation = query.pipeline_config['safety']['rate-limit']['limitation']\n\n            # TODO revert it\n            # if session_name in self.ap.pipeline_cfg.data['rate-limit']['fixwin']:\n            #     window_size = self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name]['window-size']\n            #     limitation = self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name]['limit']\n\n            # 获取当前时间戳\n            now = int(time.time())\n\n            # 获取当前窗口的起始时间戳\n            now = now - now % window_size\n\n            # 获取当前窗口的访问次数\n            count = container.records.get(now, 0)\n\n            # 如果访问次数超过了限制\n            if count >= limitation:\n                if query.pipeline_config['safety']['rate-limit']['strategy'] == 'drop':\n                    return False\n                elif query.pipeline_config['safety']['rate-limit']['strategy'] == 'wait':\n                    # 等待下一窗口\n                    await asyncio.sleep(window_size - time.time() % window_size)\n\n                    now = int(time.time())\n                    now = now - now % window_size\n\n            if now not in container.records:\n                container.records = {}\n                container.records[now] = 1\n            else:\n                # 访问次数加一\n                container.records[now] = count + 1\n\n            # 返回True\n            return True\n\n    async def release_access(\n        self,\n        query: pipeline_query.Query,\n        launcher_type: str,\n        launcher_id: typing.Union[int, str],\n    ):\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/ratelimit/ratelimit.py",
    "content": "from __future__ import annotations\n\nimport typing\n\nfrom .. import entities, stage\nfrom . import algo\nfrom ...utils import importutil\n\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\nfrom . import algos\n\nimportutil.import_modules_in_pkg(algos)\n\n\n@stage.stage_class('RequireRateLimitOccupancy')\n@stage.stage_class('ReleaseRateLimitOccupancy')\nclass RateLimit(stage.PipelineStage):\n    \"\"\"限速器控制阶段\n\n    不改写query，只检查是否需要限速。\n    \"\"\"\n\n    algo: algo.ReteLimitAlgo\n\n    async def initialize(self, pipeline_config: dict):\n        algo_name = 'fixwin'\n\n        algo_class = None\n\n        for algo_cls in algo.preregistered_algos:\n            if algo_cls.name == algo_name:\n                algo_class = algo_cls\n                break\n        else:\n            raise ValueError(f'未知的限速算法: {algo_name}')\n\n        self.algo = algo_class(self.ap)\n        await self.algo.initialize()\n\n    async def process(\n        self,\n        query: pipeline_query.Query,\n        stage_inst_name: str,\n    ) -> typing.Union[\n        entities.StageProcessResult,\n        typing.AsyncGenerator[entities.StageProcessResult, None],\n    ]:\n        \"\"\"处理\"\"\"\n        if stage_inst_name == 'RequireRateLimitOccupancy':\n            if await self.algo.require_access(\n                query,\n                query.launcher_type.value,\n                query.launcher_id,\n            ):\n                return entities.StageProcessResult(\n                    result_type=entities.ResultType.CONTINUE,\n                    new_query=query,\n                )\n            else:\n                return entities.StageProcessResult(\n                    result_type=entities.ResultType.INTERRUPT,\n                    new_query=query,\n                    console_notice=f'根据限速规则忽略 {query.launcher_type.value}:{query.launcher_id} 消息',\n                    user_notice='请求数超过限速器设定值，已丢弃本消息。',\n                )\n        elif stage_inst_name == 'ReleaseRateLimitOccupancy':\n            await self.algo.release_access(\n                query,\n                query.launcher_type.value,\n                query.launcher_id,\n            )\n            return entities.StageProcessResult(\n                result_type=entities.ResultType.CONTINUE,\n                new_query=query,\n            )\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/respback/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/respback/respback.py",
    "content": "from __future__ import annotations\n\nimport random\nimport asyncio\n\n\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\nfrom .. import stage, entities\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n@stage.stage_class('SendResponseBackStage')\nclass SendResponseBackStage(stage.PipelineStage):\n    \"\"\"发送响应消息\"\"\"\n\n    async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult:\n        \"\"\"处理\"\"\"\n\n        random_range = (\n            query.pipeline_config['output']['force-delay']['min'],\n            query.pipeline_config['output']['force-delay']['max'],\n        )\n\n        random_delay = random.uniform(*random_range)\n\n        self.ap.logger.debug('根据规则强制延迟回复: %s s', random_delay)\n\n        await asyncio.sleep(random_delay)\n\n        if query.pipeline_config['output']['misc']['at-sender'] and isinstance(\n            query.message_event, platform_events.GroupMessage\n        ):\n            query.resp_message_chain[-1].insert(0, platform_message.At(target=query.message_event.sender.id))\n\n        quote_origin = query.pipeline_config['output']['misc']['quote-origin']\n\n        has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages)\n        # TODO 命令与流式的兼容性问题\n        if await query.adapter.is_stream_output_supported() and has_chunks:\n            is_final = [msg.is_final for msg in query.resp_messages][0]\n            await query.adapter.reply_message_chunk(\n                message_source=query.message_event,\n                bot_message=query.resp_messages[-1],\n                message=query.resp_message_chain[-1],\n                quote_origin=quote_origin,\n                is_final=is_final,\n            )\n        else:\n            await query.adapter.reply_message(\n                message_source=query.message_event,\n                message=query.resp_message_chain[-1],\n                quote_origin=quote_origin,\n            )\n\n        return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/resprule/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/resprule/entities.py",
    "content": "import pydantic\n\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\n\n\nclass RuleJudgeResult(pydantic.BaseModel):\n    matching: bool = False\n\n    replacement: platform_message.MessageChain = None\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/resprule/resprule.py",
    "content": "from __future__ import annotations\n\n\nfrom . import rule\n\nfrom .. import stage, entities\nfrom ...utils import importutil\n\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\nfrom . import rules\n\nimportutil.import_modules_in_pkg(rules)\n\n\n@stage.stage_class('GroupRespondRuleCheckStage')\nclass GroupRespondRuleCheckStage(stage.PipelineStage):\n    \"\"\"群组响应规则检查器\n\n    仅检查群消息是否符合规则。\n    \"\"\"\n\n    rule_matchers: list[rule.GroupRespondRule]\n    \"\"\"检查器实例\"\"\"\n\n    async def initialize(self, pipeline_config: dict):\n        \"\"\"初始化检查器\"\"\"\n\n        self.rule_matchers = []\n\n        for rule_matcher in rule.preregisetered_rules:\n            rule_inst = rule_matcher(self.ap)\n            await rule_inst.initialize()\n            self.rule_matchers.append(rule_inst)\n\n    async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult:\n        if query.launcher_type.value != 'group':  # 只处理群消息\n            return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n\n        rules = query.pipeline_config['trigger']['group-respond-rules']\n\n        use_rule = rules\n\n        # TODO revert it\n        # if str(query.launcher_id) in rules:\n        #     use_rule = rules[str(query.launcher_id)]\n\n        for rule_matcher in self.rule_matchers:  # 任意一个匹配就放行\n            res = await rule_matcher.match(str(query.message_chain), query.message_chain, use_rule, query)\n            if res.matching:\n                query.message_chain = res.replacement\n\n                return entities.StageProcessResult(\n                    result_type=entities.ResultType.CONTINUE,\n                    new_query=query,\n                )\n\n        return entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/resprule/rule.py",
    "content": "from __future__ import annotations\nimport abc\nimport typing\n\nfrom ...core import app\nfrom . import entities\n\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\npreregisetered_rules: list[typing.Type[GroupRespondRule]] = []\n\n\ndef rule_class(name: str):\n    def decorator(cls: typing.Type[GroupRespondRule]) -> typing.Type[GroupRespondRule]:\n        cls.name = name\n        preregisetered_rules.append(cls)\n        return cls\n\n    return decorator\n\n\nclass GroupRespondRule(metaclass=abc.ABCMeta):\n    \"\"\"群组响应规则的抽象类\"\"\"\n\n    name: str\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        pass\n\n    @abc.abstractmethod\n    async def match(\n        self,\n        message_text: str,\n        message_chain: platform_message.MessageChain,\n        rule_dict: dict,\n        query: pipeline_query.Query,\n    ) -> entities.RuleJudgeResult:\n        \"\"\"判断消息是否匹配规则\"\"\"\n        raise NotImplementedError\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/resprule/rules/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/resprule/rules/atbot.py",
    "content": "from __future__ import annotations\n\n\nfrom .. import rule as rule_model\nfrom .. import entities\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n@rule_model.rule_class('at-bot')\nclass AtBotRule(rule_model.GroupRespondRule):\n    async def match(\n        self,\n        message_text: str,\n        message_chain: platform_message.MessageChain,\n        rule_dict: dict,\n        query: pipeline_query.Query,\n    ) -> entities.RuleJudgeResult:\n        found = False\n\n        def remove_at(message_chain: platform_message.MessageChain):\n            nonlocal found\n            for component in message_chain.root:\n                if isinstance(component, platform_message.At) and str(component.target) == str(\n                    query.adapter.bot_account_id\n                ):\n                    message_chain.remove(component)\n                    found = True\n                    break\n\n        remove_at(message_chain)\n        remove_at(message_chain)  # 回复消息时会at两次，检查并删除重复的\n\n        should_respond_at = rule_dict.get('at', None)\n        if should_respond_at is not None:\n            return entities.RuleJudgeResult(matching=found and bool(should_respond_at), replacement=message_chain)\n\n        return entities.RuleJudgeResult(matching=found, replacement=message_chain)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/resprule/rules/prefix.py",
    "content": "from .. import rule as rule_model\nfrom .. import entities\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n@rule_model.rule_class('prefix')\nclass PrefixRule(rule_model.GroupRespondRule):\n    async def match(\n        self,\n        message_text: str,\n        message_chain: platform_message.MessageChain,\n        rule_dict: dict,\n        query: pipeline_query.Query,\n    ) -> entities.RuleJudgeResult:\n        prefixes = rule_dict['prefix']\n\n        for prefix in prefixes:\n            if message_text.startswith(prefix):\n                # 查找第一个plain元素\n                for me in message_chain:\n                    if isinstance(me, platform_message.Plain):\n                        me.text = me.text[len(prefix) :]\n\n                return entities.RuleJudgeResult(\n                    matching=True,\n                    replacement=message_chain,\n                )\n\n        return entities.RuleJudgeResult(matching=False, replacement=message_chain)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/resprule/rules/random.py",
    "content": "import random\n\n\nfrom .. import rule as rule_model\nfrom .. import entities\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n@rule_model.rule_class('random')\nclass RandomRespRule(rule_model.GroupRespondRule):\n    async def match(\n        self,\n        message_text: str,\n        message_chain: platform_message.MessageChain,\n        rule_dict: dict,\n        query: pipeline_query.Query,\n    ) -> entities.RuleJudgeResult:\n        random_rate = rule_dict['random']\n\n        return entities.RuleJudgeResult(matching=random.random() < random_rate, replacement=message_chain)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/resprule/rules/regexp.py",
    "content": "import re\n\n\nfrom .. import rule as rule_model\nfrom .. import entities\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\n@rule_model.rule_class('regexp')\nclass RegExpRule(rule_model.GroupRespondRule):\n    async def match(\n        self,\n        message_text: str,\n        message_chain: platform_message.MessageChain,\n        rule_dict: dict,\n        query: pipeline_query.Query,\n    ) -> entities.RuleJudgeResult:\n        regexps = rule_dict['regexp']\n\n        for regexp in regexps:\n            match = re.match(regexp, message_text)\n\n            if match:\n                return entities.RuleJudgeResult(\n                    matching=True,\n                    replacement=message_chain,\n                )\n\n        return entities.RuleJudgeResult(matching=False, replacement=message_chain)\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/stage.py",
    "content": "from __future__ import annotations\n\nimport abc\nimport typing\n\nfrom ..core import app\nfrom . import entities\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\npreregistered_stages: dict[str, type[PipelineStage]] = {}\n\n\ndef stage_class(name: str) -> typing.Callable[[type[PipelineStage]], type[PipelineStage]]:\n    def decorator(cls: type[PipelineStage]) -> type[PipelineStage]:\n        preregistered_stages[name] = cls\n        return cls\n\n    return decorator\n\n\nclass PipelineStage(metaclass=abc.ABCMeta):\n    \"\"\"流水线阶段\"\"\"\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self, pipeline_config: dict):\n        \"\"\"初始化\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def process(\n        self,\n        query: pipeline_query.Query,\n        stage_inst_name: str,\n    ) -> typing.Union[\n        entities.StageProcessResult,\n        typing.AsyncGenerator[entities.StageProcessResult, None],\n    ]:\n        \"\"\"处理\"\"\"\n        raise NotImplementedError\n"
  },
  {
    "path": "src/langbot/pkg/pipeline/wrapper/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/pipeline/wrapper/wrapper.py",
    "content": "from __future__ import annotations\n\nimport typing\n\nfrom .. import entities\nfrom .. import stage\n\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.events as events\n\n\n@stage.stage_class('ResponseWrapper')\nclass ResponseWrapper(stage.PipelineStage):\n    \"\"\"回复包装阶段\n\n    把回复的 message 包装成人类识读的形式。\n\n    改写：\n        - resp_message_chain\n    \"\"\"\n\n    async def initialize(self, pipeline_config: dict):\n        pass\n\n    async def process(\n        self,\n        query: pipeline_query.Query,\n        stage_inst_name: str,\n    ) -> typing.AsyncGenerator[entities.StageProcessResult, None]:\n        \"\"\"处理\"\"\"\n\n        # 如果 resp_messages[-1] 已经是 MessageChain 了\n        if isinstance(query.resp_messages[-1], platform_message.MessageChain):\n            query.resp_message_chain.append(query.resp_messages[-1])\n\n            yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n\n        else:\n            if query.resp_messages[-1].role == 'command':\n                query.resp_message_chain.append(\n                    query.resp_messages[-1].get_content_platform_message_chain(prefix_text='[bot] ')\n                )\n\n                yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n            elif query.resp_messages[-1].role == 'plugin':\n                query.resp_message_chain.append(query.resp_messages[-1].get_content_platform_message_chain())\n\n                yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)\n            else:\n                if query.resp_messages[-1].role == 'assistant':\n                    result = query.resp_messages[-1]\n                    session = await self.ap.sess_mgr.get_session(query)\n\n                    reply_text = ''\n\n                    if result.content:  # 有内容\n                        reply_text = str(result.get_content_platform_message_chain())\n\n                        # ============= 触发插件事件 ===============\n                        event = events.NormalMessageResponded(\n                            launcher_type=query.launcher_type.value,\n                            launcher_id=query.launcher_id,\n                            sender_id=query.sender_id,\n                            session=session,\n                            prefix='',\n                            response_text=reply_text,\n                            finish_reason='stop',\n                            funcs_called=[fc.function.name for fc in result.tool_calls]\n                            if result.tool_calls is not None\n                            else [],\n                            query=query,\n                        )\n\n                        # Get bound plugins for filtering\n                        bound_plugins = query.variables.get('_pipeline_bound_plugins', None)\n                        event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)\n\n                        if event_ctx.is_prevented_default():\n                            yield entities.StageProcessResult(\n                                result_type=entities.ResultType.INTERRUPT,\n                                new_query=query,\n                            )\n                        else:\n                            if event_ctx.event.reply_message_chain is not None:\n                                query.resp_message_chain.append(event_ctx.event.reply_message_chain)\n\n                            else:\n                                query.resp_message_chain.append(result.get_content_platform_message_chain())\n\n                            yield entities.StageProcessResult(\n                                result_type=entities.ResultType.CONTINUE,\n                                new_query=query,\n                            )\n\n                    if result.tool_calls is not None and len(result.tool_calls) > 0:  # 有函数调用\n                        function_names = [tc.function.name for tc in result.tool_calls]\n\n                        reply_text = f'Call {\".\".join(function_names)}...'\n\n                        query.resp_message_chain.append(\n                            platform_message.MessageChain([platform_message.Plain(text=reply_text)])\n                        )\n\n                        if query.pipeline_config['output']['misc']['track-function-calls']:\n                            event = events.NormalMessageResponded(\n                                launcher_type=query.launcher_type.value,\n                                launcher_id=query.launcher_id,\n                                sender_id=query.sender_id,\n                                session=session,\n                                prefix='',\n                                response_text=reply_text,\n                                finish_reason='stop',\n                                funcs_called=[fc.function.name for fc in result.tool_calls]\n                                if result.tool_calls is not None\n                                else [],\n                                query=query,\n                            )\n\n                            # Get bound plugins for filtering\n                            bound_plugins = query.variables.get('_pipeline_bound_plugins', None)\n                            event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)\n\n                            if event_ctx.is_prevented_default():\n                                yield entities.StageProcessResult(\n                                    result_type=entities.ResultType.INTERRUPT,\n                                    new_query=query,\n                                )\n                            else:\n                                if event_ctx.event.reply_message_chain is not None:\n                                    query.resp_message_chain.append(event_ctx.event.reply_message_chain)\n\n                                else:\n                                    query.resp_message_chain.append(\n                                        platform_message.MessageChain([platform_message.Plain(text=reply_text)])\n                                    )\n\n                                yield entities.StageProcessResult(\n                                    result_type=entities.ResultType.CONTINUE,\n                                    new_query=query,\n                                )\n"
  },
  {
    "path": "src/langbot/pkg/platform/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/platform/botmgr.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport traceback\nimport sqlalchemy\n\nfrom ..core import app, entities as core_entities, taskmgr\n\nfrom ..discover import engine\n\nfrom ..entity.persistence import bot as persistence_bot\n\nfrom ..entity.errors import platform as platform_errors\n\nfrom .logger import EventLogger\n\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\n\n\nclass RuntimeBot:\n    \"\"\"运行时机器人\"\"\"\n\n    ap: app.Application\n\n    bot_entity: persistence_bot.Bot\n\n    enable: bool\n\n    adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter\n\n    task_wrapper: taskmgr.TaskWrapper\n\n    task_context: taskmgr.TaskContext\n\n    logger: EventLogger\n\n    def __init__(\n        self,\n        ap: app.Application,\n        bot_entity: persistence_bot.Bot,\n        adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,\n        logger: EventLogger,\n    ):\n        self.ap = ap\n        self.bot_entity = bot_entity\n        self.enable = bot_entity.enable\n        self.adapter = adapter\n        self.task_context = taskmgr.TaskContext()\n        self.logger = logger\n\n    async def initialize(self):\n        async def on_friend_message(\n            event: platform_events.FriendMessage,\n            adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,\n        ):\n            image_components = [\n                component for component in event.message_chain if isinstance(component, platform_message.Image)\n            ]\n\n            await self.logger.info(\n                f'{event.message_chain}',\n                images=image_components,\n                message_session_id=f'person_{event.sender.id}',\n            )\n\n            # Push to webhooks and check if pipeline should be skipped\n            skip_pipeline = False\n            if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:\n                skip_pipeline = await self.ap.webhook_pusher.push_person_message(\n                    event, self.bot_entity.uuid, adapter.__class__.__name__\n                )\n\n            # Only add to query pool if no webhook requested to skip pipeline\n            if not skip_pipeline:\n                launcher_id = event.sender.id\n\n                if hasattr(adapter, 'get_launcher_id'):\n                    custom_launcher_id = adapter.get_launcher_id(event)\n                    if custom_launcher_id:\n                        launcher_id = custom_launcher_id\n\n                await self.ap.msg_aggregator.add_message(\n                    bot_uuid=self.bot_entity.uuid,\n                    launcher_type=provider_session.LauncherTypes.PERSON,\n                    launcher_id=launcher_id,\n                    sender_id=event.sender.id,\n                    message_event=event,\n                    message_chain=event.message_chain,\n                    adapter=adapter,\n                    pipeline_uuid=self.bot_entity.use_pipeline_uuid,\n                )\n            else:\n                await self.logger.info('Pipeline skipped for person message due to webhook response')\n\n        async def on_group_message(\n            event: platform_events.GroupMessage,\n            adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,\n        ):\n            image_components = [\n                component for component in event.message_chain if isinstance(component, platform_message.Image)\n            ]\n\n            await self.logger.info(\n                f'{event.message_chain}',\n                images=image_components,\n                message_session_id=f'group_{event.group.id}',\n            )\n\n            # Push to webhooks and check if pipeline should be skipped\n            skip_pipeline = False\n            if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:\n                skip_pipeline = await self.ap.webhook_pusher.push_group_message(\n                    event, self.bot_entity.uuid, adapter.__class__.__name__\n                )\n\n            # Only add to query pool if no webhook requested to skip pipeline\n            if not skip_pipeline:\n                launcher_id = event.group.id\n\n                if hasattr(adapter, 'get_launcher_id'):\n                    custom_launcher_id = adapter.get_launcher_id(event)\n                    if custom_launcher_id:\n                        launcher_id = custom_launcher_id\n\n                await self.ap.msg_aggregator.add_message(\n                    bot_uuid=self.bot_entity.uuid,\n                    launcher_type=provider_session.LauncherTypes.GROUP,\n                    launcher_id=launcher_id,\n                    sender_id=event.sender.id,\n                    message_event=event,\n                    message_chain=event.message_chain,\n                    adapter=adapter,\n                    pipeline_uuid=self.bot_entity.use_pipeline_uuid,\n                )\n            else:\n                await self.logger.info('Pipeline skipped for group message due to webhook response')\n\n        self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)\n        self.adapter.register_listener(platform_events.GroupMessage, on_group_message)\n\n    async def run(self):\n        async def exception_wrapper():\n            try:\n                self.task_context.set_current_action('Running...')\n                await self.adapter.run_async()\n                self.task_context.set_current_action('Exited.')\n            except Exception as e:\n                if isinstance(e, asyncio.CancelledError):\n                    self.task_context.set_current_action('Exited.')\n                    return\n\n                traceback_str = traceback.format_exc()\n                self.task_context.set_current_action('Exited with error.')\n                await self.logger.error(f'平台适配器运行出错:\\n{e}\\n{traceback_str}')\n\n        self.task_wrapper = self.ap.task_mgr.create_task(\n            exception_wrapper(),\n            kind='platform-adapter',\n            name=f'platform-adapter-{self.adapter.__class__.__name__}',\n            context=self.task_context,\n            scopes=[\n                core_entities.LifecycleControlScope.APPLICATION,\n                core_entities.LifecycleControlScope.PLATFORM,\n            ],\n        )\n\n    async def shutdown(self):\n        await self.adapter.kill()\n\n        self.ap.task_mgr.cancel_task(self.task_wrapper.id)\n\n\n# 控制QQ消息输入输出的类\nclass PlatformManager:\n    # ====== 4.0 ======\n    ap: app.Application = None\n\n    bots: list[RuntimeBot]\n\n    websocket_proxy_bot: RuntimeBot\n\n    adapter_components: list[engine.Component]\n\n    adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]]\n\n    def __init__(self, ap: app.Application = None):\n        self.ap = ap\n        self.bots = []\n        self.adapter_components = []\n        self.adapter_dict = {}\n\n    async def initialize(self):\n        # delete all bot log images\n        await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')\n\n        self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')\n        adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}\n        for component in self.adapter_components:\n            adapter_dict[component.metadata.name] = component.get_python_component_class()\n        self.adapter_dict = adapter_dict\n\n        # initialize websocket adapter\n        websocket_adapter_class = self.adapter_dict['websocket']\n        websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)\n        websocket_adapter_inst = websocket_adapter_class(\n            {},\n            websocket_logger,\n            ap=self.ap,\n        )\n\n        self.websocket_proxy_bot = RuntimeBot(\n            ap=self.ap,\n            bot_entity=persistence_bot.Bot(\n                uuid='websocket-proxy-bot',\n                name='WebSocket',\n                description='',\n                adapter='websocket',\n                adapter_config={},\n                enable=True,\n            ),\n            adapter=websocket_adapter_inst,\n            logger=websocket_logger,\n        )\n        await self.websocket_proxy_bot.initialize()\n\n        await self.load_bots_from_db()\n\n    def get_running_adapters(self) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:\n        return [bot.adapter for bot in self.bots if bot.enable]\n\n    async def load_bots_from_db(self):\n        self.ap.logger.info('Loading bots from db...')\n\n        self.bots = []\n\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))\n\n        bots = result.all()\n\n        for bot in bots:\n            # load all bots here, enable or disable will be handled in runtime\n            try:\n                await self.load_bot(bot)\n            except platform_errors.AdapterNotFoundError as e:\n                self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}')\n            except Exception as e:\n                self.ap.logger.error(f'Failed to load bot {bot.uuid}: {e}\\n{traceback.format_exc()}')\n\n    async def load_bot(\n        self,\n        bot_entity: persistence_bot.Bot | sqlalchemy.Row[persistence_bot.Bot] | dict,\n    ) -> RuntimeBot:\n        \"\"\"加载机器人\"\"\"\n        if isinstance(bot_entity, sqlalchemy.Row):\n            bot_entity = persistence_bot.Bot(**bot_entity._mapping)\n        elif isinstance(bot_entity, dict):\n            bot_entity = persistence_bot.Bot(**bot_entity)\n\n        logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap)\n\n        if bot_entity.adapter not in self.adapter_dict:\n            raise platform_errors.AdapterNotFoundError(bot_entity.adapter)\n\n        adapter_inst = self.adapter_dict[bot_entity.adapter](\n            bot_entity.adapter_config,\n            logger,\n        )\n\n        # 如果 adapter 支持 set_bot_uuid 方法，设置 bot_uuid（用于统一 webhook）\n        if hasattr(adapter_inst, 'set_bot_uuid'):\n            adapter_inst.set_bot_uuid(bot_entity.uuid)\n\n        runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)\n\n        await runtime_bot.initialize()\n\n        self.bots.append(runtime_bot)\n\n        return runtime_bot\n\n    async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:\n        if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid:\n            return self.websocket_proxy_bot\n        for bot in self.bots:\n            if bot.bot_entity.uuid == bot_uuid:\n                return bot\n        return None\n\n    async def remove_bot(self, bot_uuid: str):\n        for bot in self.bots:\n            if bot.bot_entity.uuid == bot_uuid:\n                if bot.enable:\n                    await bot.shutdown()\n                self.bots.remove(bot)\n                return\n\n    def get_available_adapters_info(self) -> list[dict]:\n        return [\n            component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'websocket'\n        ]\n\n    def get_available_adapter_info_by_name(self, name: str) -> dict | None:\n        for component in self.adapter_components:\n            if component.metadata.name == name:\n                return component.to_plain_dict()\n        return None\n\n    def get_available_adapter_manifest_by_name(self, name: str) -> engine.Component | None:\n        for component in self.adapter_components:\n            if component.metadata.name == name:\n                return component\n        return None\n\n    async def run(self):\n        # This method will only be called when the application launching\n        await self.websocket_proxy_bot.run()\n\n        for bot in self.bots:\n            if bot.enable:\n                await bot.run()\n\n    async def shutdown(self):\n        for bot in self.bots:\n            if bot.enable:\n                await bot.shutdown()\n        self.ap.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.PLATFORM)\n"
  },
  {
    "path": "src/langbot/pkg/platform/logger.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport mimetypes\nimport time\nimport enum\nimport pydantic\nimport traceback\nimport uuid\n\nfrom ..core import app\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_event_logger\n\n\nclass EventLogLevel(enum.Enum):\n    \"\"\"日志级别\"\"\"\n\n    DEBUG = 'debug'\n    INFO = 'info'\n    WARNING = 'warning'\n    ERROR = 'error'\n\n\nclass EventLog(pydantic.BaseModel):\n    seq_id: int\n    \"\"\"日志序号\"\"\"\n\n    timestamp: int\n    \"\"\"日志时间戳\"\"\"\n\n    level: EventLogLevel\n    \"\"\"日志级别\"\"\"\n\n    text: str\n    \"\"\"日志文本\"\"\"\n\n    images: typing.Optional[list[str]] = None\n    \"\"\"日志图片 URL 列表，需要通过 /api/v1/image/{uuid} 获取图片\"\"\"\n\n    message_session_id: typing.Optional[str] = None\n    \"\"\"消息会话ID，仅收发消息事件有值\"\"\"\n\n    def to_json(self) -> dict:\n        return {\n            'seq_id': self.seq_id,\n            'timestamp': self.timestamp,\n            'level': self.level.value,\n            'text': self.text,\n            'images': self.images,\n            'message_session_id': self.message_session_id,\n        }\n\n\nMAX_LOG_COUNT = 200\nDELETE_COUNT_PER_TIME = 50\n\n\nclass EventLogger(abstract_platform_event_logger.AbstractEventLogger):\n    \"\"\"used for logging bot events\"\"\"\n\n    ap: app.Application\n\n    seq_id_inc: int\n\n    logs: list[EventLog]\n\n    def __init__(\n        self,\n        name: str,\n        ap: app.Application,\n    ):\n        self.name = name\n        self.ap = ap\n        self.logs = []\n        self.seq_id_inc = 0\n\n    async def get_logs(self, from_seq_id: int, max_count: int) -> typing.Tuple[list[EventLog], int]:\n        \"\"\"\n        获取日志，从 from_seq_id 开始获取 max_count 条历史日志\n\n        Args:\n            from_seq_id: 起始序号，-1 表示末尾\n            max_count: 最大数量\n\n        Returns:\n            Tuple[list[EventLog], int]: 日志列表，日志总数\n        \"\"\"\n        if len(self.logs) == 0:\n            return [], 0\n\n        if from_seq_id <= -1:\n            from_seq_id = self.logs[-1].seq_id\n\n        min_seq_id_in_logs = self.logs[0].seq_id\n        max_seq_id_in_logs = self.logs[-1].seq_id\n\n        if from_seq_id < min_seq_id_in_logs:  # 需要的整个范围都已经被删除\n            return [], len(self.logs)\n\n        if (\n            from_seq_id > max_seq_id_in_logs and from_seq_id - max_count > max_seq_id_in_logs\n        ):  # 需要的整个范围都还没生成\n            return [], len(self.logs)\n\n        end_index = 1\n\n        for i, log in enumerate(self.logs):\n            if log.seq_id >= from_seq_id:\n                end_index = i + 1\n                break\n\n        start_index = max(0, end_index - max_count)\n\n        if max_count > 0:\n            return self.logs[start_index:end_index], len(self.logs)\n        else:\n            return [], len(self.logs)\n\n    async def _truncate_logs(self):\n        if len(self.logs) > MAX_LOG_COUNT:\n            for i in range(DELETE_COUNT_PER_TIME):\n                for image_key in self.logs[i].images:  # type: ignore\n                    await self.ap.storage_mgr.storage_provider.delete(image_key)\n            self.logs = self.logs[DELETE_COUNT_PER_TIME:]\n\n    async def _add_log(\n        self,\n        level: EventLogLevel,\n        text: str,\n        images: typing.Optional[list[platform_message.Image]] = None,\n        message_session_id: typing.Optional[str] = None,\n        no_throw: bool = True,\n    ):\n        try:\n            image_keys = []\n\n            if images is None:\n                images = []\n\n            if message_session_id is None:\n                message_session_id = ''\n\n            if not isinstance(message_session_id, str):\n                message_session_id = str(message_session_id)\n\n            for img in images:\n                img_bytes, mime_type = await img.get_bytes()\n                extension = mimetypes.guess_extension(mime_type)\n                if extension is None:\n                    extension = '.jpg'\n                image_key = f'bot_log_images/{message_session_id}-{uuid.uuid4()}{extension}'\n                await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes)\n                image_keys.append(image_key)\n\n            self.logs.append(\n                EventLog(\n                    seq_id=self.seq_id_inc,\n                    timestamp=int(time.time()),\n                    level=level,\n                    text=text,\n                    images=image_keys,\n                    message_session_id=message_session_id,\n                )\n            )\n            self.seq_id_inc += 1\n\n            await self._truncate_logs()\n\n        except Exception as e:\n            if not no_throw:\n                raise e\n            else:\n                traceback.print_exc()\n\n    async def info(\n        self,\n        text: str,\n        images: typing.Optional[list[platform_message.Image]] = None,\n        message_session_id: typing.Optional[str] = None,\n        no_throw: bool = True,\n    ):\n        await self._add_log(\n            level=EventLogLevel.INFO,\n            text=text,\n            images=images,\n            message_session_id=message_session_id,\n            no_throw=no_throw,\n        )\n\n    async def debug(\n        self,\n        text: str,\n        images: typing.Optional[list[platform_message.Image]] = None,\n        message_session_id: typing.Optional[str] = None,\n        no_throw: bool = True,\n    ):\n        await self._add_log(\n            level=EventLogLevel.DEBUG,\n            text=text,\n            images=images,\n            message_session_id=message_session_id,\n            no_throw=no_throw,\n        )\n\n    async def warning(\n        self,\n        text: str,\n        images: typing.Optional[list[platform_message.Image]] = None,\n        message_session_id: typing.Optional[str] = None,\n        no_throw: bool = True,\n    ):\n        await self._add_log(\n            level=EventLogLevel.WARNING,\n            text=text,\n            images=images,\n            message_session_id=message_session_id,\n            no_throw=no_throw,\n        )\n\n    async def error(\n        self,\n        text: str,\n        images: typing.Optional[list[platform_message.Image]] = None,\n        message_session_id: typing.Optional[str] = None,\n        no_throw: bool = True,\n    ):\n        await self._add_log(\n            level=EventLogLevel.ERROR,\n            text=text,\n            images=images,\n            message_session_id=message_session_id,\n            no_throw=no_throw,\n        )\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/platform/sources/aiocqhttp.py",
    "content": "from __future__ import annotations\nimport typing\nimport asyncio\nimport traceback\nimport datetime\n\nimport aiocqhttp\nimport pydantic\n\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nfrom ...utils import image\nimport langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger\n\n\nclass AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def yiri2target(\n        message_chain: platform_message.MessageChain,\n    ) -> typing.Tuple[list, int, datetime.datetime]:\n        msg_list = aiocqhttp.Message()\n\n        msg_id = 0\n        msg_time = None\n\n        for msg in message_chain:\n            if type(msg) is platform_message.Plain:\n                msg_list.append(aiocqhttp.MessageSegment.text(msg.text))\n            elif type(msg) is platform_message.Source:\n                msg_id = msg.id\n                msg_time = msg.time\n            elif type(msg) is platform_message.Image:\n                arg = ''\n                if msg.base64:\n                    arg = msg.base64\n                    msg_list.append(aiocqhttp.MessageSegment.image(f'base64://{arg}'))\n                elif msg.url:\n                    arg = msg.url\n                    msg_list.append(aiocqhttp.MessageSegment.image(arg))\n                elif msg.path:\n                    arg = msg.path\n                    msg_list.append(aiocqhttp.MessageSegment.image(arg))\n            elif type(msg) is platform_message.At:\n                msg_list.append(aiocqhttp.MessageSegment.at(msg.target))\n            elif type(msg) is platform_message.AtAll:\n                msg_list.append(aiocqhttp.MessageSegment.at('all'))\n            elif type(msg) is platform_message.Voice:\n                arg = ''\n                if msg.base64:\n                    arg = msg.base64\n                    msg_list.append(aiocqhttp.MessageSegment.record(f'base64://{arg}'))\n                elif msg.url:\n                    arg = msg.url\n                    msg_list.append(aiocqhttp.MessageSegment.record(arg))\n                elif msg.path:\n                    arg = msg.path\n                    msg_list.append(aiocqhttp.MessageSegment.record(msg.path))\n            elif type(msg) is platform_message.Forward:\n                for node in msg.node_list:\n                    msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0])\n            elif isinstance(msg, platform_message.File):\n                msg_list.append({'type': 'file', 'data': {'file': msg.url, 'name': msg.name}})\n            elif isinstance(msg, platform_message.Face):\n                if msg.face_type == 'face':\n                    msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id))\n                elif msg.face_type == 'rps':\n                    msg_list.append(aiocqhttp.MessageSegment.rps())\n                elif msg.face_type == 'dice':\n                    msg_list.append(aiocqhttp.MessageSegment.dice())\n\n            else:\n                msg_list.append(aiocqhttp.MessageSegment.text(str(msg)))\n\n        return msg_list, msg_id, msg_time\n\n    @staticmethod\n    async def target2yiri(message: str, message_id: int = -1, bot: aiocqhttp.CQHttp = None):\n        message = aiocqhttp.Message(message)\n\n        def get_face_name(face_id):\n            face_code_dict = {\n                '2': '好色',\n                '4': '得意',\n                '5': '流泪',\n                '8': '睡',\n                '9': '大哭',\n                '10': '尴尬',\n                '12': '调皮',\n                '14': '微笑',\n                '16': '酷',\n                '21': '可爱',\n                '23': '傲慢',\n                '24': '饥饿',\n                '25': '困',\n                '26': '惊恐',\n                '27': '流汗',\n                '28': '憨笑',\n                '29': '悠闲',\n                '30': '奋斗',\n                '32': '疑问',\n                '33': '嘘',\n                '34': '晕',\n                '38': '敲打',\n                '39': '再见',\n                '41': '发抖',\n                '42': '爱情',\n                '43': '跳跳',\n                '49': '拥抱',\n                '53': '蛋糕',\n                '60': '咖啡',\n                '63': '玫瑰',\n                '66': '爱心',\n                '74': '太阳',\n                '75': '月亮',\n                '76': '赞',\n                '78': '握手',\n                '79': '胜利',\n                '85': '飞吻',\n                '89': '西瓜',\n                '96': '冷汗',\n                '97': '擦汗',\n                '98': '抠鼻',\n                '99': '鼓掌',\n                '100': '糗大了',\n                '101': '坏笑',\n                '102': '左哼哼',\n                '103': '右哼哼',\n                '104': '哈欠',\n                '106': '委屈',\n                '109': '左亲亲',\n                '111': '可怜',\n                '116': '示爱',\n                '118': '抱拳',\n                '120': '拳头',\n                '122': '爱你',\n                '123': 'NO',\n                '124': 'OK',\n                '125': '转圈',\n                '129': '挥手',\n                '144': '喝彩',\n                '147': '棒棒糖',\n                '171': '茶',\n                '173': '泪奔',\n                '174': '无奈',\n                '175': '卖萌',\n                '176': '小纠结',\n                '179': 'doge',\n                '180': '惊喜',\n                '181': '骚扰',\n                '182': '笑哭',\n                '183': '我最美',\n                '201': '点赞',\n                '203': '托脸',\n                '212': '托腮',\n                '214': '啵啵',\n                '219': '蹭一蹭',\n                '222': '抱抱',\n                '227': '拍手',\n                '232': '佛系',\n                '240': '喷脸',\n                '243': '甩头',\n                '246': '加油抱抱',\n                '262': '脑阔疼',\n                '264': '捂脸',\n                '265': '辣眼睛',\n                '266': '哦哟',\n                '267': '头秃',\n                '268': '问号脸',\n                '269': '暗中观察',\n                '270': 'emm',\n                '271': '吃瓜',\n                '272': '呵呵哒',\n                '273': '我酸了',\n                '277': '汪汪',\n                '278': '汗',\n                '281': '无眼笑',\n                '282': '敬礼',\n                '284': '面无表情',\n                '285': '摸鱼',\n                '287': '哦',\n                '289': '睁眼',\n                '290': '敲开心',\n                '293': '摸锦鲤',\n                '294': '期待',\n                '297': '拜谢',\n                '298': '元宝',\n                '299': '牛啊',\n                '305': '右亲亲',\n                '306': '牛气冲天',\n                '307': '喵喵',\n                '314': '仔细分析',\n                '315': '加油',\n                '318': '崇拜',\n                '319': '比心',\n                '320': '庆祝',\n                '322': '拒绝',\n                '324': '吃糖',\n                '326': '生气',\n            }\n            return face_code_dict.get(face_id, '')\n\n        async def process_message_data(msg_data, reply_list):\n            if msg_data['type'] == 'image':\n                image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])\n                reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))\n\n            elif msg_data['type'] == 'text':\n                reply_list.append(platform_message.Plain(text=msg_data['data']['text']))\n\n            elif msg_data['type'] == 'forward':  # 这里来应该传入转发消息组，暂时传入Quote\n                for forward_msg_datas in msg_data['data']['content']:\n                    for forward_msg_data in forward_msg_datas['message']:\n                        await process_message_data(forward_msg_data, reply_list)\n\n            elif msg_data['type'] == 'at':\n                if msg_data['data']['qq'] == 'all':\n                    reply_list.append(platform_message.AtAll())\n                else:\n                    reply_list.append(\n                        platform_message.At(\n                            target=msg_data['data']['qq'],\n                        )\n                    )\n\n        yiri_msg_list = []\n\n        yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))\n\n        for msg in message:\n            reply_list = []\n            if msg.type == 'at':\n                if msg.data['qq'] == 'all':\n                    yiri_msg_list.append(platform_message.AtAll())\n                else:\n                    yiri_msg_list.append(\n                        platform_message.At(\n                            target=msg.data['qq'],\n                        )\n                    )\n            elif msg.type == 'text':\n                yiri_msg_list.append(platform_message.Plain(text=msg.data['text']))\n            elif msg.type == 'image':\n                emoji_id = msg.data.get('emoji_package_id', None)\n                if emoji_id:\n                    face_id = emoji_id\n                    face_name = msg.data.get('summary', '')\n                    image_msg = platform_message.Face(face_id=face_id, face_name=face_name)\n                else:\n                    image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])\n                    image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')\n                yiri_msg_list.append(image_msg)\n            elif msg.type == 'forward':\n                # 暂时不太合理\n                # msg_datas = await bot.get_msg(message_id=message_id)\n                # print(msg_datas)\n                # for msg_data in msg_datas[\"message\"]:\n                #     await process_message_data(msg_data, yiri_msg_list)\n                pass\n\n            elif msg.type == 'reply':  # 此处处理引用消息传入Quote\n                msg_datas = await bot.get_msg(message_id=msg.data['id'])\n\n                for msg_data in msg_datas['message']:\n                    await process_message_data(msg_data, reply_list)\n\n                reply_msg = platform_message.Quote(\n                    message_id=msg.data['id'], sender_id=msg_datas['user_id'], origin=reply_list\n                )\n                yiri_msg_list.append(reply_msg)\n\n            elif msg.type == 'file':\n                pass\n                # file_name = msg.data['file']\n                # file_id = msg.data['file_id']\n                # file_data = await bot.get_file(file_id=file_id)\n                # file_name = file_data.get('file_name')\n                # file_path = file_data.get('file')\n                # _ = file_path\n                # file_url = file_data.get('file_url')\n                # file_size = file_data.get('file_size')\n                # yiri_msg_list.append(platform_message.File(id=file_id, name=file_name,url=file_url,size=file_size))\n            elif msg.type == 'face':\n                face_id = msg.data['id']\n                face_name = msg.data['raw']['faceText']\n                if not face_name:\n                    face_name = get_face_name(face_id)\n                yiri_msg_list.append(platform_message.Face(face_id=int(face_id), face_name=face_name.replace('/', '')))\n            elif msg.type == 'rps':\n                face_id = msg.data['result']\n                yiri_msg_list.append(platform_message.Face(face_type='rps', face_id=int(face_id), face_name='猜拳'))\n            elif msg.type == 'dice':\n                face_id = msg.data['result']\n                yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子'))\n\n        chain = platform_message.MessageChain(yiri_msg_list)\n\n        return chain\n\n\nclass AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):\n        return event.source_platform_object\n\n    @staticmethod\n    async def target2yiri(event: aiocqhttp.Event, bot=None):\n        yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)\n\n        if event.message_type == 'group':\n            permission = 'MEMBER'\n\n            if 'role' in event.sender:\n                if event.sender['role'] == 'admin':\n                    permission = 'ADMINISTRATOR'\n                elif event.sender['role'] == 'owner':\n                    permission = 'OWNER'\n            converted_event = platform_events.GroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=event.sender['user_id'],  # message_seq 放哪？\n                    member_name=event.sender['nickname'],\n                    permission=permission,\n                    group=platform_entities.Group(\n                        id=event.group_id,\n                        name=event.sender['nickname'],\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title=event.sender['title'] if 'title' in event.sender else '',\n                ),\n                message_chain=yiri_chain,\n                time=event.time,\n                source_platform_object=event,\n            )\n            return converted_event\n        elif event.message_type == 'private':\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event.sender['user_id'],\n                    nickname=event.sender['nickname'],\n                    remark='',\n                ),\n                message_chain=yiri_chain,\n                time=event.time,\n                source_platform_object=event,\n            )\n\n\nclass AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp)\n\n    message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()\n    event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()\n\n    on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []\n\n    def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):\n        super().__init__(\n            config=config,\n            logger=logger,\n        )\n\n        async def shutdown_trigger_placeholder():\n            while True:\n                await asyncio.sleep(1)\n\n        self.config['shutdown_trigger'] = shutdown_trigger_placeholder\n\n        self.on_websocket_connection_event_cache = []\n\n        if 'access-token' in config:\n            self.bot = aiocqhttp.CQHttp(access_token=config['access-token'])\n            del self.config['access-token']\n        else:\n            self.bot = aiocqhttp.CQHttp()\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        # Check if message contains a Forward component\n        forward_msg = message.get_first(platform_message.Forward)\n        if forward_msg:\n            if target_type == 'group':\n                # Send as merged forward message via OneBot API\n                await self._send_forward_message(int(target_id), forward_msg)\n                return\n            else:\n                await self.logger.warning(\n                    f'Forward message is only supported for group targets, got target_type={target_type}. Falling through to normal send.'\n                )\n\n        aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0]\n\n        if target_type == 'group':\n            await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg)\n        elif target_type == 'person':\n            await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg)\n\n    async def _send_forward_message(self, group_id: int, forward: platform_message.Forward):\n        \"\"\"Send a merged forward message to a group using NapCat extended API.\"\"\"\n        messages = []\n\n        for node in forward.node_list:\n            # Build content for each node\n            content = []\n            if node.message_chain:\n                for component in node.message_chain:\n                    if isinstance(component, platform_message.Plain):\n                        if component.text:\n                            content.append({'type': 'text', 'data': {'text': component.text}})\n                    elif isinstance(component, platform_message.Image):\n                        img_data = {}\n                        if component.base64:\n                            b64 = component.base64\n                            if b64.startswith('data:'):\n                                b64 = b64.split(',', 1)[-1] if ',' in b64 else b64\n                            img_data['file'] = f'base64://{b64}'\n                        elif component.url:\n                            img_data['file'] = component.url\n                        elif component.path:\n                            img_data['file'] = str(component.path)\n\n                        if img_data:\n                            content.append({'type': 'image', 'data': img_data})\n\n            if not content:\n                continue\n\n            # Build node data - use user_id and nickname format for NapCat\n            user_id = str(node.sender_id) if node.sender_id else str(self.bot_account_id or '10000')\n            node_data = {\n                'type': 'node',\n                'data': {\n                    'user_id': user_id,\n                    'nickname': node.sender_name or '未知',\n                    'content': content,\n                },\n            }\n\n            messages.append(node_data)\n\n        if not messages:\n            return\n\n        # Build the full message payload for NapCat's send_forward_msg API\n        # This matches the format used by GiveMeSetuPlugin\n        bot_id = str(self.bot_account_id) if self.bot_account_id else '10000'\n        payload = {\n            'group_id': group_id,\n            'user_id': bot_id,  # Required by NapCat for display\n            'messages': messages,\n        }\n\n        # Add display settings if available\n        if forward.display:\n            if forward.display.title:\n                payload['news'] = [{'text': forward.display.title}]\n            if forward.display.brief:\n                payload['prompt'] = forward.display.brief\n            if forward.display.summary:\n                payload['summary'] = forward.display.summary\n            if forward.display.source:\n                payload['source'] = forward.display.source\n\n        try:\n            # Use send_forward_msg (NapCat extended API) instead of send_group_forward_msg\n            await self.logger.info(\n                f'Sending forward message to group {group_id} with {len(messages)} nodes, payload keys: {list(payload.keys())}'\n            )\n            result = await self.bot.call_action('send_forward_msg', **payload)\n            await self.logger.info(f'Forward message sent to group {group_id}, result: {result}')\n        except Exception as e:\n            await self.logger.error(f'Failed to send forward message to group {group_id}: {e}')\n            # Fallback: try standard OneBot API with integer group_id\n            try:\n                await self.logger.info('Trying fallback API send_group_forward_msg')\n                await self.bot.call_action('send_group_forward_msg', group_id=group_id, messages=messages)\n                await self.logger.info(f'Forward message sent via fallback API to group {group_id}')\n            except Exception as e2:\n                await self.logger.error(f'Fallback also failed: {e2}')\n                raise\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        aiocq_event = await AiocqhttpEventConverter.yiri2target(message_source, self.bot_account_id)\n        aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0]\n        if quote_origin:\n            aiocq_msg = aiocqhttp.MessageSegment.reply(aiocq_event.message_id) + aiocq_msg\n\n        return await self.bot.send(aiocq_event, aiocq_msg)\n\n    async def is_muted(self, group_id: int) -> bool:\n        return False\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        async def on_message(event: aiocqhttp.Event):\n            self.bot_account_id = event.self_id\n            try:\n                return await callback(await self.event_converter.target2yiri(event, self.bot), self)\n            except Exception:\n                await self.logger.error(f'Error in on_message: {traceback.format_exc()}')\n                traceback.print_exc()\n\n        if event_type == platform_events.GroupMessage:\n            self.bot.on_message('group')(on_message)\n            # self.bot.on_notice()(on_message)\n        elif event_type == platform_events.FriendMessage:\n            self.bot.on_message('private')(on_message)\n            # self.bot.on_notice()(on_message)\n        # print(event_type)\n\n        async def on_websocket_connection(event: aiocqhttp.Event):\n            for event in self.on_websocket_connection_event_cache:\n                if event.self_id == event.self_id and event.time == event.time:\n                    return\n\n            self.on_websocket_connection_event_cache.append(event)\n            await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')\n\n        self.bot.on_websocket_connection(on_websocket_connection)\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        return super().unregister_listener(event_type, callback)\n\n    async def run_async(self):\n        await self.bot._server_app.run_task(**self.config)\n\n    async def kill(self) -> bool:\n        # Current issue: existing connection will not be closed\n        # self.should_shutdown = True\n        return False\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/aiocqhttp.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: aiocqhttp\n  label:\n    en_US: OneBot v11\n    zh_Hans: OneBot v11\n  description:\n    en_US: OneBot v11 Adapter\n    zh_Hans: OneBot v11 适配器，请查看文档了解使用方式\n  icon: onebot.png\nspec:\n  config:\n    - name: host\n      label:\n        en_US: Host\n        zh_Hans: 主机\n      description:\n        en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0\n        zh_Hans: OneBot v11 监听的反向 WS 主机，除非你知道自己在做什么，否则请写 0.0.0.0\n      type: string\n      required: true\n      default: 0.0.0.0\n    - name: port\n      label:\n        en_US: Port\n        zh_Hans: 端口\n      description:\n        en_US: Port\n        zh_Hans: 监听的端口\n      type: integer\n      required: true\n      default: 2280\n    - name: access-token\n      label:\n        en_US: Access Token\n        zh_Hans: 访问令牌\n      description:\n        en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it\n        zh_Hans: 自定义的与协议端的连接令牌，若协议端未设置，则不填\n      type: string\n      required: false\n      default: \"\"\nexecution:\n  python:\n    path: ./aiocqhttp.py\n    attr: AiocqhttpAdapter"
  },
  {
    "path": "src/langbot/pkg/platform/sources/dingtalk.py",
    "content": "import traceback\nimport typing\nfrom langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nfrom langbot.libs.dingtalk_api.api import DingTalkClient\nimport datetime\nfrom langbot.pkg.platform.logger import EventLogger\n\n\nclass DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    def _format_image_as_markdown(msg: platform_message.Image) -> str:\n        \"\"\"Convert an Image message to Markdown format for DingTalk.\"\"\"\n        if msg.url:\n            return f'\\n![image]({msg.url})\\n'\n        elif msg.base64:\n            # For base64 images, try to include them as data URIs\n            # DingTalk may have limited support for base64 in markdown\n            if msg.base64.startswith('data:'):\n                return f'\\n![image]({msg.base64})\\n'\n            else:\n                return f'\\n![image](data:image/png;base64,{msg.base64})\\n'\n        return ''\n\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain, markdown_enabled: bool = True):\n        content = ''\n        at = False\n        for msg in message_chain:\n            if type(msg) is platform_message.At:\n                at = True\n            elif type(msg) is platform_message.Plain:\n                content += msg.text\n            elif type(msg) is platform_message.Image:\n                # DingTalk supports markdown images when markdown_card is enabled\n                # When markdown is disabled, images cannot be rendered in plain text mode\n                if markdown_enabled:\n                    content += DingTalkMessageConverter._format_image_as_markdown(msg)\n                # Note: When markdown_enabled is False, images are not included\n                # as DingTalk plain text messages don't support image embedding\n            elif type(msg) is platform_message.Forward:\n                for node in msg.node_list:\n                    forwarded_content, _ = await DingTalkMessageConverter.yiri2target(\n                        node.message_chain, markdown_enabled\n                    )\n                    content += forwarded_content\n        return content, at\n\n    @staticmethod\n    async def target2yiri(event: DingTalkEvent, bot_name: str):\n        yiri_msg_list = []\n        yiri_msg_list.append(\n            platform_message.Source(id=event.incoming_message.message_id, time=datetime.datetime.now())\n        )\n\n        for atUser in event.incoming_message.at_users:\n            if atUser.dingtalk_id == event.incoming_message.chatbot_user_id:\n                yiri_msg_list.append(platform_message.At(target=bot_name))\n\n        if event.rich_content:\n            elements = event.rich_content.get('Elements')\n            for element in elements:\n                if element.get('Type') == 'text':\n                    text = element.get('Content', '').replace('@' + bot_name, '')\n                    if text.strip():\n                        yiri_msg_list.append(platform_message.Plain(text=text))\n                elif element.get('Type') == 'image' and element.get('Picture'):\n                    yiri_msg_list.append(platform_message.Image(base64=element['Picture']))\n        else:\n            # 回退到原有简单逻辑\n            if event.content:\n                text_content = event.content.replace('@' + bot_name, '')\n                yiri_msg_list.append(platform_message.Plain(text=text_content))\n            if event.picture:\n                yiri_msg_list.append(platform_message.Image(base64=event.picture))\n\n            # 处理其他类型消息（文件、音频等）\n        if event.file:\n            yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))\n        if event.audio:\n            yiri_msg_list.append(platform_message.Voice(base64=event.audio))\n\n        chain = platform_message.MessageChain(yiri_msg_list)\n\n        return chain\n\n\nclass DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def yiri2target(event: platform_events.MessageEvent):\n        return event.source_platform_object\n\n    @staticmethod\n    async def target2yiri(event: DingTalkEvent, bot_name: str):\n        message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name)\n\n        if event.conversation == 'FriendMessage':\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event.incoming_message.sender_staff_id,\n                    nickname=event.incoming_message.sender_nick,\n                    remark='',\n                ),\n                message_chain=message_chain,\n                time=event.incoming_message.create_at,\n                source_platform_object=event,\n            )\n        elif event.conversation == 'GroupMessage':\n            sender = platform_entities.GroupMember(\n                id=event.incoming_message.sender_staff_id,\n                member_name=event.incoming_message.sender_nick,\n                permission='MEMBER',\n                group=platform_entities.Group(\n                    id=event.incoming_message.conversation_id,\n                    name=event.incoming_message.conversation_title,\n                    permission=platform_entities.Permission.Member,\n                ),\n                special_title='',\n            )\n            time = event.incoming_message.create_at\n            return platform_events.GroupMessage(\n                sender=sender,\n                message_chain=message_chain,\n                time=time,\n                source_platform_object=event,\n            )\n\n\nclass DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: DingTalkClient\n    bot_account_id: str\n    message_converter: DingTalkMessageConverter = DingTalkMessageConverter()\n    event_converter: DingTalkEventConverter = DingTalkEventConverter()\n    config: dict\n    card_instance_id_dict: (\n        dict  # 回复卡片消息字典，key为消息id，value为回复卡片实例id，用于在流式消息时判断是否发送到指定卡片\n    )\n\n    def __init__(self, config: dict, logger: EventLogger):\n        required_keys = [\n            'client_id',\n            'client_secret',\n            'robot_name',\n            'robot_code',\n        ]\n        missing_keys = [key for key in required_keys if key not in config]\n        if missing_keys:\n            raise Exception('钉钉缺少相关配置项，请查看文档或联系管理员')\n        bot = DingTalkClient(\n            client_id=config['client_id'],\n            client_secret=config['client_secret'],\n            robot_name=config['robot_name'],\n            robot_code=config['robot_code'],\n            markdown_card=config['markdown_card'],\n            logger=logger,\n        )\n        bot_account_id = config['robot_name']\n        super().__init__(\n            config=config,\n            logger=logger,\n            card_instance_id_dict={},\n            bot_account_id=bot_account_id,\n            bot=bot,\n            listeners={},\n        )\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        event = await DingTalkEventConverter.yiri2target(\n            message_source,\n        )\n        incoming_message = event.incoming_message\n\n        markdown_enabled = self.config.get('markdown_card', False)\n        content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)\n        await self.bot.send_message(content, incoming_message, at)\n\n    async def reply_message_chunk(\n        self,\n        message_source: platform_events.MessageEvent,\n        bot_message,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n        is_final: bool = False,\n    ):\n        # event = await DingTalkEventConverter.yiri2target(\n        #     message_source,\n        # )\n        # incoming_message = event.incoming_message\n\n        # msg_id = incoming_message.message_id\n        message_id = bot_message.resp_message_id\n        msg_seq = bot_message.msg_sequence\n\n        if (msg_seq - 1) % 8 == 0 or is_final:\n            markdown_enabled = self.config.get('markdown_card', False)\n            content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)\n\n            card_instance, card_instance_id = self.card_instance_id_dict[message_id]\n            if not content and bot_message.content:\n                content = bot_message.content  # 兼容直接传入content的情况\n            # print(card_instance_id)\n            if content:\n                await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)\n            if is_final and bot_message.tool_calls is None:\n                # self.seq = 1  # 消息回复结束之后重置seq\n                self.card_instance_id_dict.pop(message_id)  # 消息回复结束之后删除卡片实例id\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        markdown_enabled = self.config.get('markdown_card', False)\n        content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)\n        if target_type == 'person':\n            await self.bot.send_proactive_message_to_one(target_id, content)\n        if target_type == 'group':\n            await self.bot.send_proactive_message_to_group(target_id, content)\n\n    async def is_stream_output_supported(self) -> bool:\n        is_stream = False\n        if self.config.get('enable-stream-reply', None):\n            is_stream = True\n        return is_stream\n\n    async def create_message_card(self, message_id, event):\n        card_template_id = self.config['card_template_id']\n        incoming_message = event.source_platform_object.incoming_message\n        # message_id = incoming_message.message_id\n        card_auto_layout = self.config.get('card_ auto_layout', False)\n        card_instance, card_instance_id = await self.bot.create_and_card(\n            card_template_id, incoming_message, card_auto_layout=card_auto_layout\n        )\n        self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)\n        return True\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        async def on_message(event: DingTalkEvent):\n            try:\n                return await callback(\n                    await self.event_converter.target2yiri(event, self.config['robot_name']),\n                    self,\n                )\n            except Exception:\n                await self.logger.error(f'Error in dingtalk callback: {traceback.format_exc()}')\n\n        if event_type == platform_events.FriendMessage:\n            self.bot.on_message('FriendMessage')(on_message)\n        elif event_type == platform_events.GroupMessage:\n            self.bot.on_message('GroupMessage')(on_message)\n\n    async def run_async(self):\n        await self.bot.start()\n\n    async def kill(self) -> bool:\n        await self.bot.stop()\n        return True\n\n    async def is_muted(self) -> bool:\n        return False\n\n    async def unregister_listener(\n        self,\n        event_type: type,\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        return super().unregister_listener(event_type, callback)\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/dingtalk.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: dingtalk\n  label:\n    en_US: DingTalk\n    zh_Hans: 钉钉\n  description:\n    en_US: DingTalk Adapter\n    zh_Hans: 钉钉适配器，请查看文档了解使用方式\n  icon: dingtalk.svg\nspec:\n  config:\n    - name: client_id\n      label:\n        en_US: Client ID\n        zh_Hans: 客户端ID\n      type: string\n      required: true\n      default: \"\"\n    - name: client_secret\n      label:\n        en_US: Client Secret\n        zh_Hans: 客户端密钥\n      type: string\n      required: true\n      default: \"\"\n    - name: robot_code\n      label:\n        en_US: Robot Code\n        zh_Hans: 机器人代码\n      type: string\n      required: true\n      default: \"\"\n    - name: robot_name\n      label:\n        en_US: Robot Name\n        zh_Hans: 机器人名称\n      type: string\n      required: true\n      default: \"\"\n    - name: markdown_card\n      label:\n        en_US: Markdown Card\n        zh_Hans: 是否使用 Markdown 卡片\n      type: boolean\n      required: false\n      default: true\n    - name: enable-stream-reply\n      label:\n        en_US: Enable Stream Reply Mode\n        zh_Hans: 启用钉钉卡片流式回复模式\n      description:\n        en_US: If enabled, the bot will use the stream of lark reply mode\n        zh_Hans: 如果启用，将使用钉钉卡片流式方式来回复内容\n      type: boolean\n      required: true\n      default: false\n    - name: card_auto_layout\n      label:\n        en_US: Card Auto Layout\n        zh_Hans: 卡片宽屏自动布局\n      type: boolean\n      required: false\n      default: false\n    - name: card_template_id\n      label:\n        en_US: card template id\n        zh_Hans: 卡片模板ID\n      type: string\n      required: true\n      default: \"填写你的卡片template_id\"\nexecution:\n  python:\n    path: ./dingtalk.py\n    attr: DingTalkAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/discord.py",
    "content": "from __future__ import annotations\n\nimport discord\n\nimport typing\nimport re\nimport base64\nimport uuid\nimport os\nimport datetime\n\n# 使用BytesIO创建文件对象，避免路径问题\nimport io\nimport asyncio\nfrom enum import Enum\n\nfrom langbot.pkg.utils import httpclient\nimport pydantic\n\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger\nfrom ..logger import EventLogger\n\n\n# 语音功能相关异常定义\nclass VoiceConnectionError(Exception):\n    \"\"\"语音连接基础异常\"\"\"\n\n    def __init__(self, message: str, error_code: str = None, guild_id: int = None):\n        super().__init__(message)\n        self.error_code = error_code\n        self.guild_id = guild_id\n        self.timestamp = datetime.datetime.now()\n\n\nclass VoicePermissionError(VoiceConnectionError):\n    \"\"\"语音权限异常\"\"\"\n\n    def __init__(self, message: str, missing_permissions: list = None, user_id: int = None, channel_id: int = None):\n        super().__init__(message, 'PERMISSION_ERROR')\n        self.missing_permissions = missing_permissions or []\n        self.user_id = user_id\n        self.channel_id = channel_id\n\n\nclass VoiceNetworkError(VoiceConnectionError):\n    \"\"\"语音网络异常\"\"\"\n\n    def __init__(self, message: str, retry_count: int = 0):\n        super().__init__(message, 'NETWORK_ERROR')\n        self.retry_count = retry_count\n        self.last_attempt = datetime.datetime.now()\n\n\nclass VoiceConnectionStatus(Enum):\n    \"\"\"语音连接状态枚举\"\"\"\n\n    IDLE = 'idle'\n    CONNECTING = 'connecting'\n    CONNECTED = 'connected'\n    PLAYING = 'playing'\n    RECONNECTING = 'reconnecting'\n    FAILED = 'failed'\n\n\nclass VoiceConnectionInfo:\n    \"\"\"\n    语音连接信息类\n\n    用于存储和管理单个语音连接的详细信息，包括连接状态、时间戳、\n    频道信息等。提供连接信息的标准化数据结构。\n\n    @author: @ydzat\n    @version: 1.0\n    @since: 2025-07-04\n    \"\"\"\n\n    def __init__(self, guild_id: int, channel_id: int, channel_name: str = None):\n        \"\"\"\n        初始化语音连接信息\n\n        @author: @ydzat\n\n        Args:\n            guild_id (int): 服务器ID\n            channel_id (int): 语音频道ID\n            channel_name (str, optional): 语音频道名称\n        \"\"\"\n        self.guild_id = guild_id\n        self.channel_id = channel_id\n        self.channel_name = channel_name or f'Channel-{channel_id}'\n        self.connected = False\n        self.connection_time: datetime.datetime = None\n        self.last_activity = datetime.datetime.now()\n        self.status = VoiceConnectionStatus.IDLE\n        self.user_count = 0\n        self.latency = 0.0\n        self.connection_health = 'unknown'\n        self.voice_client = None\n\n    def update_status(self, status: VoiceConnectionStatus):\n        \"\"\"\n        更新连接状态\n\n        @author: @ydzat\n\n        Args:\n            status (VoiceConnectionStatus): 新的连接状态\n        \"\"\"\n        self.status = status\n        self.last_activity = datetime.datetime.now()\n\n        if status == VoiceConnectionStatus.CONNECTED:\n            self.connected = True\n            if self.connection_time is None:\n                self.connection_time = datetime.datetime.now()\n        elif status in [VoiceConnectionStatus.IDLE, VoiceConnectionStatus.FAILED]:\n            self.connected = False\n            self.connection_time = None\n            self.voice_client = None\n\n    def to_dict(self) -> dict:\n        \"\"\"\n        转换为字典格式\n\n        @author: @ydzat\n\n        Returns:\n            dict: 连接信息的字典表示\n        \"\"\"\n        return {\n            'guild_id': self.guild_id,\n            'channel_id': self.channel_id,\n            'channel_name': self.channel_name,\n            'connected': self.connected,\n            'connection_time': self.connection_time.isoformat() if self.connection_time else None,\n            'last_activity': self.last_activity.isoformat(),\n            'status': self.status.value,\n            'user_count': self.user_count,\n            'latency': self.latency,\n            'connection_health': self.connection_health,\n        }\n\n\nclass VoiceConnectionManager:\n    \"\"\"\n    语音连接管理器\n\n    负责管理多个服务器的语音连接，提供连接建立、断开、状态查询等功能。\n    采用单例模式确保全局只有一个连接管理器实例。\n\n    @author: @ydzat\n    @version: 1.0\n    @since: 2025-07-04\n    \"\"\"\n\n    def __init__(self, bot: discord.Client, logger: EventLogger):\n        \"\"\"\n        初始化语音连接管理器\n\n        @author: @ydzat\n\n        Args:\n            bot (discord.Client): Discord 客户端实例\n            logger (EventLogger): 事件日志记录器\n        \"\"\"\n        self.bot = bot\n        self.logger = logger\n        self.connections: typing.Dict[int, VoiceConnectionInfo] = {}\n        self._connection_lock = asyncio.Lock()\n        self._cleanup_task = None\n        self._monitoring_enabled = True\n\n    async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:\n        \"\"\"\n        加入语音频道\n\n        验证用户权限和频道状态后，建立到指定语音频道的连接。\n        支持连接复用和自动重连机制。\n\n        @author: @ydzat\n\n        Args:\n            guild_id (int): 服务器ID\n            channel_id (int): 语音频道ID\n            user_id (int, optional): 请求用户ID，用于权限验证\n\n        Returns:\n            discord.VoiceClient: 语音客户端实例\n\n        Raises:\n            VoicePermissionError: 权限不足时抛出\n            VoiceNetworkError: 网络连接失败时抛出\n            VoiceConnectionError: 其他连接错误时抛出\n        \"\"\"\n        async with self._connection_lock:\n            try:\n                # 获取服务器和频道对象\n                guild = self.bot.get_guild(guild_id)\n                if not guild:\n                    raise VoiceConnectionError(f'无法找到服务器 {guild_id}', 'GUILD_NOT_FOUND', guild_id)\n\n                channel = guild.get_channel(channel_id)\n                if not channel or not isinstance(channel, discord.VoiceChannel):\n                    raise VoiceConnectionError(f'无法找到语音频道 {channel_id}', 'CHANNEL_NOT_FOUND', guild_id)\n\n                # 验证用户是否在语音频道中（如果提供了用户ID）\n                if user_id:\n                    await self._validate_user_in_channel(guild, channel, user_id)\n\n                # 验证机器人权限\n                await self._validate_bot_permissions(channel)\n\n                # 检查是否已有连接\n                if guild_id in self.connections:\n                    existing_conn = self.connections[guild_id]\n                    if existing_conn.connected and existing_conn.voice_client:\n                        if existing_conn.channel_id == channel_id:\n                            # 已连接到相同频道，返回现有连接\n                            await self.logger.info(f'复用现有语音连接: {guild.name} -> {channel.name}')\n                            return existing_conn.voice_client\n                        else:\n                            # 连接到不同频道，先断开旧连接\n                            await self._disconnect_internal(guild_id)\n\n                # 建立新连接\n                voice_client = await channel.connect()\n\n                # 更新连接信息\n                conn_info = VoiceConnectionInfo(guild_id, channel_id, channel.name)\n                conn_info.voice_client = voice_client\n                conn_info.update_status(VoiceConnectionStatus.CONNECTED)\n                conn_info.user_count = len(channel.members)\n                self.connections[guild_id] = conn_info\n\n                await self.logger.info(f'成功连接到语音频道: {guild.name} -> {channel.name}')\n                return voice_client\n\n            except discord.ClientException as e:\n                raise VoiceNetworkError(f'Discord 客户端错误: {str(e)}')\n            except discord.opus.OpusNotLoaded as e:\n                raise VoiceConnectionError(f'Opus 编码器未加载: {str(e)}', 'OPUS_NOT_LOADED', guild_id)\n            except Exception as e:\n                await self.logger.error(f'连接语音频道时发生未知错误: {str(e)}')\n                raise VoiceConnectionError(f'连接失败: {str(e)}', 'UNKNOWN_ERROR', guild_id)\n\n    async def leave_voice_channel(self, guild_id: int) -> bool:\n        \"\"\"\n        离开语音频道\n\n        断开指定服务器的语音连接，清理相关资源和状态信息。\n        确保音频播放停止后再断开连接。\n\n        @author: @ydzat\n\n        Args:\n            guild_id (int): 服务器ID\n\n        Returns:\n            bool: 断开是否成功\n        \"\"\"\n        async with self._connection_lock:\n            return await self._disconnect_internal(guild_id)\n\n    async def _disconnect_internal(self, guild_id: int) -> bool:\n        \"\"\"\n        内部断开连接方法\n\n        @author: @ydzat\n\n        Args:\n            guild_id (int): 服务器ID\n\n        Returns:\n            bool: 断开是否成功\n        \"\"\"\n        if guild_id not in self.connections:\n            return True\n\n        conn_info = self.connections[guild_id]\n\n        try:\n            if conn_info.voice_client and conn_info.voice_client.is_connected():\n                # 停止当前播放\n                if conn_info.voice_client.is_playing():\n                    conn_info.voice_client.stop()\n\n                # 等待播放完全停止\n                await asyncio.sleep(0.1)\n\n                # 断开连接\n                await conn_info.voice_client.disconnect()\n\n            conn_info.update_status(VoiceConnectionStatus.IDLE)\n            del self.connections[guild_id]\n\n            await self.logger.info(f'已断开语音连接: Guild {guild_id}')\n            return True\n\n        except Exception as e:\n            await self.logger.error(f'断开语音连接时发生错误: {str(e)}')\n            # 即使出错也要清理连接记录\n            conn_info.update_status(VoiceConnectionStatus.FAILED)\n            if guild_id in self.connections:\n                del self.connections[guild_id]\n            return False\n\n    async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]:\n        \"\"\"\n        获取语音客户端\n\n        返回指定服务器的语音客户端实例，如果未连接则返回 None。\n        会验证连接的有效性，自动清理无效连接。\n\n        @author: @ydzat\n\n        Args:\n            guild_id (int): 服务器ID\n\n        Returns:\n            Optional[discord.VoiceClient]: 语音客户端实例或 None\n        \"\"\"\n        if guild_id not in self.connections:\n            return None\n\n        conn_info = self.connections[guild_id]\n\n        # 验证连接是否仍然有效\n        if conn_info.voice_client and not conn_info.voice_client.is_connected():\n            # 连接已失效，清理状态\n            await self._disconnect_internal(guild_id)\n            return None\n\n        return conn_info.voice_client if conn_info.connected else None\n\n    async def is_connected_to_voice(self, guild_id: int) -> bool:\n        \"\"\"\n        检查是否连接到语音频道\n\n        @author: @ydzat\n\n        Args:\n            guild_id (int): 服务器ID\n\n        Returns:\n            bool: 是否已连接\n        \"\"\"\n        if guild_id not in self.connections:\n            return False\n\n        conn_info = self.connections[guild_id]\n\n        # 检查实际连接状态\n        if conn_info.voice_client and not conn_info.voice_client.is_connected():\n            # 连接已失效，清理状态\n            await self._disconnect_internal(guild_id)\n            return False\n\n        return conn_info.connected\n\n    async def get_connection_status(self, guild_id: int) -> typing.Optional[dict]:\n        \"\"\"\n        获取连接状态信息\n\n        @author: @ydzat\n\n        Args:\n            guild_id (int): 服务器ID\n\n        Returns:\n            Optional[dict]: 连接状态信息字典或 None\n        \"\"\"\n        if guild_id not in self.connections:\n            return None\n\n        conn_info = self.connections[guild_id]\n\n        # 更新实时信息\n        if conn_info.voice_client and conn_info.voice_client.is_connected():\n            conn_info.latency = conn_info.voice_client.latency * 1000  # 转换为毫秒\n            conn_info.connection_health = 'good' if conn_info.latency < 100 else 'poor'\n\n            # 更新频道用户数\n            guild = self.bot.get_guild(guild_id)\n            if guild:\n                channel = guild.get_channel(conn_info.channel_id)\n                if channel and isinstance(channel, discord.VoiceChannel):\n                    conn_info.user_count = len(channel.members)\n\n        return conn_info.to_dict()\n\n    async def list_active_connections(self) -> typing.List[dict]:\n        \"\"\"\n        列出所有活跃连接\n\n        @author: @ydzat\n\n        Returns:\n            List[dict]: 活跃连接列表\n        \"\"\"\n        active_connections = []\n\n        for guild_id, conn_info in self.connections.items():\n            if conn_info.connected:\n                status = await self.get_connection_status(guild_id)\n                if status:\n                    active_connections.append(status)\n\n        return active_connections\n\n    async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]:\n        \"\"\"\n        获取语音频道信息\n\n        @author: @ydzat\n\n        Args:\n            guild_id (int): 服务器ID\n            channel_id (int): 频道ID\n\n        Returns:\n            Optional[dict]: 频道信息字典或 None\n        \"\"\"\n        guild = self.bot.get_guild(guild_id)\n        if not guild:\n            return None\n\n        channel = guild.get_channel(channel_id)\n        if not channel or not isinstance(channel, discord.VoiceChannel):\n            return None\n\n        # 获取用户信息\n        users = []\n        for member in channel.members:\n            users.append(\n                {'id': member.id, 'name': member.display_name, 'status': str(member.status), 'is_bot': member.bot}\n            )\n\n        # 获取权限信息\n        bot_member = guild.me\n        permissions = channel.permissions_for(bot_member)\n\n        return {\n            'channel_id': channel_id,\n            'channel_name': channel.name,\n            'guild_id': guild_id,\n            'guild_name': guild.name,\n            'user_limit': channel.user_limit,\n            'current_users': users,\n            'user_count': len(users),\n            'bitrate': channel.bitrate,\n            'permissions': {\n                'connect': permissions.connect,\n                'speak': permissions.speak,\n                'use_voice_activation': permissions.use_voice_activation,\n                'priority_speaker': permissions.priority_speaker,\n            },\n        }\n\n    async def _validate_user_in_channel(self, guild: discord.Guild, channel: discord.VoiceChannel, user_id: int):\n        \"\"\"\n        验证用户是否在语音频道中\n\n        @author: @ydzat\n\n        Args:\n            guild: Discord 服务器对象\n            channel: 语音频道对象\n            user_id: 用户ID\n\n        Raises:\n            VoicePermissionError: 用户不在频道中时抛出\n        \"\"\"\n        member = guild.get_member(user_id)\n        if not member:\n            raise VoicePermissionError(f'无法找到用户 {user_id}', ['member_not_found'], user_id, channel.id)\n\n        if not member.voice or member.voice.channel != channel:\n            raise VoicePermissionError(\n                f'用户 {member.display_name} 不在语音频道 {channel.name} 中',\n                ['user_not_in_channel'],\n                user_id,\n                channel.id,\n            )\n\n    async def _validate_bot_permissions(self, channel: discord.VoiceChannel):\n        \"\"\"\n        验证机器人权限\n\n        @author: @ydzat\n\n        Args:\n            channel: 语音频道对象\n\n        Raises:\n            VoicePermissionError: 权限不足时抛出\n        \"\"\"\n        bot_member = channel.guild.me\n        permissions = channel.permissions_for(bot_member)\n\n        missing_permissions = []\n\n        if not permissions.connect:\n            missing_permissions.append('connect')\n        if not permissions.speak:\n            missing_permissions.append('speak')\n\n        if missing_permissions:\n            raise VoicePermissionError(\n                f'机器人在频道 {channel.name} 中缺少权限: {\", \".join(missing_permissions)}',\n                missing_permissions,\n                channel_id=channel.id,\n            )\n\n    async def cleanup_inactive_connections(self):\n        \"\"\"\n        清理无效连接\n\n        定期检查并清理已断开或无效的语音连接，释放资源。\n\n        @author: @ydzat\n        \"\"\"\n        cleanup_guilds = []\n\n        for guild_id, conn_info in self.connections.items():\n            if not conn_info.voice_client or not conn_info.voice_client.is_connected():\n                cleanup_guilds.append(guild_id)\n\n        for guild_id in cleanup_guilds:\n            await self._disconnect_internal(guild_id)\n\n        if cleanup_guilds:\n            await self.logger.info(f'清理了 {len(cleanup_guilds)} 个无效的语音连接')\n\n    async def start_monitoring(self):\n        \"\"\"\n        开始连接监控\n\n        @author: @ydzat\n        \"\"\"\n        if self._cleanup_task is None and self._monitoring_enabled:\n            self._cleanup_task = asyncio.create_task(self._monitoring_loop())\n\n    async def stop_monitoring(self):\n        \"\"\"\n        停止连接监控\n\n        @author: @ydzat\n        \"\"\"\n        self._monitoring_enabled = False\n        if self._cleanup_task:\n            self._cleanup_task.cancel()\n            try:\n                await self._cleanup_task\n            except asyncio.CancelledError:\n                pass\n            self._cleanup_task = None\n\n    async def _monitoring_loop(self):\n        \"\"\"\n        监控循环\n\n        @author: @ydzat\n        \"\"\"\n        try:\n            while self._monitoring_enabled:\n                await asyncio.sleep(60)  # 每分钟检查一次\n                await self.cleanup_inactive_connections()\n        except asyncio.CancelledError:\n            pass\n\n    async def disconnect_all(self):\n        \"\"\"\n        断开所有连接\n\n        @author: @ydzat\n        \"\"\"\n        async with self._connection_lock:\n            guild_ids = list(self.connections.keys())\n            for guild_id in guild_ids:\n                await self._disconnect_internal(guild_id)\n\n        await self.stop_monitoring()\n\n\nclass DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def yiri2target(\n        message_chain: platform_message.MessageChain,\n    ) -> typing.Tuple[str, typing.List[discord.File]]:\n        for ele in message_chain:\n            if isinstance(ele, platform_message.At):\n                message_chain.remove(ele)\n                break\n\n        text_string = ''\n        files = []\n\n        for ele in message_chain:\n            if isinstance(ele, platform_message.Image):\n                image_bytes = None\n                filename = f'{uuid.uuid4()}.png'  # 默认文件名\n\n                if ele.base64:\n                    # 处理base64编码的图片\n                    if ele.base64.startswith('data:'):\n                        # 从data URL中提取文件类型\n                        data_header = ele.base64.split(',')[0]\n                        if 'jpeg' in data_header or 'jpg' in data_header:\n                            filename = f'{uuid.uuid4()}.jpg'\n                        elif 'gif' in data_header:\n                            filename = f'{uuid.uuid4()}.gif'\n                        elif 'webp' in data_header:\n                            filename = f'{uuid.uuid4()}.webp'\n                        # 去掉data:image/xxx;base64,前缀\n                        base64_data = ele.base64.split(',')[1]\n                    else:\n                        base64_data = ele.base64\n                    image_bytes = base64.b64decode(base64_data)\n                elif ele.url:\n                    # 从URL下载图片\n                    session = httpclient.get_session()\n                    async with session.get(ele.url) as response:\n                        image_bytes = await response.read()\n                        # 从URL或Content-Type推断文件类型\n                        content_type = response.headers.get('Content-Type', '')\n                        if 'jpeg' in content_type or 'jpg' in content_type:\n                            filename = f'{uuid.uuid4()}.jpg'\n                        elif 'gif' in content_type:\n                            filename = f'{uuid.uuid4()}.gif'\n                        elif 'webp' in content_type:\n                            filename = f'{uuid.uuid4()}.webp'\n                        elif ele.url.lower().endswith(('.jpg', '.jpeg')):\n                            filename = f'{uuid.uuid4()}.jpg'\n                        elif ele.url.lower().endswith('.gif'):\n                            filename = f'{uuid.uuid4()}.gif'\n                        elif ele.url.lower().endswith('.webp'):\n                            filename = f'{uuid.uuid4()}.webp'\n                elif ele.path:\n                    # 从文件路径读取图片\n                    # 确保路径没有空字节\n                    clean_path = ele.path.replace('\\x00', '')\n                    clean_path = os.path.abspath(clean_path)\n\n                    if not os.path.exists(clean_path):\n                        continue  # 跳过不存在的文件\n\n                    try:\n                        with open(clean_path, 'rb') as f:\n                            image_bytes = f.read()\n                        # 从文件路径获取文件名，保持原始扩展名\n                        original_filename = os.path.basename(clean_path)\n                        if original_filename and '.' in original_filename:\n                            # 保持原始文件名的扩展名\n                            ext = original_filename.split('.')[-1].lower()\n                            filename = f'{uuid.uuid4()}.{ext}'\n                        else:\n                            # 如果没有扩展名，尝试从文件内容检测\n                            if image_bytes.startswith(b'\\xff\\xd8\\xff'):\n                                filename = f'{uuid.uuid4()}.jpg'\n                            elif image_bytes.startswith(b'GIF'):\n                                filename = f'{uuid.uuid4()}.gif'\n                            elif image_bytes.startswith(b'RIFF') and b'WEBP' in image_bytes[:20]:\n                                filename = f'{uuid.uuid4()}.webp'\n                            # 默认保持PNG\n                    except Exception as e:\n                        print(f'Error reading image file {clean_path}: {e}')\n                        continue  # 跳过读取失败的文件\n\n                if image_bytes:\n                    files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))\n            elif isinstance(ele, platform_message.Plain):\n                text_string += ele.text\n            elif isinstance(ele, platform_message.Voice):\n                file_bytes = None\n                filename = f'{uuid.uuid4()}.mp3'\n                if ele.base64:\n                    if ele.base64.startswith('data:'):\n                        data_header = ele.base64.split(',')[0]\n                        if 'wav' in data_header:\n                            filename = f'{uuid.uuid4()}.wav'\n                        elif 'mp3' in data_header:\n                            filename = f'{uuid.uuid4()}.mp3'\n                        elif 'ogg' in data_header:\n                            filename = f'{uuid.uuid4()}.ogg'\n                        elif 'm4a' in data_header:\n                            filename = f'{uuid.uuid4()}.m4a'\n                        elif 'aac' in data_header:\n                            filename = f'{uuid.uuid4()}.aac'\n                        elif 'flac' in data_header:\n                            filename = f'{uuid.uuid4()}.flac'\n                        elif 'alac' in data_header:\n                            filename = f'{uuid.uuid4()}.alac'\n                        elif 'opus' in data_header:\n                            filename = f'{uuid.uuid4()}.opus'\n                        elif 'webm' in data_header:\n                            filename = f'{uuid.uuid4()}.webm'\n\n                    file_base64 = ele.base64.split(',')[-1]\n                    file_bytes = base64.b64decode(file_base64)\n                elif ele.url:\n                    session = httpclient.get_session()\n                    async with session.get(ele.url) as response:\n                        file_bytes = await response.read()\n                if file_bytes:\n                    files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))\n            elif isinstance(ele, platform_message.File):\n                file_bytes = None\n                filename = f'{uuid.uuid4()}.{ele.name.split(\".\")[-1]}'\n                if ele.base64:\n                    if ele.base64.startswith('data:'):\n                        file_base64 = ele.base64.split(',')[1]\n                        file_bytes = base64.b64decode(file_base64)\n                    else:\n                        file_bytes = base64.b64decode(ele.base64)\n                elif ele.url:\n                    session = httpclient.get_session()\n                    async with session.get(ele.url) as response:\n                        file_bytes = await response.read()\n                if file_bytes:\n                    files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))\n            elif isinstance(ele, platform_message.Forward):\n                for node in ele.node_list:\n                    (\n                        node_text,\n                        node_files,\n                    ) = await DiscordMessageConverter.yiri2target(node.message_chain)\n                    text_string += node_text\n                    files.extend(node_files)\n\n        return text_string, files\n\n    @staticmethod\n    async def target2yiri(message: discord.Message) -> platform_message.MessageChain:\n        lb_msg_list = []\n\n        msg_create_time = datetime.datetime.fromtimestamp(int(message.created_at.timestamp()))\n\n        lb_msg_list.append(platform_message.Source(id=message.id, time=msg_create_time))\n\n        element_list = []\n\n        def text_element_recur(\n            text_ele: str,\n        ) -> list[platform_message.MessageComponent]:\n            if text_ele == '':\n                return []\n\n            # <@1234567890>\n            # @everyone\n            # @here\n            at_pattern = re.compile(r'(@everyone|@here|<@[\\d]+>)')\n            at_matches = at_pattern.findall(text_ele)\n\n            if len(at_matches) > 0:\n                mid_at = at_matches[0]\n\n                text_split = text_ele.split(mid_at)\n\n                mid_at_component = []\n\n                if mid_at == '@everyone' or mid_at == '@here':\n                    mid_at_component.append(platform_message.AtAll())\n                else:\n                    mid_at_component.append(platform_message.At(target=mid_at[2:-1]))\n\n                return text_element_recur(text_split[0]) + mid_at_component + text_element_recur(text_split[1])\n            else:\n                return [platform_message.Plain(text=text_ele)]\n\n        element_list.extend(text_element_recur(message.content))\n\n        # attachments\n        for attachment in message.attachments:\n            session = httpclient.get_session(trust_env=True)\n            async with session.get(attachment.url) as response:\n                image_data = await response.read()\n                image_base64 = base64.b64encode(image_data).decode('utf-8')\n                image_format = response.headers['Content-Type']\n                element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))\n\n        return platform_message.MessageChain(element_list)\n\n\nclass DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def yiri2target(event: platform_events.Event) -> discord.Message:\n        pass\n\n    @staticmethod\n    async def target2yiri(event: discord.Message) -> platform_events.Event:\n        message_chain = await DiscordMessageConverter.target2yiri(event)\n\n        if isinstance(event.channel, discord.DMChannel):\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event.author.id,\n                    nickname=event.author.name,\n                    remark=event.channel.id,\n                ),\n                message_chain=message_chain,\n                time=event.created_at.timestamp(),\n                source_platform_object=event,\n            )\n        elif isinstance(event.channel, discord.TextChannel):\n            return platform_events.GroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=event.author.id,\n                    member_name=event.author.name,\n                    permission=platform_entities.Permission.Member,\n                    group=platform_entities.Group(\n                        id=event.channel.id,\n                        name=event.channel.name,\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title='',\n                ),\n                message_chain=message_chain,\n                time=event.created_at.timestamp(),\n                source_platform_object=event,\n            )\n\n\nclass DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: discord.Client = pydantic.Field(exclude=True)\n\n    message_converter: DiscordMessageConverter = DiscordMessageConverter()\n    event_converter: DiscordEventConverter = DiscordEventConverter()\n\n    listeners: typing.Dict[\n        typing.Type[platform_events.Event],\n        typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],\n    ] = {}\n\n    voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None)\n\n    def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):\n        bot_account_id = config['client_id']\n\n        listeners = {}\n\n        # 初始化语音连接管理器\n        # self.voice_manager: VoiceConnectionManager = None\n\n        adapter_self = self\n\n        class MyClient(discord.Client):\n            async def on_message(self: discord.Client, message: discord.Message):\n                if message.author.id == self.user.id or message.author.bot:\n                    return\n\n                lb_event = await adapter_self.event_converter.target2yiri(message)\n                await adapter_self.listeners[type(lb_event)](lb_event, adapter_self)\n\n        intents = discord.Intents.default()\n        intents.message_content = True\n\n        args = {}\n\n        if os.getenv('http_proxy'):\n            args['proxy'] = os.getenv('http_proxy')\n\n        bot = MyClient(intents=intents, **args)\n\n        super().__init__(\n            config=config,\n            logger=logger,\n            bot_account_id=bot_account_id,\n            listeners=listeners,\n            bot=bot,\n            voice_manager=None,\n            **kwargs,\n        )\n\n    # Voice functionality methods\n    async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:\n        \"\"\"\n        加入语音频道\n\n        为指定服务器的语音频道建立连接，支持用户权限验证和连接复用。\n\n        @author: @ydzat\n        @version: 1.0\n        @since: 2025-07-04\n\n        Args:\n            guild_id (int): Discord 服务器ID\n            channel_id (int): 语音频道ID\n            user_id (int, optional): 请求用户ID，用于权限验证\n\n        Returns:\n            discord.VoiceClient: 语音客户端实例\n\n        Raises:\n            VoicePermissionError: 权限不足\n            VoiceNetworkError: 网络连接失败\n            VoiceConnectionError: 其他连接错误\n        \"\"\"\n        if not self.voice_manager:\n            raise VoiceConnectionError('语音管理器未初始化', 'MANAGER_NOT_READY')\n\n        return await self.voice_manager.join_voice_channel(guild_id, channel_id, user_id)\n\n    async def leave_voice_channel(self, guild_id: int) -> bool:\n        \"\"\"\n        离开语音频道\n\n        断开指定服务器的语音连接，清理相关资源。\n\n        @author: @ydzat\n        @version: 1.0\n        @since: 2025-07-04\n\n        Args:\n            guild_id (int): Discord 服务器ID\n\n        Returns:\n            bool: 是否成功断开连接\n        \"\"\"\n        if not self.voice_manager:\n            return False\n\n        return await self.voice_manager.leave_voice_channel(guild_id)\n\n    async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]:\n        \"\"\"\n        获取语音客户端\n\n        返回指定服务器的语音客户端实例，用于音频播放控制。\n\n        @author: @ydzat\n        @version: 1.0\n        @since: 2025-07-04\n\n        Args:\n            guild_id (int): Discord 服务器ID\n\n        Returns:\n            Optional[discord.VoiceClient]: 语音客户端实例或 None\n        \"\"\"\n        if not self.voice_manager:\n            return None\n\n        return await self.voice_manager.get_voice_client(guild_id)\n\n    async def is_connected_to_voice(self, guild_id: int) -> bool:\n        \"\"\"\n        检查语音连接状态\n\n        @author: @ydzat\n        @version: 1.0\n        @since: 2025-07-04\n\n        Args:\n            guild_id (int): Discord 服务器ID\n\n        Returns:\n            bool: 是否已连接到语音频道\n        \"\"\"\n        if not self.voice_manager:\n            return False\n\n        return await self.voice_manager.is_connected_to_voice(guild_id)\n\n    async def get_voice_connection_status(self, guild_id: int) -> typing.Optional[dict]:\n        \"\"\"\n        获取语音连接详细状态\n\n        返回包含连接时间、延迟、用户数等详细信息的状态字典。\n\n        @author: @ydzat\n        @version: 1.0\n        @since: 2025-07-04\n\n        Args:\n            guild_id (int): Discord 服务器ID\n\n        Returns:\n            Optional[dict]: 连接状态信息或 None\n        \"\"\"\n        if not self.voice_manager:\n            return None\n\n        return await self.voice_manager.get_connection_status(guild_id)\n\n    async def list_active_voice_connections(self) -> typing.List[dict]:\n        \"\"\"\n        列出所有活跃的语音连接\n\n        @author: @ydzat\n        @version: 1.0\n        @since: 2025-07-04\n\n        Returns:\n            List[dict]: 活跃语音连接列表\n        \"\"\"\n        if not self.voice_manager:\n            return []\n\n        return await self.voice_manager.list_active_connections()\n\n    async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]:\n        \"\"\"\n        获取语音频道详细信息\n\n        包括频道名称、用户列表、权限信息等。\n\n        @author: @ydzat\n        @version: 1.0\n        @since: 2025-07-04\n\n        Args:\n            guild_id (int): Discord 服务器ID\n            channel_id (int): 语音频道ID\n\n        Returns:\n            Optional[dict]: 频道信息字典或 None\n        \"\"\"\n        if not self.voice_manager:\n            return None\n\n        return await self.voice_manager.get_voice_channel_info(guild_id, channel_id)\n\n    async def cleanup_voice_connections(self):\n        \"\"\"\n        清理无效的语音连接\n\n        手动触发语音连接清理，移除已断开或无效的连接。\n\n        @author: @ydzat\n        @version: 1.0\n        @since: 2025-07-04\n        \"\"\"\n        if self.voice_manager:\n            await self.voice_manager.cleanup_inactive_connections()\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        msg_to_send, files = await self.message_converter.yiri2target(message)\n\n        try:\n            # 获取频道对象\n            channel = self.bot.get_channel(int(target_id))\n            if channel is None:\n                # 如果本地缓存中没有，尝试从API获取\n                channel = await self.bot.fetch_channel(int(target_id))\n\n            args = {\n                'content': msg_to_send,\n            }\n\n            if len(files) > 0:\n                args['files'] = files\n\n            await channel.send(**args)\n\n        except Exception as e:\n            await self.logger.error(f'Discord send_message failed: {e}')\n            raise e\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        msg_to_send, files = await self.message_converter.yiri2target(message)\n\n        assert isinstance(message_source.source_platform_object, discord.Message)\n\n        args = {\n            'content': msg_to_send,\n        }\n\n        if len(files) > 0:\n            args['files'] = files\n\n        if quote_origin:\n            args['reference'] = message_source.source_platform_object\n\n        has_at = False\n\n        for component in message.root:\n            if isinstance(component, platform_message.At):\n                has_at = True\n                break\n\n        if has_at:\n            args['mention_author'] = True\n\n        await message_source.source_platform_object.channel.send(**args)\n\n    async def is_muted(self, group_id: int) -> bool:\n        return False\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        self.listeners[event_type] = callback\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        self.listeners.pop(event_type)\n\n    async def run_async(self):\n        \"\"\"\n        启动 Discord 适配器\n\n        初始化语音管理器并启动 Discord 客户端连接。\n\n        @author: @ydzat (修改)\n        \"\"\"\n        async with self.bot:\n            # 初始化语音管理器\n            self.voice_manager = VoiceConnectionManager(self.bot, self.logger)\n            await self.voice_manager.start_monitoring()\n\n            await self.logger.info('Discord 适配器语音功能已启用')\n            await self.bot.start(self.config['token'], reconnect=True)\n\n    async def kill(self) -> bool:\n        \"\"\"\n        关闭 Discord 适配器\n\n        清理语音连接并关闭 Discord 客户端。\n\n        @author: @ydzat (修改)\n        \"\"\"\n        if self.voice_manager:\n            await self.voice_manager.disconnect_all()\n\n        await self.bot.close()\n        return True\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/discord.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: discord\n  label:\n    en_US: Discord\n    zh_Hans: Discord\n  description:\n    en_US: Discord Adapter\n    zh_Hans: Discord 适配器，请查看文档了解使用方式\n  icon: discord.svg\nspec:\n  config:\n    - name: client_id\n      label:\n        en_US: Client ID\n        zh_Hans: 客户端ID\n      type: string\n      required: true\n      default: \"\"\n    - name: token\n      label:\n        en_US: Token\n        zh_Hans: 令牌\n      type: string\n      required: true\n      default: \"\"\nexecution:\n  python:\n    path: ./discord.py\n    attr: DiscordAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/kook.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport asyncio\nimport json\nimport base64\nimport zlib\nimport traceback\nimport time\n\nimport aiohttp\n\nfrom langbot.pkg.utils import httpclient\nimport websockets\nimport pydantic\n\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger\n\n\nclass KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    \"\"\"Convert between LangBot MessageChain and KOOK message format\"\"\"\n\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]:\n        \"\"\"\n        Convert LangBot MessageChain to KOOK message format\n\n        Returns:\n            tuple: (content, message_type)\n                - content: message content string\n                - message_type: 1=text, 2=image, 4=file, 9=KMarkdown\n        \"\"\"\n        content_parts = []\n        message_type = 1  # Default to text\n\n        for component in message_chain:\n            if isinstance(component, platform_message.Plain):\n                content_parts.append(component.text)\n            elif isinstance(component, platform_message.At):\n                # KOOK mention format: (met)user_id(met)\n                if component.target:\n                    content_parts.append(f'(met){component.target}(met)')\n            elif isinstance(component, platform_message.AtAll):\n                # KOOK @all format: (met)all(met)\n                content_parts.append('(met)all(met)')\n            elif isinstance(component, platform_message.Image):\n                # For images, we need to upload first via KOOK's asset API\n                # For now, we'll send the image URL if available\n                if component.url:\n                    content_parts.append(component.url)\n                    message_type = 2  # Image message type\n            elif isinstance(component, platform_message.Forward):\n                # Handle forward messages by concatenating content\n                for node in component.node_list:\n                    forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain)\n                    content_parts.append(forward_content)\n            # Ignore Source and other components\n\n        content = ''.join(content_parts)\n        return content, message_type\n\n    @staticmethod\n    async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain:\n        \"\"\"\n        Convert KOOK message format to LangBot MessageChain\n\n        Args:\n            kook_message: KOOK message event data dict\n            bot_account_id: Bot's account ID for handling role mentions\n        \"\"\"\n        components = []\n\n        msg_type = kook_message.get('type', 1)\n        content = kook_message.get('content', '')\n        extra = kook_message.get('extra', {})\n\n        # Handle mentions\n        mentions = extra.get('mention', [])\n        mention_all = extra.get('mention_all', False)\n        mention_roles = extra.get('mention_roles', [])\n\n        if mention_all:\n            components.append(platform_message.AtAll())\n\n        for mention_id in mentions:\n            components.append(platform_message.At(target=str(mention_id)))\n\n        # Handle role mentions (when bot is mentioned via role)\n        # In KOOK, when a role that the bot has is mentioned, we receive it as a role mention\n        # We need to convert this to an At with the bot's account ID for the pipeline to recognize it\n        if mention_roles and bot_account_id:\n            # Add an At component with the bot's account ID when any role is mentioned\n            # This is because KOOK bots are often assigned roles and @role mentions should trigger responses\n            components.append(platform_message.At(target=bot_account_id))\n\n        # Strip mention patterns from content\n        # Remove user mention patterns: (met)USER_ID(met)\n        for mention_id in mentions:\n            content = content.replace(f'(met){mention_id}(met)', '')\n\n        # Remove @all pattern\n        if mention_all:\n            content = content.replace('(met)all(met)', '')\n\n        # Remove role mention patterns: (rol)ROLE_ID(rol)\n        for role_id in mention_roles:\n            content = content.replace(f'(rol){role_id}(rol)', '')\n\n        # Clean up extra whitespace\n        content = content.strip()\n\n        # Handle different message types\n        if msg_type == 1:  # Text message\n            if content:\n                components.append(platform_message.Plain(text=content))\n        elif msg_type == 2:  # Image message\n            # Image content is typically a URL\n            if content:\n                # Download image and convert to base64\n                try:\n                    session = httpclient.get_session()\n                    async with session.get(content) as response:\n                        if response.status == 200:\n                            image_bytes = await response.read()\n                            image_base64 = base64.b64encode(image_bytes).decode('utf-8')\n                            # Detect image format\n                            content_type = response.headers.get('Content-Type', 'image/png')\n                            components.append(\n                                platform_message.Image(base64=f'data:{content_type};base64,{image_base64}')\n                            )\n                except Exception:\n                    # If download fails, just add as plain text\n                    components.append(platform_message.Plain(text=f'[Image: {content}]'))\n        elif msg_type == 4:  # File message\n            # For file messages, content is typically the file URL\n            attachments = extra.get('attachments', {})\n            file_name = attachments.get('name', 'file')\n            components.append(platform_message.File(url=content, name=file_name))\n        elif msg_type == 8:  # Audio message\n            # For audio messages, content is typically the audio URL\n            attachments = extra.get('attachments', {})\n            components.append(platform_message.Voice(url=content))\n        elif msg_type == 9:  # KMarkdown message\n            # Note: content is already stripped of mention patterns above\n            if content:\n                components.append(platform_message.Plain(text=content))\n        elif msg_type == 10:  # Card message\n            # Card messages are complex, for now just indicate it's a card\n            components.append(platform_message.Plain(text='[Card Message]'))\n        else:\n            # Other message types, just use content as plain text\n            if content:\n                components.append(platform_message.Plain(text=content))\n\n        return platform_message.MessageChain(components)\n\n\nclass KookEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    \"\"\"Convert between LangBot events and KOOK events\"\"\"\n\n    @staticmethod\n    async def yiri2target(event: platform_events.MessageEvent):\n        \"\"\"Convert LangBot event to KOOK event (not implemented)\"\"\"\n        pass\n\n    @staticmethod\n    async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageEvent:\n        \"\"\"\n        Convert KOOK event to LangBot MessageEvent\n\n        Args:\n            kook_event: KOOK event data dict containing channel_type, type, etc.\n            bot_account_id: Bot's account ID for handling role mentions\n\n        Returns:\n            FriendMessage or GroupMessage depending on channel_type\n        \"\"\"\n        channel_type = kook_event.get('channel_type')\n        author_id = kook_event.get('author_id')\n        target_id = kook_event.get('target_id')\n        msg_timestamp = kook_event.get('msg_timestamp', int(time.time() * 1000))\n        extra = kook_event.get('extra', {})\n\n        # Convert message to MessageChain\n        message_chain = await KookMessageConverter.target2yiri(kook_event, bot_account_id)\n\n        # Convert timestamp from milliseconds to seconds\n        event_time = msg_timestamp / 1000.0\n\n        if channel_type == 'PERSON':\n            # Direct/Private message\n            author = extra.get('author', {})\n            author_name = author.get('nickname', author.get('username', str(author_id)))\n\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=str(author_id),\n                    nickname=author_name,\n                    remark=str(author_id),\n                ),\n                message_chain=message_chain,\n                time=event_time,\n                source_platform_object=kook_event,\n            )\n        elif channel_type == 'GROUP':\n            # Guild/Server channel message\n            author = extra.get('author', {})\n            author_name = author.get('nickname', author.get('username', str(author_id)))\n\n            # guild_id = extra.get('guild_id', '')\n            channel_name = extra.get('channel_name', str(target_id))\n\n            return platform_events.GroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=str(author_id),\n                    member_name=author_name,\n                    permission=platform_entities.Permission.Member,\n                    group=platform_entities.Group(\n                        id=str(target_id),  # Channel ID\n                        name=channel_name,\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title='',\n                ),\n                message_chain=message_chain,\n                time=event_time,\n                source_platform_object=kook_event,\n            )\n        else:\n            # Fallback to FriendMessage for unknown channel types\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=str(author_id),\n                    nickname=str(author_id),\n                    remark=str(author_id),\n                ),\n                message_chain=message_chain,\n                time=event_time,\n                source_platform_object=kook_event,\n            )\n\n\nclass KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    \"\"\"KOOK platform adapter for LangBot\"\"\"\n\n    config: dict\n    message_converter: KookMessageConverter = KookMessageConverter()\n    event_converter: KookEventConverter = KookEventConverter()\n    listeners: typing.Dict[\n        typing.Type[platform_events.Event],\n        typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],\n    ] = {}\n\n    # WebSocket connection\n    ws: typing.Optional[websockets.WebSocketClientProtocol] = pydantic.Field(exclude=True, default=None)\n    ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)\n    heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)\n    running: bool = pydantic.Field(exclude=True, default=False)\n\n    # Connection state\n    session_id: str = pydantic.Field(exclude=True, default='')\n    current_sn: int = pydantic.Field(exclude=True, default=0)\n    gateway_url: str = pydantic.Field(exclude=True, default='')\n\n    # HTTP session\n    http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)\n\n    def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):\n        # Debug: Track init\n        with open('/tmp/kook_adapter_init.txt', 'w') as f:\n            f.write(f'KOOK adapter __init__ called at {time.time()}\\n')\n\n        # Validate required config\n        if 'token' not in config:\n            raise Exception('KOOK adapter requires \"token\" in config')\n\n        super().__init__(\n            config=config,\n            logger=logger,\n            bot_account_id='',  # Will be set after connection\n            listeners={},\n            **kwargs,\n        )\n\n    async def _get_gateway_url(self) -> str:\n        \"\"\"Get WebSocket gateway URL from KOOK API\"\"\"\n        base_url = 'https://www.kookapp.cn/api/v3/gateway/index'\n\n        # Always use compression for better performance\n        params = {'compress': 1}\n\n        headers = {\n            'Authorization': f'Bot {self.config[\"token\"]}',\n        }\n\n        session = httpclient.get_session()\n        async with session.get(base_url, params=params, headers=headers) as response:\n            if response.status == 200:\n                data = await response.json()\n                if data.get('code') == 0:\n                    gateway_url = data['data']['url']\n                    return gateway_url\n                else:\n                    raise Exception(f'Failed to get gateway URL: {data.get(\"message\")}')\n            else:\n                raise Exception(f'Failed to get gateway URL: HTTP {response.status}')\n\n    async def _get_bot_user_info(self) -> dict:\n        \"\"\"Get bot's own user information from KOOK API\"\"\"\n        base_url = 'https://www.kookapp.cn/api/v3/user/me'\n\n        headers = {\n            'Authorization': f'Bot {self.config[\"token\"]}',\n        }\n\n        session = httpclient.get_session()\n        async with session.get(base_url, headers=headers) as response:\n            if response.status == 200:\n                data = await response.json()\n                if data.get('code') == 0:\n                    user_info = data['data']\n                    return user_info\n                else:\n                    raise Exception(f'Failed to get bot user info: {data.get(\"message\")}')\n            else:\n                raise Exception(f'Failed to get bot user info: HTTP {response.status}')\n\n    async def _handle_hello(self, data: dict):\n        \"\"\"Handle HELLO signal (signal 1)\"\"\"\n        session_id = data.get('session_id', '')\n        self.session_id = session_id\n        await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {session_id}')\n\n    async def _handle_event(self, data: dict, sn: int):\n        \"\"\"Handle EVENT signal (signal 0)\"\"\"\n        self.current_sn = max(self.current_sn, sn)\n\n        # Check if this is a message event\n        event_type = data.get('type')\n        channel_type = data.get('channel_type')\n        author_id = data.get('author_id')\n\n        # Ignore messages from bot itself to prevent infinite loops\n        if self.bot_account_id and str(author_id) == self.bot_account_id:\n            return\n\n        # Only process text messages (type 1, 2, 4, 8, 9, 10) in GROUP or PERSON channels\n        if event_type in [1, 2, 4, 8, 9, 10] and channel_type in ['GROUP', 'PERSON']:\n            try:\n                # Convert to LangBot event\n                lb_event = await self.event_converter.target2yiri(data, self.bot_account_id)\n\n                # Call registered listener\n                event_class = type(lb_event)\n                if event_class in self.listeners:\n                    await self.listeners[event_class](lb_event, self)\n            except Exception as e:\n                await self.logger.error(f'Error handling KOOK event: {e}\\n{traceback.format_exc()}')\n\n    async def _handle_pong(self, data: dict):\n        \"\"\"Handle PONG signal (signal 3)\"\"\"\n        # PONG received, connection is healthy\n        pass\n\n    async def _heartbeat_loop(self):\n        \"\"\"Send PING every 30 seconds\"\"\"\n        try:\n            while self.running and self.ws:\n                await asyncio.sleep(30)\n\n                if self.ws:\n                    try:\n                        ping_msg = {\n                            's': 2,  # PING signal\n                            'sn': self.current_sn,\n                        }\n                        await self.ws.send(json.dumps(ping_msg))\n                    except Exception:\n                        # Connection closed or send failed, exit loop\n                        break\n        except asyncio.CancelledError:\n            pass\n        except Exception as e:\n            await self.logger.error(f'Heartbeat error: {e}')\n\n    async def _websocket_loop(self):\n        \"\"\"Main WebSocket event loop\"\"\"\n        retry_count = 0\n        max_retries = 3\n\n        while self.running and retry_count < max_retries:\n            try:\n                # Get gateway URL if not already retrieved\n                if not self.gateway_url:\n                    self.gateway_url = await self._get_gateway_url()\n\n                # Connect to WebSocket\n                async with websockets.connect(self.gateway_url) as ws:\n                    await self.logger.info(f'Connected to KOOK WebSocket: {self.gateway_url}')\n                    self.ws = ws\n\n                    # Start heartbeat\n                    self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())\n\n                    # Wait for HELLO within 6 seconds\n                    try:\n                        hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)\n\n                        # Handle compressed messages (same as main message loop)\n                        if isinstance(hello_msg, bytes):\n                            # Decompress if compressed\n                            try:\n                                hello_msg = zlib.decompress(hello_msg).decode('utf-8')\n                            except Exception:\n                                # Not compressed or decompression failed\n                                hello_msg = hello_msg.decode('utf-8')\n\n                        hello_data = json.loads(hello_msg)\n\n                        if hello_data.get('s') == 1:  # HELLO signal\n                            await self._handle_hello(hello_data['d'])\n                        else:\n                            raise Exception(f'Expected HELLO signal, got signal {hello_data.get(\"s\")}')\n                    except asyncio.TimeoutError:\n                        raise Exception('Did not receive HELLO within 6 seconds')\n\n                    # Reset retry count on successful connection\n                    retry_count = 0\n\n                    # Main message loop\n                    async for message in ws:\n                        if isinstance(message, bytes):\n                            # Decompress if compressed\n                            try:\n                                message = zlib.decompress(message).decode('utf-8')\n                            except Exception:\n                                # Not compressed or decompression failed\n                                message = message.decode('utf-8')\n\n                        try:\n                            msg_data = json.loads(message)\n                            signal = msg_data.get('s')\n\n                            if signal == 0:  # EVENT\n                                data = msg_data.get('d', {})\n                                sn = msg_data.get('sn', 0)\n                                await self._handle_event(data, sn)\n                            elif signal == 3:  # PONG\n                                await self._handle_pong(msg_data.get('d', {}))\n                            elif signal == 5:  # RECONNECT\n                                # await self.logger.info('Received RECONNECT signal')\n                                break  # Break to reconnect\n                            elif signal == 6:  # RESUME ACK\n                                # await self.logger.info('Resume successful')\n                                pass\n                        except json.JSONDecodeError:\n                            await self.logger.error(f'Failed to parse message: {message}')\n                        except Exception as e:\n                            await self.logger.error(f'Error processing message: {e}\\n{traceback.format_exc()}')\n\n            except websockets.exceptions.ConnectionClosed:\n                await self.logger.warning('KOOK WebSocket connection closed, reconnecting...')\n                retry_count += 1\n                await asyncio.sleep(2**retry_count)  # Exponential backoff\n            except Exception as e:\n                await self.logger.error(f'KOOK WebSocket error: {e}\\n{traceback.format_exc()}')\n                retry_count += 1\n                await asyncio.sleep(2**retry_count)\n            finally:\n                # Stop heartbeat\n                if self.heartbeat_task:\n                    self.heartbeat_task.cancel()\n                    try:\n                        await self.heartbeat_task\n                    except asyncio.CancelledError:\n                        pass\n                self.ws = None\n\n        if retry_count >= max_retries:\n            await self.logger.error(f'Failed to connect after {max_retries} retries')\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        \"\"\"Send a message to a channel or user\"\"\"\n        content, msg_type = await self.message_converter.yiri2target(message)\n\n        # Determine endpoint based on target_type\n        if target_type == 'GROUP':\n            # Send to channel\n            url = 'https://www.kookapp.cn/api/v3/message/create'\n            payload = {\n                'target_id': target_id,\n                'content': content,\n                'type': msg_type,\n            }\n        else:  # PERSON or default\n            # Send direct message\n            url = 'https://www.kookapp.cn/api/v3/direct-message/create'\n            payload = {\n                'target_id': target_id,\n                'content': content,\n                'type': msg_type,\n            }\n\n        headers = {\n            'Authorization': f'Bot {self.config[\"token\"]}',\n            'Content-Type': 'application/json',\n        }\n\n        try:\n            if not self.http_session:\n                self.http_session = httpclient.get_session()\n\n            async with self.http_session.post(url, json=payload, headers=headers) as response:\n                if response.status == 200:\n                    result = await response.json()\n                    if result.get('code') == 0:\n                        await self.logger.debug(f'Message sent successfully to {target_id}')\n                    else:\n                        await self.logger.error(f'Failed to send message: {result.get(\"message\")}')\n                else:\n                    await self.logger.error(f'Failed to send message: HTTP {response.status}')\n        except Exception as e:\n            await self.logger.error(f'Error sending message: {e}')\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        \"\"\"Reply to a message\"\"\"\n        content, msg_type = await self.message_converter.yiri2target(message)\n\n        kook_event = message_source.source_platform_object\n        channel_type = kook_event.get('channel_type')\n        target_id = kook_event.get('target_id')\n        msg_id = kook_event.get('msg_id')\n\n        # Determine endpoint based on channel_type\n        if channel_type == 'GROUP':\n            url = 'https://www.kookapp.cn/api/v3/message/create'\n            payload = {\n                'target_id': target_id,\n                'content': content,\n                'type': msg_type,\n            }\n        else:  # PERSON\n            url = 'https://www.kookapp.cn/api/v3/direct-message/create'\n            # For direct messages, we need the chat_code or target_id\n            author_id = kook_event.get('author_id')\n            extra = kook_event.get('extra', {})\n            chat_code = extra.get('code', '')\n\n            payload = {\n                'content': content,\n                'type': msg_type,\n            }\n\n            if chat_code:\n                payload['chat_code'] = chat_code\n            else:\n                payload['target_id'] = str(author_id)\n\n        # Add quote if requested\n        if quote_origin and msg_id:\n            payload['quote'] = msg_id\n\n        payload['reply_msg_id'] = msg_id\n\n        headers = {\n            'Authorization': f'Bot {self.config[\"token\"]}',\n            'Content-Type': 'application/json',\n        }\n\n        try:\n            if not self.http_session:\n                self.http_session = httpclient.get_session()\n\n            async with self.http_session.post(url, json=payload, headers=headers) as response:\n                if response.status == 200:\n                    result = await response.json()\n                    if result.get('code') == 0:\n                        await self.logger.debug('Reply sent successfully')\n                    else:\n                        await self.logger.error(f'Failed to send reply: {result.get(\"message\")}')\n                else:\n                    await self.logger.error(f'Failed to send reply: HTTP {response.status}')\n        except Exception as e:\n            await self.logger.error(f'Error sending reply: {e}')\n\n    async def is_muted(self, group_id: int) -> bool:\n        \"\"\"Check if bot is muted in a group (not implemented for KOOK)\"\"\"\n        return False\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        \"\"\"Register an event listener\"\"\"\n        self.listeners[event_type] = callback\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        \"\"\"Unregister an event listener\"\"\"\n        self.listeners.pop(event_type, None)\n\n    async def run_async(self):\n        \"\"\"Start the KOOK adapter\"\"\"\n        # Debug: Track run_async\n        with open('/tmp/kook_adapter_run.txt', 'w') as f:\n            f.write(f'KOOK adapter run_async called at {time.time()}\\n')\n\n        self.running = True\n\n        try:\n            # Create HTTP session\n            self.http_session = httpclient.get_session()\n\n            await self.logger.info('Starting KOOK adapter')\n\n            # Get bot's user information and set bot_account_id\n            try:\n                bot_info = await self._get_bot_user_info()\n                self.bot_account_id = str(bot_info.get('id', ''))\n            except Exception as e:\n                await self.logger.error(f'Failed to get bot user info: {e}')\n                # Continue anyway, but bot will process its own messages\n\n            # Start WebSocket connection\n            self.ws_task = asyncio.create_task(self._websocket_loop())\n\n            # Keep running\n            await self.ws_task\n        except Exception as e:\n            await self.logger.error(f'KOOK adapter error: {e}\\n{traceback.format_exc()}')\n        finally:\n            self.running = False\n\n    async def kill(self) -> bool:\n        \"\"\"Stop the KOOK adapter\"\"\"\n        self.running = False\n\n        # Cancel tasks\n        if self.heartbeat_task:\n            self.heartbeat_task.cancel()\n            try:\n                await self.heartbeat_task\n            except asyncio.CancelledError:\n                pass\n\n        if self.ws_task:\n            self.ws_task.cancel()\n            try:\n                await self.ws_task\n            except asyncio.CancelledError:\n                pass\n\n        # Close WebSocket\n        if self.ws:\n            try:\n                await self.ws.close()\n            except Exception:\n                pass  # Already closed or error during close\n\n        # Close HTTP session\n        if self.http_session:\n            await self.http_session.close()\n\n        await self.logger.info('KOOK adapter stopped')\n        return True\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/kook.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: kook\n  label:\n    en_US: KOOK\n    zh_Hans: KOOK\n  description:\n    en_US: KOOK Adapter (formerly KaiHeiLa)\n    zh_Hans: KOOK 适配器(原开黑啦)，支持频道消息和私聊消息\n  icon: kook.png\nspec:\n  config:\n    - name: token\n      label:\n        en_US: Bot Token\n        zh_Hans: 机器人令牌\n      type: string\n      required: true\n      default: \"\"\nexecution:\n  python:\n    path: ./kook.py\n    attr: KookAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/lark.py",
    "content": "from __future__ import annotations\n\nimport lark_oapi\nfrom lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody, CreateFileRequest, CreateFileRequestBody\nimport traceback\nimport typing\nimport asyncio\nimport re\nimport base64\nimport uuid\nimport json\nimport time\nimport datetime\nimport hashlib\nfrom Crypto.Cipher import AES\nimport tempfile\nimport os\nimport mimetypes\n\nfrom langbot.pkg.utils import httpclient\nimport lark_oapi.ws.exception\nimport quart\nfrom lark_oapi.api.im.v1 import *\nimport pydantic\nfrom lark_oapi.api.cardkit.v1 import *\nfrom lark_oapi.api.auth.v3 import *\nfrom lark_oapi.core.model import *\n\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger\n\n\nclass AESCipher(object):\n    def __init__(self, key):\n        self.bs = AES.block_size\n        self.key = hashlib.sha256(AESCipher.str_to_bytes(key)).digest()\n\n    @staticmethod\n    def str_to_bytes(data):\n        u_type = type(b''.decode('utf8'))\n        if isinstance(data, u_type):\n            return data.encode('utf8')\n        return data\n\n    @staticmethod\n    def _unpad(s):\n        return s[: -ord(s[len(s) - 1 :])]\n\n    def decrypt(self, enc):\n        iv = enc[: AES.block_size]\n        cipher = AES.new(self.key, AES.MODE_CBC, iv)\n        return self._unpad(cipher.decrypt(enc[AES.block_size :]))\n\n    def decrypt_string(self, enc):\n        enc = base64.b64decode(enc)\n        return self.decrypt(enc).decode('utf8')\n\n\nclass LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> typing.Optional[str]:\n        \"\"\"Upload an image to Lark and return the image_key, or None if upload fails.\"\"\"\n        image_bytes = None\n\n        if msg.base64:\n            try:\n                # Remove data URL prefix if present\n                base64_data = msg.base64\n                if base64_data.startswith('data:'):\n                    base64_data = base64_data.split(',', 1)[1]\n                image_bytes = base64.b64decode(base64_data)\n            except Exception as e:\n                print(f'Failed to decode base64 image: {e}')\n                traceback.print_exc()\n                return None\n        elif msg.url:\n            try:\n                session = httpclient.get_session()\n                async with session.get(msg.url) as response:\n                    if response.status == 200:\n                        image_bytes = await response.read()\n                    else:\n                        print(f'Failed to download image from {msg.url}: HTTP {response.status}')\n                        return None\n            except Exception as e:\n                print(f'Failed to download image from {msg.url}: {e}')\n                traceback.print_exc()\n                return None\n        elif msg.path:\n            try:\n                with open(msg.path, 'rb') as f:\n                    image_bytes = f.read()\n            except Exception as e:\n                print(f'Failed to read image from path {msg.path}: {e}')\n                traceback.print_exc()\n                return None\n\n        if image_bytes is None:\n            print(\n                f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})'\n            )\n            return None\n\n        try:\n            # Create a temporary file to store the image bytes\n            import tempfile\n            import os\n\n            with tempfile.NamedTemporaryFile(delete=False) as temp_file:\n                temp_file.write(image_bytes)\n                temp_file.flush()\n                temp_file_path = temp_file.name\n\n            try:\n                # Create image request using the temporary file\n                request = (\n                    CreateImageRequest.builder()\n                    .request_body(\n                        CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build()\n                    )\n                    .build()\n                )\n\n                response = await api_client.im.v1.image.acreate(request)\n\n                if not response.success():\n                    print(\n                        f'client.im.v1.image.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}'\n                    )\n                    return None\n\n                return response.data.image_key\n            finally:\n                # Clean up the temporary file\n                os.unlink(temp_file_path)\n        except Exception as e:\n            print(f'Failed to upload image to Lark: {e}')\n            traceback.print_exc()\n            return None\n\n    @staticmethod\n    async def upload_file_to_lark(\n        file_bytes: bytes,\n        api_client: lark_oapi.Client,\n        file_type: str,\n        file_name: str = 'file',\n        duration: typing.Optional[int] = None,\n    ) -> typing.Optional[str]:\n        \"\"\"Upload a file to Lark and return the file_key, or None if upload fails.\n\n        Args:\n            file_bytes: Raw file bytes.\n            api_client: Lark API client.\n            file_type: Lark file type, e.g. 'opus', 'mp4', 'pdf', 'doc', etc.\n            file_name: Display name for the file.\n            duration: Duration in milliseconds (for audio files).\n        \"\"\"\n        try:\n            with tempfile.NamedTemporaryFile(delete=False) as temp_file:\n                temp_file.write(file_bytes)\n                temp_file_path = temp_file.name\n\n            try:\n                body_builder = (\n                    CreateFileRequestBody.builder()\n                    .file_type(file_type)\n                    .file_name(file_name)\n                    .file(open(temp_file_path, 'rb'))\n                )\n                if duration is not None:\n                    body_builder = body_builder.duration(duration)\n\n                request = CreateFileRequest.builder().request_body(body_builder.build()).build()\n\n                response = await api_client.im.v1.file.acreate(request)\n\n                if not response.success():\n                    print(\n                        f'client.im.v1.file.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}'\n                    )\n                    return None\n\n                return response.data.file_key\n            finally:\n                os.unlink(temp_file_path)\n        except Exception as e:\n            print(f'Failed to upload file to Lark: {e}')\n            traceback.print_exc()\n            return None\n\n    @staticmethod\n    async def _get_media_bytes(\n        msg: typing.Union[platform_message.Voice, platform_message.File],\n    ) -> typing.Optional[bytes]:\n        \"\"\"Get bytes from a Voice or File message (base64, url, or path).\"\"\"\n        data = None\n\n        if msg.base64:\n            try:\n                base64_str = msg.base64\n                if ',' in base64_str:\n                    base64_str = base64_str.split(',', 1)[1]\n                data = base64.b64decode(base64_str)\n            except Exception:\n                pass\n        elif msg.url:\n            try:\n                session = httpclient.get_session()\n                async with session.get(msg.url) as resp:\n                    if resp.status == 200:\n                        data = await resp.read()\n            except Exception:\n                pass\n        elif msg.path:\n            try:\n                with open(msg.path, 'rb') as f:\n                    data = f.read()\n            except Exception:\n                pass\n\n        return data\n\n    @staticmethod\n    async def yiri2target(\n        message_chain: platform_message.MessageChain, api_client: lark_oapi.Client\n    ) -> typing.Tuple[list, list]:\n        \"\"\"Convert message chain to Lark format.\n\n        Returns:\n            Tuple of (text_elements, image_keys):\n            - text_elements: List of paragraphs for post message format\n            - media_items: List of dicts with 'msg_type' and 'content' for separate media messages\n        \"\"\"\n        message_elements = []\n        media_items = []\n        pending_paragraph = []\n\n        # Regex pattern to match Markdown image syntax: ![alt](url)\n        markdown_image_pattern = re.compile(r'!\\[([^\\]]*)\\]\\(([^)]+)\\)')\n\n        async def process_text_with_images(text: str) -> typing.Tuple[str, list]:\n            \"\"\"Extract Markdown images from text and return cleaned text + image URLs.\"\"\"\n            extracted_urls = []\n\n            # Find all Markdown images\n            matches = list(markdown_image_pattern.finditer(text))\n            if not matches:\n                return text, []\n\n            # Extract URLs and remove image syntax from text\n            cleaned_text = text\n            for match in reversed(matches):  # Reverse to maintain correct positions\n                url = match.group(2)\n                extracted_urls.insert(0, url)  # Insert at beginning since we're going in reverse\n                # Replace image syntax with empty string or a placeholder\n                cleaned_text = cleaned_text[: match.start()] + cleaned_text[match.end() :]\n\n            # Clean up multiple consecutive newlines that might result from removing images\n            cleaned_text = re.sub(r'\\n{3,}', '\\n\\n', cleaned_text)\n            cleaned_text = cleaned_text.strip()\n\n            return cleaned_text, extracted_urls\n\n        for msg in message_chain:\n            if isinstance(msg, platform_message.Plain):\n                # Ensure text is valid UTF-8\n                try:\n                    text = msg.text.encode('utf-8').decode('utf-8')\n                except UnicodeError:\n                    try:\n                        text = msg.text.encode('latin1').decode('utf-8')\n                    except UnicodeError:\n                        text = msg.text.encode('utf-8', errors='replace').decode('utf-8')\n\n                # Check for and extract Markdown images from text\n                cleaned_text, extracted_urls = await process_text_with_images(text)\n\n                # Split by blank lines to create separate paragraphs for Lark post format.\n                # Lark truncates md elements at the first \\n\\n, so we must use the\n                # post format's native paragraph structure instead.\n                if cleaned_text:\n                    segments = re.split(r'\\n\\s*\\n', cleaned_text)\n                    for i, segment in enumerate(segments):\n                        segment = segment.strip()\n                        if not segment:\n                            continue\n                        if i > 0 and pending_paragraph:\n                            message_elements.append(pending_paragraph)\n                            pending_paragraph = []\n                        pending_paragraph.append({'tag': 'md', 'text': segment})\n\n                # Process extracted image URLs\n                for url in extracted_urls:\n                    temp_image = platform_message.Image(url=url)\n                    image_key = await LarkMessageConverter.upload_image_to_lark(temp_image, api_client)\n                    if image_key:\n                        media_items.append({'msg_type': 'image', 'content': {'image_key': image_key}})\n\n            elif isinstance(msg, platform_message.At):\n                pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []})\n            elif isinstance(msg, platform_message.AtAll):\n                pending_paragraph.append({'tag': 'at', 'user_id': 'all', 'style': []})\n            elif isinstance(msg, platform_message.Image):\n                image_key = await LarkMessageConverter.upload_image_to_lark(msg, api_client)\n                if image_key:\n                    media_items.append({'msg_type': 'image', 'content': {'image_key': image_key}})\n            elif isinstance(msg, platform_message.Voice):\n                data = await LarkMessageConverter._get_media_bytes(msg)\n                if data:\n                    duration = int(msg.length * 1000) if msg.length else None\n                    file_key = await LarkMessageConverter.upload_file_to_lark(\n                        data, api_client, file_type='opus', file_name='voice.opus', duration=duration\n                    )\n                    if file_key:\n                        media_items.append({'msg_type': 'audio', 'content': {'file_key': file_key}})\n            elif isinstance(msg, platform_message.File):\n                data = await LarkMessageConverter._get_media_bytes(msg)\n                if data:\n                    file_name = msg.name or 'file'\n                    # Guess file_type from extension\n                    ext = os.path.splitext(file_name)[1].lstrip('.').lower() if file_name else ''\n                    file_type_map = {\n                        'opus': 'opus',\n                        'mp4': 'mp4',\n                        'pdf': 'pdf',\n                        'doc': 'doc',\n                        'docx': 'doc',\n                        'xls': 'xls',\n                        'xlsx': 'xls',\n                        'ppt': 'ppt',\n                        'pptx': 'ppt',\n                    }\n                    file_type = file_type_map.get(ext, 'stream')\n                    file_key = await LarkMessageConverter.upload_file_to_lark(\n                        data, api_client, file_type=file_type, file_name=file_name\n                    )\n                    if file_key:\n                        media_items.append({'msg_type': 'file', 'content': {'file_key': file_key}})\n            elif isinstance(msg, platform_message.Forward):\n                for node in msg.node_list:\n                    sub_elements, sub_media = await LarkMessageConverter.yiri2target(node.message_chain, api_client)\n                    message_elements.extend(sub_elements)\n                    media_items.extend(sub_media)\n\n        if pending_paragraph:\n            message_elements.append(pending_paragraph)\n\n        return message_elements, media_items\n\n    @staticmethod\n    async def target2yiri(\n        message: lark_oapi.api.im.v1.model.event_message.EventMessage,\n        api_client: lark_oapi.Client,\n    ) -> platform_message.MessageChain:\n        message_content = json.loads(message.content)\n\n        lb_msg_list = []\n\n        msg_create_time = datetime.datetime.fromtimestamp(int(message.create_time) / 1000)\n\n        lb_msg_list.append(platform_message.Source(id=message.message_id, time=msg_create_time))\n\n        if message.message_type == 'text':\n            element_list = []\n\n            def text_element_recur(text_ele: dict) -> list[dict]:\n                if text_ele['text'] == '':\n                    return []\n\n                at_pattern = re.compile(r'@_user_[\\d]+')\n                at_matches = at_pattern.findall(text_ele['text'])\n\n                name_mapping = {}\n                for mathc in at_matches:\n                    for mention in message.mentions:\n                        if mention.key == mathc:\n                            name_mapping[mathc] = mention.name\n                            break\n\n                if len(name_mapping.keys()) == 0:\n                    return [text_ele]\n\n                # 只处理第一个，剩下的递归处理\n                text_split = text_ele['text'].split(list(name_mapping.keys())[0])\n\n                new_list = []\n\n                left_text = text_split[0]\n                right_text = text_split[1]\n\n                new_list.extend(text_element_recur({'tag': 'text', 'text': left_text, 'style': []}))\n\n                new_list.append(\n                    {\n                        'tag': 'at',\n                        'user_id': list(name_mapping.keys())[0],\n                        'user_name': name_mapping[list(name_mapping.keys())[0]],\n                        'style': [],\n                    }\n                )\n\n                new_list.extend(text_element_recur({'tag': 'text', 'text': right_text, 'style': []}))\n\n                return new_list\n\n            element_list = text_element_recur({'tag': 'text', 'text': message_content['text'], 'style': []})\n\n            message_content = {'title': '', 'content': element_list}\n\n        elif message.message_type == 'post':\n            new_list = []\n\n            for ele in message_content['content']:\n                if type(ele) is dict:\n                    new_list.append(ele)\n                elif type(ele) is list:\n                    new_list.extend(ele)\n\n            message_content['content'] = new_list\n        elif message.message_type == 'image':\n            message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}]\n        elif message.message_type == 'file':\n            message_content['content'] = [\n                {'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']}\n            ]\n        elif message.message_type == 'audio':\n            message_content['content'] = [\n                {\n                    'tag': 'audio',\n                    'file_key': message_content['file_key'],\n                    'duration': message_content.get('duration', 0),\n                }\n            ]\n\n        for ele in message_content['content']:\n            if ele['tag'] == 'text':\n                lb_msg_list.append(platform_message.Plain(text=ele['text']))\n            elif ele['tag'] == 'at':\n                lb_msg_list.append(platform_message.At(target=ele['user_name']))\n            elif ele['tag'] == 'img':\n                image_key = ele['image_key']\n\n                request: GetMessageResourceRequest = (\n                    GetMessageResourceRequest.builder()\n                    .message_id(message.message_id)\n                    .file_key(image_key)\n                    .type('image')\n                    .build()\n                )\n\n                response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)\n\n                if not response.success():\n                    raise Exception(\n                        f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n                    )\n\n                image_bytes = response.file.read()\n                image_base64 = base64.b64encode(image_bytes).decode()\n\n                image_format = response.raw.headers['content-type']\n\n                lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))\n            elif ele['tag'] == 'audio':\n                file_key = ele['file_key']\n                duration = ele['duration']\n\n                # Download audio file\n                request: GetMessageResourceRequest = (\n                    GetMessageResourceRequest.builder()\n                    .message_id(message.message_id)\n                    .file_key(file_key)\n                    .type('file')\n                    .build()\n                )\n\n                try:\n                    response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)\n\n                    if not response.success():\n                        print(f'Failed to download audio: code: {response.code}, msg: {response.msg}')\n                        lb_msg_list.append(platform_message.Plain(text='[Audio file download failed]'))\n                        return platform_message.MessageChain(lb_msg_list)\n\n                    # Read audio bytes\n                    audio_bytes = response.file.read()\n                    audio_base64 = base64.b64encode(audio_bytes).decode()\n\n                    # Get content type from response headers\n                    content_type = response.raw.headers.get('content-type', 'audio/mpeg')\n\n                    mime_main = content_type.split(';')[0].strip()\n                    ext = mimetypes.guess_extension(mime_main) or '.bin'\n                    temp_dir = tempfile.gettempdir()\n                    temp_file_path = os.path.join(temp_dir, f'lark_audio_{file_key}{ext}')\n\n                    with open(temp_file_path, 'wb') as f:\n                        f.write(audio_bytes)\n\n                    # Create Voice message: prefer path/url + length, include base64 as optional data URI\n                    lb_msg_list.append(\n                        platform_message.Voice(\n                            voice_id=file_key,\n                            url=f'file://{temp_file_path}',\n                            path=temp_file_path,\n                            base64=f'data:{content_type};base64,{audio_base64}',\n                            length=(duration // 1000) if duration else None,\n                        )\n                    )\n                except Exception as e:\n                    print(f'Error downloading audio: {e}')\n                    traceback.print_exc()\n                    lb_msg_list.append(platform_message.Plain(text='[Audio file download error]'))\n\n            elif ele['tag'] == 'file':\n                file_key = ele['file_key']\n                file_name = ele['file_name']\n\n                request: GetMessageResourceRequest = (\n                    GetMessageResourceRequest.builder()\n                    .message_id(message.message_id)\n                    .file_key(file_key)\n                    .type('file')\n                    .build()\n                )\n\n                response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)\n\n                if not response.success():\n                    raise Exception(\n                        f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n                    )\n\n                file_bytes = response.file.read()\n                file_base64 = base64.b64encode(file_bytes).decode()\n\n                file_format = response.raw.headers['content-type']\n\n                file_size = len(file_bytes)\n\n                # Determine extension from content-type if possible\n                content_type = response.raw.headers.get('content-type', '')\n                mime_main = content_type.split(';')[0].strip() if content_type else ''\n                ext = mimetypes.guess_extension(mime_main) or ''\n\n                # Ensure a safe filename (avoid path components)\n                safe_name = os.path.basename(file_name).replace('/', '_').replace('\\\\', '_')\n                if ext and not safe_name.lower().endswith(ext.lower()):\n                    filename_with_ext = f'{safe_name}{ext}'\n                else:\n                    filename_with_ext = safe_name\n\n                temp_dir = tempfile.gettempdir()\n                temp_file_path = os.path.join(temp_dir, f'lark_{file_key}_{filename_with_ext}')\n\n                with open(temp_file_path, 'wb') as f:\n                    f.write(file_bytes)\n\n                # Create File message with local path and file:// URL\n                lb_msg_list.append(\n                    platform_message.File(\n                        id=file_key,\n                        name=file_name,\n                        size=file_size,\n                        url=f'file://{temp_file_path}',\n                        path=temp_file_path,\n                        base64=f'data:{file_format};base64,{file_base64}',  # not including base64 by default to save memory; can be added if needed\n                    )\n                )\n\n        return platform_message.MessageChain(lb_msg_list)\n\n\nclass LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    _processed_thread_quote_cache: typing.ClassVar[dict[str, float]] = {}\n    _processed_thread_quote_cache_max_size: typing.ClassVar[int] = 4096\n    _processed_thread_quote_cache_ttl_seconds: typing.ClassVar[int] = 86400\n\n    @classmethod\n    def _prune_processed_thread_quote_cache(cls, now: typing.Optional[float] = None) -> None:\n        if now is None:\n            now = time.time()\n\n        expire_before = now - cls._processed_thread_quote_cache_ttl_seconds\n        while cls._processed_thread_quote_cache:\n            oldest_key, oldest_ts = next(iter(cls._processed_thread_quote_cache.items()))\n            if oldest_ts >= expire_before:\n                break\n            cls._processed_thread_quote_cache.pop(oldest_key, None)\n\n        while len(cls._processed_thread_quote_cache) > cls._processed_thread_quote_cache_max_size:\n            oldest_key = next(iter(cls._processed_thread_quote_cache))\n            cls._processed_thread_quote_cache.pop(oldest_key, None)\n\n    @classmethod\n    def _mark_thread_quote_processed(cls, thread_id: str) -> None:\n        now = time.time()\n        cls._prune_processed_thread_quote_cache(now)\n        cls._processed_thread_quote_cache[thread_id] = now\n\n    @classmethod\n    def _extract_quote_message_id(cls, message: EventMessage) -> typing.Optional[str]:\n        \"\"\"\n        Extract the message ID to quote from the given message.\n\n        Rules:\n        - First thread reply in a topic: return parent_id and mark topic as processed\n        - Follow-up thread replies in the same topic: return None\n        - Non-thread message: return parent_id if valid (non-empty, different from message_id)\n\n        Thread reply state is kept in a bounded TTL cache to avoid unbounded memory growth.\n        \"\"\"\n        parent_id = getattr(message, 'parent_id', None)\n        if not parent_id:\n            return None\n\n        message_id = getattr(message, 'message_id', None)\n        if parent_id == message_id:\n            return None\n\n        thread_id = getattr(message, 'thread_id', None)\n        if thread_id:\n            cls._prune_processed_thread_quote_cache()\n            if thread_id in cls._processed_thread_quote_cache:\n                return None\n            cls._mark_thread_quote_processed(thread_id)\n\n        return parent_id\n\n    @staticmethod\n    def _build_event_message_from_message_item(message_item: Message) -> typing.Optional[EventMessage]:\n        \"\"\"\n        Build EventMessage from SDK typed Message item.\n\n        Returns None if body or content is missing.\n        \"\"\"\n        body = getattr(message_item, 'body', None)\n        if not body:\n            return None\n\n        content = getattr(body, 'content', None)\n        if not content:\n            return None\n\n        event_data = {\n            'message_id': message_item.message_id,\n            'message_type': message_item.msg_type,\n            'content': content,\n            'create_time': message_item.create_time,\n            'mentions': getattr(message_item, 'mentions', []) or [],\n        }\n\n        # Preserve thread-related fields\n        if hasattr(message_item, 'parent_id') and message_item.parent_id:\n            event_data['parent_id'] = message_item.parent_id\n        if hasattr(message_item, 'root_id') and message_item.root_id:\n            event_data['root_id'] = message_item.root_id\n        if hasattr(message_item, 'thread_id') and message_item.thread_id:\n            event_data['thread_id'] = message_item.thread_id\n        if hasattr(message_item, 'chat_id') and message_item.chat_id:\n            event_data['chat_id'] = message_item.chat_id\n\n        return EventMessage(event_data)\n\n    @staticmethod\n    async def _fetch_quoted_message(\n        quote_message_id: str,\n        api_client: lark_oapi.Client,\n    ) -> typing.Optional[platform_message.MessageChain]:\n        \"\"\"\n        Fetch the quoted message and convert to MessageChain.\n\n        Returns None if:\n        - API call fails\n        - Response items is empty\n        - Message item normalization fails\n        \"\"\"\n        request = GetMessageRequest.builder().message_id(quote_message_id).build()\n        response = await api_client.im.v1.message.aget(request)\n\n        if not response.success():\n            return None\n\n        items = getattr(response.data, 'items', None)\n        if not items:\n            return None\n\n        message_item = items[0]\n        event_message = LarkEventConverter._build_event_message_from_message_item(message_item)\n        if event_message is None:\n            return None\n\n        quote_chain = await LarkMessageConverter.target2yiri(event_message, api_client)\n        return quote_chain\n\n    @staticmethod\n    async def yiri2target(\n        event: platform_events.MessageEvent,\n    ) -> lark_oapi.im.v1.P2ImMessageReceiveV1:\n        pass\n\n    @staticmethod\n    async def target2yiri(\n        event: lark_oapi.im.v1.P2ImMessageReceiveV1, api_client: lark_oapi.Client\n    ) -> platform_events.Event:\n        message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)\n\n        # Check for quote/reply message\n        quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)\n        if quote_message_id:\n            quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)\n            if quote_chain:\n                # Filter out Source component from quoted chain, keep only content\n                quote_origin = platform_message.MessageChain(\n                    [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]\n                )\n                if quote_origin:\n                    message_chain.append(\n                        platform_message.Quote(\n                            message_id=quote_message_id,\n                            origin=quote_origin,\n                        )\n                    )\n\n        if event.event.message.chat_type == 'p2p':\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event.event.sender.sender_id.open_id,\n                    nickname=event.event.sender.sender_id.union_id,\n                    remark='',\n                ),\n                message_chain=message_chain,\n                time=event.event.message.create_time,\n                source_platform_object=event,\n            )\n        elif event.event.message.chat_type == 'group':\n            return platform_events.GroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=event.event.sender.sender_id.open_id,\n                    member_name=event.event.sender.sender_id.union_id,\n                    permission=platform_entities.Permission.Member,\n                    group=platform_entities.Group(\n                        id=event.event.message.chat_id,\n                        name='',\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title='',\n                ),\n                message_chain=message_chain,\n                time=event.event.message.create_time,\n                source_platform_object=event,\n            )\n\n\nCARD_ID_CACHE_SIZE = 500\nCARD_ID_CACHE_MAX_LIFETIME = 20 * 60  # 20分钟\n\n\nclass LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: lark_oapi.ws.Client = pydantic.Field(exclude=True)\n    api_client: lark_oapi.Client = pydantic.Field(exclude=True)\n\n    bot_account_id: str  # 用于在流水线中识别at是否是本bot，直接以bot_name作为标识\n    lark_tenant_key: str = pydantic.Field(exclude=True, default='')  # 飞书企业key\n\n    message_converter: LarkMessageConverter = LarkMessageConverter()\n    event_converter: LarkEventConverter = LarkEventConverter()\n    cipher: AESCipher\n\n    listeners: typing.Dict[\n        typing.Type[platform_events.Event],\n        typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],\n    ]\n\n    quart_app: quart.Quart = pydantic.Field(exclude=True)\n\n    card_id_dict: dict[str, str]  # 消息id到卡片id的映射，便于创建卡片后的发送消息到指定卡片\n\n    seq: int  # 用于在发送卡片消息中识别消息顺序，直接以seq作为标识\n    bot_uuid: str = None  # 机器人UUID\n    app_ticket: str = None  # 商店应用用到\n    app_access_token: str = None  # 商店应用用到\n    app_access_token_expire_at: int = None\n    tenant_access_tokens: dict[str, dict[str, str]] = {}  # 租户access_token映射\n\n    def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):\n        quart_app = quart.Quart(__name__)\n\n        async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):\n            lb_event = await self.event_converter.target2yiri(event, self.api_client)\n\n            await self.listeners[type(lb_event)](lb_event, self)\n\n        def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):\n            asyncio.create_task(on_message(event))\n\n        event_handler = (\n            lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build()\n        )\n\n        bot_account_id = config['bot_name']\n\n        bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)\n        api_client = self.build_api_client(config)\n        cipher = AESCipher(config.get('encrypt-key', ''))\n        self.request_app_ticket(api_client, config)\n\n        super().__init__(\n            config=config,\n            logger=logger,\n            lark_tenant_key=config.get('lark_tenant_key', ''),\n            card_id_dict={},\n            seq=1,\n            listeners={},\n            quart_app=quart_app,\n            bot=bot,\n            api_client=api_client,\n            bot_account_id=bot_account_id,\n            cipher=cipher,\n            **kwargs,\n        )\n\n    def request_app_ticket(self, api_client, config):\n        app_id = config['app_id']\n        app_secret = config['app_secret']\n        print(f'Requesting app ticket for app_id: {app_id[:3]}***{app_id[-3:]}')\n        if 'isv' == config.get('app_type', 'self'):\n            request: ResendAppTicketRequest = (\n                ResendAppTicketRequest.builder()\n                .request_body(ResendAppTicketRequestBody.builder().app_id(app_id).app_secret(app_secret).build())\n                .build()\n            )\n            response: ResendAppTicketResponse = api_client.auth.v3.app_ticket.resend(request)\n            if not response.success():\n                raise Exception(\n                    f'client.auth.v3.auth.app_ticket_resend failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n                )\n\n    def request_app_access_token(self):\n        app_id = self.config['app_id']\n        app_secret = self.config['app_secret']\n        if 'isv' == self.config.get('app_type', 'self'):\n            request: CreateAppAccessTokenRequest = (\n                CreateAppAccessTokenRequest.builder()\n                .request_body(\n                    CreateAppAccessTokenRequestBody.builder()\n                    .app_id(app_id)\n                    .app_secret(app_secret)\n                    .app_ticket(self.app_ticket)\n                    .build()\n                )\n                .build()\n            )\n            response: CreateAppAccessTokenResponse = self.api_client.auth.v3.app_access_token.create(request)\n            if not response.success():\n                raise Exception(\n                    f'client.auth.v3.auth.app_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n                )\n            content = json.loads(response.raw.content)\n            self.app_access_token = content['app_access_token']\n            self.app_access_token_expire_at = int(time.time()) + content['expire'] - 300\n\n    def get_app_access_token(self):\n        if 'isv' != self.config.get('app_type', 'self'):\n            return None\n        if (\n            self.app_access_token is None\n            or self.app_access_token_expire_at is None\n            or int(time.time()) >= self.app_access_token_expire_at\n        ):\n            self.request_app_access_token()\n        return self.app_access_token\n\n    def request_tenant_access_token(self, tenant_key: str):\n        app_access_token = self.get_app_access_token()\n        if 'isv' == self.config.get('app_type', 'self'):\n            request: CreateTenantAccessTokenRequest = (\n                CreateTenantAccessTokenRequest.builder()\n                .request_body(\n                    CreateTenantAccessTokenRequestBody.builder()\n                    .app_access_token(app_access_token)\n                    .tenant_key(tenant_key)\n                    .build()\n                )\n                .build()\n            )\n            response: CreateTenantAccessTokenResponse = self.api_client.auth.v3.tenant_access_token.create(request)\n            if not response.success():\n                raise Exception(\n                    f'client.auth.v3.auth.tenant_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n                )\n            content = json.loads(response.raw.content)\n            tenant_access_token = content['tenant_access_token']\n            expire = content['expire']\n            self.tenant_access_tokens[tenant_key] = {\n                'token': tenant_access_token,\n                'expire_at': int(time.time()) + expire - 300,\n            }\n\n    def get_tenant_access_token(self, tenant_key: str):\n        if tenant_key is None or 'isv' != self.config.get('app_type', 'self'):\n            return None\n        tenant_access_token = self.tenant_access_tokens.get(tenant_key)\n        if tenant_access_token is None or int(time.time()) >= tenant_access_token['expire_at']:\n            self.request_tenant_access_token(tenant_key)\n        return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None\n\n    def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:\n        \"\"\"\n        Get topic-scoped launcher_id for thread-aware session isolation.\n\n        For group thread messages, returns \"{group_id}_{thread_id}\"\n        to ensure conversation context stays stable per topic.\n\n        Returns None for non-thread messages or P2P messages.\n        \"\"\"\n        source_event = getattr(event.source_platform_object, 'event', None)\n        if not source_event:\n            return None\n\n        message = getattr(source_event, 'message', None)\n        if not message:\n            return None\n\n        thread_id = getattr(message, 'thread_id', None)\n        if not thread_id:\n            return None\n\n        if isinstance(event, platform_events.GroupMessage):\n            return f'{event.group.id}_{thread_id}'\n\n        return None\n\n    def build_api_client(self, config):\n        app_id = config['app_id']\n        app_secret = config['app_secret']\n        api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build()\n        if 'isv' == config.get('app_type', 'self'):\n            api_client = (\n                lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build()\n            )\n        return api_client\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        pass\n\n    async def is_stream_output_supported(self) -> bool:\n        is_stream = False\n        if self.config.get('enable-stream-reply', None):\n            is_stream = True\n        return is_stream\n\n    async def create_card_id(self, message_id):\n        try:\n            # self.logger.debug('飞书支持stream输出,创建卡片......')\n\n            card_data = {\n                'schema': '2.0',\n                'config': {\n                    'update_multi': True,\n                    'streaming_mode': True,\n                    'streaming_config': {\n                        'print_step': {'default': 1},\n                        'print_frequency_ms': {'default': 70},\n                        'print_strategy': 'fast',\n                    },\n                },\n                'body': {\n                    'direction': 'vertical',\n                    'padding': '12px 12px 12px 12px',\n                    'elements': [\n                        {\n                            'tag': 'div',\n                            'text': {\n                                'tag': 'plain_text',\n                                'content': 'LangBot',\n                                'text_size': 'normal',\n                                'text_align': 'left',\n                                'text_color': 'default',\n                            },\n                            'icon': {\n                                'tag': 'custom_icon',\n                                'img_key': 'img_v3_02p3_05c65d5d-9bad-440a-a2fb-c89571bfd5bg',\n                            },\n                        },\n                        {\n                            'tag': 'markdown',\n                            'content': '',\n                            'text_align': 'left',\n                            'text_size': 'normal',\n                            'margin': '0px 0px 0px 0px',\n                            'element_id': 'streaming_txt',\n                        },\n                        {\n                            'tag': 'markdown',\n                            'content': '',\n                            'text_align': 'left',\n                            'text_size': 'normal',\n                            'margin': '0px 0px 0px 0px',\n                        },\n                        {\n                            'tag': 'column_set',\n                            'horizontal_spacing': '8px',\n                            'horizontal_align': 'left',\n                            'columns': [\n                                {\n                                    'tag': 'column',\n                                    'width': 'weighted',\n                                    'elements': [\n                                        {\n                                            'tag': 'markdown',\n                                            'content': '',\n                                            'text_align': 'left',\n                                            'text_size': 'normal',\n                                            'margin': '0px 0px 0px 0px',\n                                        },\n                                        {\n                                            'tag': 'markdown',\n                                            'content': '',\n                                            'text_align': 'left',\n                                            'text_size': 'normal',\n                                            'margin': '0px 0px 0px 0px',\n                                        },\n                                        {\n                                            'tag': 'markdown',\n                                            'content': '',\n                                            'text_align': 'left',\n                                            'text_size': 'normal',\n                                            'margin': '0px 0px 0px 0px',\n                                        },\n                                    ],\n                                    'padding': '0px 0px 0px 0px',\n                                    'direction': 'vertical',\n                                    'horizontal_spacing': '8px',\n                                    'vertical_spacing': '2px',\n                                    'horizontal_align': 'left',\n                                    'vertical_align': 'top',\n                                    'margin': '0px 0px 0px 0px',\n                                    'weight': 1,\n                                }\n                            ],\n                            'margin': '0px 0px 0px 0px',\n                        },\n                        {'tag': 'hr', 'margin': '0px 0px 0px 0px'},\n                        {\n                            'tag': 'column_set',\n                            'horizontal_spacing': '12px',\n                            'horizontal_align': 'right',\n                            'columns': [\n                                {\n                                    'tag': 'column',\n                                    'width': 'weighted',\n                                    'elements': [\n                                        {\n                                            'tag': 'markdown',\n                                            'content': '<font color=\"grey-600\">以上内容由 AI 生成，仅供参考。更多详细、准确信息可点击引用链接查看</font>',\n                                            'text_align': 'left',\n                                            'text_size': 'notation',\n                                            'margin': '4px 0px 0px 0px',\n                                            'icon': {\n                                                'tag': 'standard_icon',\n                                                'token': 'robot_outlined',\n                                                'color': 'grey',\n                                            },\n                                        }\n                                    ],\n                                    'padding': '0px 0px 0px 0px',\n                                    'direction': 'vertical',\n                                    'horizontal_spacing': '8px',\n                                    'vertical_spacing': '8px',\n                                    'horizontal_align': 'left',\n                                    'vertical_align': 'top',\n                                    'margin': '0px 0px 0px 0px',\n                                    'weight': 1,\n                                },\n                                {\n                                    'tag': 'column',\n                                    'width': '20px',\n                                    'elements': [\n                                        {\n                                            'tag': 'button',\n                                            'text': {'tag': 'plain_text', 'content': ''},\n                                            'type': 'text',\n                                            'width': 'fill',\n                                            'size': 'medium',\n                                            'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},\n                                            'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},\n                                            'margin': '0px 0px 0px 0px',\n                                        }\n                                    ],\n                                    'padding': '0px 0px 0px 0px',\n                                    'direction': 'vertical',\n                                    'horizontal_spacing': '8px',\n                                    'vertical_spacing': '8px',\n                                    'horizontal_align': 'left',\n                                    'vertical_align': 'top',\n                                    'margin': '0px 0px 0px 0px',\n                                },\n                                {\n                                    'tag': 'column',\n                                    'width': '30px',\n                                    'elements': [\n                                        {\n                                            'tag': 'button',\n                                            'text': {'tag': 'plain_text', 'content': ''},\n                                            'type': 'text',\n                                            'width': 'default',\n                                            'size': 'medium',\n                                            'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},\n                                            'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},\n                                            'margin': '0px 0px 0px 0px',\n                                        }\n                                    ],\n                                    'padding': '0px 0px 0px 0px',\n                                    'vertical_spacing': '8px',\n                                    'horizontal_align': 'left',\n                                    'vertical_align': 'top',\n                                    'margin': '0px 0px 0px 0px',\n                                },\n                            ],\n                            'margin': '0px 0px 4px 0px',\n                        },\n                    ],\n                },\n            }\n            # delay / fast 创建卡片模板，delay 延迟打印，fast 实时打印，可以自定义更好看的消息模板\n\n            request: CreateCardRequest = (\n                CreateCardRequest.builder()\n                .request_body(CreateCardRequestBody.builder().type('card_json').data(json.dumps(card_data)).build())\n                .build()\n            )\n\n            # 发起请求\n            response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request)\n\n            # 处理失败返回\n            if not response.success():\n                raise Exception(\n                    f'client.cardkit.v1.card.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n                )\n\n            self.card_id_dict[message_id] = response.data.card_id\n\n            card_id = response.data.card_id\n            return card_id\n\n        except Exception as e:\n            raise e\n\n    async def create_message_card(self, message_id, event) -> str:\n        \"\"\"\n        创建卡片消息。\n        使用卡片消息是因为普通消息更新次数有限制，而大模型流式返回结果可能很多而超过限制，而飞书卡片没有这个限制（api免费次数有限）\n        \"\"\"\n        # message_id = event.message_chain.message_id\n\n        card_id = await self.create_card_id(message_id)\n        content = {\n            'type': 'card',\n            'data': {'card_id': card_id, 'template_variable': {'content': 'Thinking...'}},\n        }  # 当收到消息时发送消息模板，可添加模板变量，详情查看飞书中接口文档\n        request: ReplyMessageRequest = (\n            ReplyMessageRequest.builder()\n            .message_id(event.message_chain.message_id)\n            .request_body(\n                ReplyMessageRequestBody.builder().content(json.dumps(content)).msg_type('interactive').build()\n            )\n            .build()\n        )\n        tenant_key = event.source_platform_object.header.tenant_key if event.source_platform_object else None\n        app_access_token = self.get_app_access_token()\n        tenant_access_token = self.get_tenant_access_token(tenant_key)\n        req_opt: RequestOption = (\n            RequestOption.builder()\n            .app_ticket(self.app_ticket)\n            .tenant_key(tenant_key)\n            .app_access_token(app_access_token)\n            .tenant_access_token(tenant_access_token)\n            .build()\n        )\n        # 发起请求\n        response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)\n\n        # 处理失败返回\n        if not response.success():\n            raise Exception(\n                f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n            )\n        return True\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        # 不再需要了，因为message_id已经被包含到message_chain中\n        # lark_event = await self.event_converter.yiri2target(message_source)\n        text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)\n\n        # Send text message if there are text elements\n        if text_elements:\n            # Determine msg_type based on content: use 'post' if at mentions\n            # are present (requires post paragraph structure), otherwise 'text'\n            needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)\n\n            if needs_post:\n                msg_type = 'post'\n                final_content = json.dumps(\n                    {\n                        'zh_Hans': {\n                            'title': '',\n                            'content': text_elements,\n                        },\n                    }\n                )\n            else:\n                msg_type = 'text'\n                parts = []\n                for paragraph in text_elements:\n                    para_text = ''.join(ele.get('text', '') for ele in paragraph)\n                    if para_text:\n                        parts.append(para_text)\n                final_content = json.dumps({'text': '\\n\\n'.join(parts)})\n\n            request: ReplyMessageRequest = (\n                ReplyMessageRequest.builder()\n                .message_id(message_source.message_chain.message_id)\n                .request_body(\n                    ReplyMessageRequestBody.builder()\n                    .content(final_content)\n                    .msg_type(msg_type)\n                    .reply_in_thread(False)\n                    .uuid(str(uuid.uuid4()))\n                    .build()\n                )\n                .build()\n            )\n\n            tenant_key = (\n                message_source.source_platform_object.header.tenant_key\n                if message_source.source_platform_object\n                else None\n            )\n            app_access_token = self.get_app_access_token()\n            tenant_access_token = self.get_tenant_access_token(tenant_key)\n            req_opt: RequestOption = (\n                RequestOption.builder()\n                .app_ticket(self.app_ticket)\n                .tenant_key(tenant_key)\n                .app_access_token(app_access_token)\n                .tenant_access_token(tenant_access_token)\n                .build()\n            )\n            response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)\n\n            if not response.success():\n                raise Exception(\n                    f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n                )\n\n        # Send media messages separately (image, audio, file, etc.)\n        for media in media_items:\n            request: ReplyMessageRequest = (\n                ReplyMessageRequest.builder()\n                .message_id(message_source.message_chain.message_id)\n                .request_body(\n                    ReplyMessageRequestBody.builder()\n                    .content(json.dumps(media['content']))\n                    .msg_type(media['msg_type'])\n                    .reply_in_thread(False)\n                    .uuid(str(uuid.uuid4()))\n                    .build()\n                )\n                .build()\n            )\n\n            tenant_key = (\n                message_source.source_platform_object.header.tenant_key\n                if message_source.source_platform_object\n                else None\n            )\n            app_access_token = self.get_app_access_token()\n            tenant_access_token = self.get_tenant_access_token(tenant_key)\n            req_opt: RequestOption = (\n                RequestOption.builder()\n                .app_ticket(self.app_ticket)\n                .tenant_key(tenant_key)\n                .app_access_token(app_access_token)\n                .tenant_access_token(tenant_access_token)\n                .build()\n            )\n            response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)\n\n            if not response.success():\n                raise Exception(\n                    f'client.im.v1.message.reply ({media[\"msg_type\"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n                )\n\n    async def reply_message_chunk(\n        self,\n        message_source: platform_events.MessageEvent,\n        bot_message,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n        is_final: bool = False,\n    ):\n        \"\"\"\n        回复消息变成更新卡片消息\n        \"\"\"\n        # self.seq += 1\n        message_id = bot_message.resp_message_id\n        msg_seq = bot_message.msg_sequence\n        if msg_seq % 8 == 0 or is_final:\n            text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)\n\n            text_message = ''\n            if text_elements:\n                parts = []\n                for paragraph in text_elements:\n                    para_text = ''.join(ele['text'] for ele in paragraph if ele['tag'] in ('text', 'md'))\n                    if para_text:\n                        parts.append(para_text)\n                text_message = '\\n\\n'.join(parts)\n\n            # content = {\n            #     'type': 'card_json',\n            #     'data': {'card_id': self.card_id_dict[message_id], 'elements': {'content': text_message}},\n            # }\n\n            request: ContentCardElementRequest = (\n                ContentCardElementRequest.builder()\n                .card_id(self.card_id_dict[message_id])\n                .element_id('streaming_txt')\n                .request_body(\n                    ContentCardElementRequestBody.builder()\n                    # .uuid(\"a0d69e20-1dd1-458b-k525-dfeca4015204\")\n                    .content(text_message)\n                    .sequence(msg_seq)\n                    .build()\n                )\n                .build()\n            )\n\n            if is_final and bot_message.tool_calls is None:\n                # self.seq = 1  # 消息回复结束之后重置seq\n                self.card_id_dict.pop(message_id)  # 清理已经使用过的卡片\n\n            tenant_key = (\n                message_source.source_platform_object.header.tenant_key\n                if message_source.source_platform_object\n                else None\n            )\n            app_access_token = self.get_app_access_token()\n            tenant_access_token = self.get_tenant_access_token(tenant_key)\n            req_opt: RequestOption = (\n                RequestOption.builder()\n                .app_ticket(self.app_ticket)\n                .tenant_key(tenant_key)\n                .app_access_token(app_access_token)\n                .tenant_access_token(tenant_access_token)\n                .build()\n            )\n            # 发起请求\n            response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request, req_opt)\n\n            # 处理失败返回\n            if not response.success():\n                raise Exception(\n                    f'client.im.v1.message.patch failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n                )\n                return\n\n            # Send media messages when streaming is done\n            if is_final and media_items:\n                for media in media_items:\n                    media_request: ReplyMessageRequest = (\n                        ReplyMessageRequest.builder()\n                        .message_id(message_source.message_chain.message_id)\n                        .request_body(\n                            ReplyMessageRequestBody.builder()\n                            .content(json.dumps(media['content']))\n                            .msg_type(media['msg_type'])\n                            .reply_in_thread(False)\n                            .uuid(str(uuid.uuid4()))\n                            .build()\n                        )\n                        .build()\n                    )\n                    media_response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(\n                        media_request, req_opt\n                    )\n                    if not media_response.success():\n                        raise Exception(\n                            f'client.im.v1.message.reply ({media[\"msg_type\"]}) failed, code: {media_response.code}, msg: {media_response.msg}, log_id: {media_response.get_log_id()}'\n                        )\n\n    async def is_muted(self, group_id: int) -> bool:\n        return False\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        self.listeners[event_type] = callback\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        self.listeners.pop(event_type)\n\n    def set_bot_uuid(self, bot_uuid: str):\n        \"\"\"设置 bot UUID（用于生成 webhook URL）\"\"\"\n        self.bot_uuid = bot_uuid\n\n    def get_event_type(self, data):\n        schema = '1.0'\n        if 'schema' in data:\n            schema = data['schema']\n        if '2.0' == schema:\n            return data['header']['event_type']\n        elif 'event' in data:\n            return data['event']['type']\n        else:\n            return data['type']\n\n    async def handle_unified_webhook(self, bot_uuid: str, path: str, request):\n        \"\"\"处理统一 webhook 请求。\n        Args:\n            bot_uuid: Bot 的 UUID\n            path: 子路径（如果有的话）\n            request: Quart Request 对象\n        Returns:\n            响应数据\n        \"\"\"\n        try:\n            data = await request.json\n\n            if 'encrypt' in data:\n                data = self.cipher.decrypt_string(data['encrypt'])\n                data = json.loads(data)\n            type = self.get_event_type(data)\n            context = EventContext(data)\n            if 'url_verification' == type:\n                # todo 验证verification token\n                return {'challenge': data.get('challenge')}\n            elif 'app_ticket' == type:\n                self.app_ticket = context.event['app_ticket']\n            elif 'im.message.receive_v1' == type:\n                try:\n                    p2v1 = P2ImMessageReceiveV1()\n                    p2v1.header = context.header\n                    event = P2ImMessageReceiveV1Data()\n                    event.message = EventMessage(context.event['message'])\n                    event.sender = EventSender(context.event['sender'])\n                    p2v1.event = event\n                    p2v1.schema = context.schema\n                    event = await self.event_converter.target2yiri(p2v1, self.api_client)\n                except Exception:\n                    await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')\n\n                if event.__class__ in self.listeners:\n                    await self.listeners[event.__class__](event, self)\n            elif 'im.chat.member.bot.added_v1' == type:\n                try:\n                    bot_added_welcome_msg = self.config.get('bot_added_welcome', '')\n                    if bot_added_welcome_msg:\n                        final_content = {\n                            'zh_Hans': {\n                                'title': '',\n                                'content': [[{'tag': 'md', 'text': bot_added_welcome_msg}]],\n                            },\n                        }\n                        chat_id = context.event['chat_id']\n                        request: CreateMessageRequest = (\n                            CreateMessageRequest.builder()\n                            .receive_id_type('chat_id')\n                            .request_body(\n                                CreateMessageRequestBody.builder()\n                                .receive_id(chat_id)\n                                .content(json.dumps(final_content))\n                                .msg_type('post')\n                                .uuid(str(uuid.uuid4()))\n                                .build()\n                            )\n                            .build()\n                        )\n                        tenant_key = context.header.tenant_key if context.header else None\n                        app_access_token = self.get_app_access_token()\n                        tenant_access_token = self.get_tenant_access_token(tenant_key)\n                        req_opt: RequestOption = (\n                            RequestOption.builder()\n                            .app_ticket(self.app_ticket)\n                            .tenant_key(tenant_key)\n                            .app_access_token(app_access_token)\n                            .tenant_access_token(tenant_access_token)\n                            .build()\n                        )\n                        response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)\n\n                        if not response.success():\n                            raise Exception(\n                                f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \\n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'\n                            )\n                except Exception as e:\n                    print(f'im.chat.member.bot.added_v1: {e}')\n                    await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')\n\n            return {'code': 200, 'message': 'ok'}\n        except Exception as e:\n            print(f'Error in lark callback: {e}')\n            await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')\n            return {'code': 500, 'message': 'error'}\n\n    async def run_async(self):\n        enable_webhook = self.config['enable-webhook']\n\n        if not enable_webhook:\n            try:\n                await self.bot._connect()\n            except lark_oapi.ws.exception.ClientException as e:\n                raise e\n            except Exception as e:\n                await self.bot._disconnect()\n                if self.bot._auto_reconnect:\n                    await self.bot._reconnect()\n                else:\n                    raise e\n        else:\n            # 统一 webhook 模式下，不启动独立的 Quart 应用\n            # 保持运行但不启动独立端口\n\n            async def keep_alive():\n                while True:\n                    await asyncio.sleep(1)\n\n            await keep_alive()\n\n    async def kill(self) -> bool:\n        # 需要断开连接，不然旧的连接会继续运行，导致飞书消息来时会随机选择一个连接\n        # 断开时lark.ws.Client的_receive_message_loop会打印error日志: receive message loop exit。然后进行重连，\n        # 所以要设置_auto_reconnect=False,让其不重连。\n        self.bot._auto_reconnect = False\n        await self.bot._disconnect()\n        return False\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/lark.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: lark\n  label:\n    en_US: Lark\n    zh_Hans: 飞书\n  description:\n    en_US: Lark Adapter\n    zh_Hans: 飞书适配器，请查看文档了解使用方式\n  icon: lark.svg\nspec:\n  config:\n    - name: app_id\n      label:\n        en_US: App ID\n        zh_Hans: 应用ID\n      type: string\n      required: true\n      default: \"\"\n    - name: app_secret\n      label:\n        en_US: App Secret\n        zh_Hans: 应用密钥\n      type: string\n      required: true\n      default: \"\"\n    - name: bot_name\n      label:\n        en_US: Bot Name\n        zh_Hans: 机器人名称\n      description:\n        en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group\n        zh_Hans: 必须与飞书机器人名称一致，否则机器人将无法在群内正常接收消息\n      type: string\n      required: true\n      default: \"\"\n    - name: enable-webhook\n      label:\n        en_US: Enable Webhook Mode\n        zh_Hans: 启用Webhook模式\n      description:\n        en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode\n        zh_Hans: 如果启用，机器人将使用 Webhook 模式接收消息。否则，将使用 WS 长连接模式\n      type: boolean\n      required: true\n      default: false\n    - name: encrypt-key\n      label:\n        en_US: Encrypt Key\n        zh_Hans: 加密密钥\n      description:\n        en_US: Only valid when webhook mode is enabled, please fill in the encrypt key\n        zh_Hans: 仅在启用 Webhook 模式时有效，请填写加密密钥\n      type: string\n      required: true\n      default: \"\"\n    - name: enable-stream-reply\n      label:\n        en_US: Enable Stream Reply Mode\n        zh_Hans: 启用飞书流式回复模式\n      description:\n        en_US: If enabled, the bot will use the stream of lark reply mode\n        zh_Hans: 如果启用，将使用飞书流式方式来回复内容\n      type: boolean\n      required: true\n      default: false\n    - name: app_type\n      label:\n        en_US: App Type\n        zh_Hans: 应用类型\n      description:\n        en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview\n        zh_Hans: 默认为企业自建应用，参考 https://open.feishu.cn/document/platform-overveiw/overview\n      type: select\n      options:\n        - name: self\n          label:\n            en_US: Self-built Application\n            zh_Hans: 自建应用\n        - name: isv\n          label:\n            en_US: Store Application\n            zh_Hans: 商店应用\n      required: false\n      default: self\n    - name: bot_added_welcome\n      label:\n        en_US: Bot Welcome Message\n        zh_Hans: 机器人进群欢迎语\n      description:\n        en_US: Welcome message when the bot is added to a group, supports Markdown format\n        zh_Hans: 机器人进群欢迎语，支持 Markdown 格式\n      type: text\n      required: false\n      default: \"\"\nexecution:\n  python:\n    path: ./lark.py\n    attr: LarkAdapter"
  },
  {
    "path": "src/langbot/pkg/platform/sources/legacy/gewechat.py",
    "content": "import gewechat_client\n\nimport typing\nimport asyncio\nimport traceback\nimport time\nimport re\nimport copy\nimport threading\n\nimport quart\nfrom langbot.pkg.utils import httpclient\n\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nfrom ....core import app\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nfrom ....utils import image\nimport xml.etree.ElementTree as ET\nfrom typing import Optional, Tuple\nfrom functools import partial\nfrom ...logger import EventLogger\n\n\nclass GewechatMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    def __init__(self, config: dict):\n        self.config = config\n\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:\n        content_list = []\n        for component in message_chain:\n            if isinstance(component, platform_message.At):\n                content_list.append({'type': 'at', 'target': component.target})\n            elif isinstance(component, platform_message.Plain):\n                content_list.append({'type': 'text', 'content': component.text})\n            elif isinstance(component, platform_message.Image):\n                if not component.url:\n                    pass\n                content_list.append({'type': 'image', 'image': component.url})\n\n            elif isinstance(component, platform_message.Voice):\n                content_list.append({'type': 'voice', 'url': component.url, 'length': component.length})\n            elif isinstance(component, platform_message.Forward):\n                for node in component.node_list:\n                    content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))\n                content_list.append({'type': 'image', 'image': component.url})\n            elif isinstance(component, platform_message.WeChatMiniPrograms):\n                content_list.append(\n                    {\n                        'type': 'WeChatMiniPrograms',\n                        'mini_app_id': component.mini_app_id,\n                        'display_name': component.display_name,\n                        'page_path': component.page_path,\n                        'cover_img_url': component.image_url,\n                        'title': component.title,\n                        'user_name': component.user_name,\n                    }\n                )\n            elif isinstance(component, platform_message.WeChatForwardMiniPrograms):\n                content_list.append(\n                    {\n                        'type': 'WeChatForwardMiniPrograms',\n                        'xml_data': component.xml_data,\n                        'image_url': component.image_url,\n                    }\n                )\n            elif isinstance(component, platform_message.WeChatEmoji):\n                content_list.append(\n                    {\n                        'type': 'WeChatEmoji',\n                        'emoji_md5': component.emoji_md5,\n                        'emoji_size': component.emoji_size,\n                    }\n                )\n            elif isinstance(component, platform_message.WeChatLink):\n                content_list.append(\n                    {\n                        'type': 'WeChatLink',\n                        'link_title': component.link_title,\n                        'link_desc': component.link_desc,\n                        'link_thumb_url': component.link_thumb_url,\n                        'link_url': component.link_url,\n                    }\n                )\n            elif isinstance(component, platform_message.WeChatForwardLink):\n                content_list.append({'type': 'WeChatForwardLink', 'xml_data': component.xml_data})\n            elif isinstance(component, platform_message.Voice):\n                content_list.append({'type': 'voice', 'url': component.url, 'length': component.length})\n            elif isinstance(component, platform_message.WeChatForwardImage):\n                content_list.append({'type': 'WeChatForwardImage', 'xml_data': component.xml_data})\n            elif isinstance(component, platform_message.WeChatForwardFile):\n                content_list.append({'type': 'WeChatForwardFile', 'xml_data': component.xml_data})\n            elif isinstance(component, platform_message.WeChatAppMsg):\n                content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})\n            # 引用消息转发\n            elif isinstance(component, platform_message.WeChatForwardQuote):\n                content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})\n            elif isinstance(component, platform_message.Forward):\n                for node in component.node_list:\n                    if node.message_chain:\n                        content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))\n\n        return content_list\n\n    async def target2yiri(self, message: dict, bot_account_id: str) -> platform_message.MessageChain:\n        \"\"\"外部消息转平台消息\"\"\"\n        # 数据预处理\n        message_list = []\n        ats_bot = False  # 是否被@\n        content = message['Data']['Content']['string']\n        content_no_preifx = content  # 群消息则去掉前缀\n        is_group_message = self._is_group_message(message)\n        if is_group_message:\n            ats_bot = self._ats_bot(message, bot_account_id)\n            if '@所有人' in content:\n                message_list.append(platform_message.AtAll())\n            elif ats_bot:\n                message_list.append(platform_message.At(target=bot_account_id))\n            content_no_preifx, _ = self._extract_content_and_sender(content)\n\n        msg_type = message['Data']['MsgType']\n\n        # 映射消息类型到处理器方法\n        handler_map = {\n            1: self._handler_text,\n            3: self._handler_image,\n            34: self._handler_voice,\n            49: self._handler_compound,  # 复合类型\n        }\n\n        # 分派处理\n        handler = handler_map.get(msg_type, self._handler_default)\n        handler_result = await handler(\n            message=message,  # 原始的message\n            content_no_preifx=content_no_preifx,  # 处理后的content\n        )\n\n        if handler_result and len(handler_result) > 0:\n            message_list.extend(handler_result)\n\n        return platform_message.MessageChain(message_list)\n\n    async def _handler_text(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:\n        \"\"\"处理文本消息 (msg_type=1)\"\"\"\n        if message and self._is_group_message(message):\n            pattern = r'@\\S{1,20}'\n            content_no_preifx = re.sub(pattern, '', content_no_preifx)\n\n        return platform_message.MessageChain([platform_message.Plain(content_no_preifx)])\n\n    async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:\n        \"\"\"处理图像消息 (msg_type=3)\"\"\"\n        try:\n            image_xml = content_no_preifx\n            if not image_xml:\n                return platform_message.MessageChain([platform_message.Unknown('[图片内容为空]')])\n\n            base64_str, image_format = await image.get_gewechat_image_base64(\n                gewechat_url=self.config['gewechat_url'],\n                gewechat_file_url=self.config['gewechat_file_url'],\n                app_id=self.config['app_id'],\n                xml_content=image_xml,\n                token=self.config['token'],\n                image_type=2,\n            )\n\n            elements = [\n                platform_message.Image(base64=f'data:image/{image_format};base64,{base64_str}'),\n                platform_message.WeChatForwardImage(xml_data=image_xml),  # 微信消息转发\n            ]\n            return platform_message.MessageChain(elements)\n        except Exception as e:\n            print(f'处理图片失败: {str(e)}')\n            return platform_message.MessageChain([platform_message.Unknown('[图片处理失败]')])\n\n    async def _handler_voice(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:\n        \"\"\"处理语音消息 (msg_type=34)\"\"\"\n        message_List = []\n        try:\n            # 从消息中提取语音数据（需根据实际数据结构调整字段名）\n            audio_base64 = message['Data']['ImgBuf']['buffer']\n\n            # 验证语音数据有效性\n            if not audio_base64:\n                message_List.append(platform_message.Unknown(text='[语音内容为空]'))\n                return platform_message.MessageChain(message_List)\n\n            # 转换为平台支持的语音格式（如 Silk 格式）\n            voice_element = platform_message.Voice(base64=f'data:audio/silk;base64,{audio_base64}')\n            message_List.append(voice_element)\n\n        except KeyError as e:\n            print(f'语音数据字段缺失: {str(e)}')\n            message_List.append(platform_message.Unknown(text='[语音数据解析失败]'))\n        except Exception as e:\n            print(f'处理语音消息异常: {str(e)}')\n            message_List.append(platform_message.Unknown(text='[语音处理失败]'))\n\n        return platform_message.MessageChain(message_List)\n\n    async def _handler_compound(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:\n        \"\"\"处理复合消息 (msg_type=49)，根据子类型分派\"\"\"\n        try:\n            xml_data = ET.fromstring(content_no_preifx)\n            appmsg_data = xml_data.find('.//appmsg')\n            if appmsg_data:\n                data_type = appmsg_data.findtext('.//type', '')\n\n                # 二次分派处理器\n                sub_handler_map = {\n                    '57': self._handler_compound_quote,\n                    '5': self._handler_compound_link,\n                    '6': self._handler_compound_file,\n                    '33': self._handler_compound_mini_program,\n                    '36': self._handler_compound_mini_program,\n                    '2000': partial(self._handler_compound_unsupported, text='[转账消息]'),\n                    '2001': partial(self._handler_compound_unsupported, text='[红包消息]'),\n                    '51': partial(self._handler_compound_unsupported, text='[视频号消息]'),\n                }\n\n                handler = sub_handler_map.get(data_type, self._handler_compound_unsupported)\n                return await handler(\n                    message=message,  # 原始msg\n                    xml_data=xml_data,  # xml数据\n                )\n            else:\n                return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])\n        except Exception as e:\n            print(f'解析复合消息失败: {str(e)}')\n            return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])\n\n    async def _handler_compound_quote(\n        self, message: Optional[dict], xml_data: ET.Element\n    ) -> platform_message.MessageChain:\n        \"\"\"处理引用消息 (data_type=57)\"\"\"\n        message_list = []\n        # print(\"_handler_compound_quote\", ET.tostring(xml_data, encoding='unicode'))\n        appmsg_data = xml_data.find('.//appmsg')\n        quote_data = ''  # 引用原文\n        user_data = ''  # 用户消息\n        sender_id = xml_data.findtext('.//fromusername')  # 发送方：单聊用户/群member\n        if appmsg_data:\n            user_data = appmsg_data.findtext('.//title') or ''\n            quote_data = appmsg_data.find('.//refermsg').findtext('.//content')\n            message_list.append(\n                platform_message.WeChatForwardQuote(app_msg=ET.tostring(appmsg_data, encoding='unicode'))\n            )\n        # quote_data原始的消息\n        if quote_data:\n            quote_data_message_list = platform_message.MessageChain()\n            # 文本消息\n            try:\n                if '<msg>' not in quote_data:\n                    quote_data_message_list.append(platform_message.Plain(quote_data))\n                else:\n                    # 引用消息展开\n                    quote_data_xml = ET.fromstring(quote_data)\n                    if quote_data_xml.find('img'):\n                        quote_data_message_list.extend(await self._handler_image(None, quote_data))\n                    elif quote_data_xml.find('voicemsg'):\n                        quote_data_message_list.extend(await self._handler_voice(None, quote_data))\n                    elif quote_data_xml.find('videomsg'):\n                        quote_data_message_list.extend(await self._handler_default(None, quote_data))  # 先不处理\n                    else:\n                        # appmsg\n                        quote_data_message_list.extend(await self._handler_compound(None, quote_data))\n            except Exception as e:\n                print(f'处理引用消息异常 expcetion:{e}')\n                quote_data_message_list.append(platform_message.Plain(quote_data))\n            message_list.append(\n                platform_message.Quote(\n                    sender_id=sender_id,\n                    origin=quote_data_message_list,\n                )\n            )\n            if len(user_data) > 0:\n                pattern = r'@\\S{1,20}'\n                user_data = re.sub(pattern, '', user_data)\n                message_list.append(platform_message.Plain(user_data))\n\n        # for comp in message_list:\n        #     if isinstance(comp, platform_message.Quote):\n        #         print(f\"quote_message_chain len={len(message_list)}\")\n        #         print(f\"quote_message_chain send_id={comp.sender_id}\" )\n        #         for quote_item in comp.origin:\n        #             print(f\"--quote_message_component [msg_type={quote_item.type}][message={quote_item}]\" )\n        #     else:\n        #         print(f\"quote_message_chain plain [msg_type={comp.type}][message={comp.text}]\")\n        return platform_message.MessageChain(message_list)\n\n    async def _handler_compound_file(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:\n        \"\"\"处理文件消息 (data_type=6)\"\"\"\n        xml_data_str = ET.tostring(xml_data, encoding='unicode')\n        return platform_message.MessageChain([platform_message.WeChatForwardFile(xml_data=xml_data_str)])\n\n    async def _handler_compound_link(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:\n        \"\"\"处理链接消息（如公众号文章、外部网页）\"\"\"\n        message_list = []\n        try:\n            # 解析 XML 中的链接参数\n            appmsg = xml_data.find('.//appmsg')\n            if appmsg is None:\n                return platform_message.MessageChain()\n            message_list.append(\n                platform_message.WeChatLink(\n                    link_title=appmsg.findtext('title', ''),\n                    link_desc=appmsg.findtext('des', ''),\n                    link_url=appmsg.findtext('url', ''),\n                    link_thumb_url=appmsg.findtext('thumburl', ''),  # 这个字段拿不到\n                )\n            )\n            # 转发消息\n            xml_data_str = ET.tostring(xml_data, encoding='unicode')\n            # print(xml_data_str)\n            message_list.append(platform_message.WeChatForwardLink(xml_data=xml_data_str))\n        except Exception as e:\n            print(f'解析链接消息失败: {str(e)}')\n        return platform_message.MessageChain(message_list)\n\n    async def _handler_compound_mini_program(\n        self, message: dict, xml_data: ET.Element\n    ) -> platform_message.MessageChain:\n        \"\"\"处理小程序消息（如小程序卡片、服务通知）\"\"\"\n        xml_data_str = ET.tostring(xml_data, encoding='unicode')\n        return platform_message.MessageChain([platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)])\n\n    async def _handler_default(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:\n        \"\"\"处理未知消息类型\"\"\"\n        if message:\n            msg_type = message['Data']['MsgType']\n        else:\n            msg_type = ''\n        return platform_message.MessageChain([platform_message.Unknown(text=f'[未知消息类型 msg_type:{msg_type}]')])\n\n    def _handler_compound_unsupported(\n        self, message: dict, xml_data: str, text: Optional[str] = None\n    ) -> platform_message.MessageChain:\n        \"\"\"处理未支持复合消息类型(msg_type=49)子类型\"\"\"\n        if not text:\n            text = f'[xml_data={xml_data}]'\n        content_list = []\n        content_list.append(platform_message.Unknown(text=f'[处理未支持复合消息类型[msg_type=49]|{text}'))\n\n        return platform_message.MessageChain(content_list)\n\n    # 返回是否被艾特\n    def _ats_bot(self, message: dict, bot_account_id: str) -> bool:\n        ats_bot = False\n        try:\n            to_user_name = message['Wxid']  # 接收方: 所属微信的wxid\n            raw_content = message['Data']['Content']['string']  # 原始消息内容\n            content_no_prefix, _ = self._extract_content_and_sender(raw_content)\n            # 直接艾特机器人（这个有bug，当被引用的消息里面有@bot,会套娃\n            # ats_bot =  ats_bot or (f\"@{bot_account_id}\" in content_no_prefix)\n            # 文本类@bot\n            push_content = message.get('Data', {}).get('PushContent', '')\n            ats_bot = ats_bot or ('在群聊中@了你' in push_content)\n            # 引用别人时@bot\n            msg_source = message.get('Data', {}).get('MsgSource', '') or ''\n            if len(msg_source) > 0:\n                msg_source_data = ET.fromstring(msg_source)\n                at_user_list = msg_source_data.findtext('atuserlist') or ''\n                ats_bot = ats_bot or (to_user_name in at_user_list)\n            # 引用bot\n            if message.get('Data', {}).get('MsgType', 0) == 49:\n                xml_data = ET.fromstring(content_no_prefix)\n                appmsg_data = xml_data.find('.//appmsg')\n                tousername = message['Wxid']\n                if appmsg_data:  # 接收方: 所属微信的wxid\n                    quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr')  # 引用消息的原发送者\n                    ats_bot = ats_bot or (quote_id == tousername)\n        except Exception as e:\n            print(f'Error in gewechat _ats_bot: {e}')\n        finally:\n            return ats_bot\n\n    # 提取一下content前面的sender_id, 和去掉前缀的内容\n    def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]:\n        try:\n            # 检查消息开头，如果有 wxid_sbitaz0mt65n22:\\n 则删掉\n            # add: 有些用户的wxid不是上述格式。换成user_name:\n            regex = re.compile(r'^[a-zA-Z0-9_\\-]{5,20}:')\n            line_split = raw_content.split('\\n')\n            if len(line_split) > 0 and regex.match(line_split[0]):\n                raw_content = '\\n'.join(line_split[1:])\n                sender_id = line_split[0].strip(':')\n                return raw_content, sender_id\n        except Exception as e:\n            print(f'_extract_content_and_sender got except: {e}')\n        finally:\n            return raw_content, None\n\n    # 是否是群消息\n    def _is_group_message(self, message: dict) -> bool:\n        from_user_name = message['Data']['FromUserName']['string']\n        return from_user_name.endswith('@chatroom')\n\n\nclass GewechatEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    def __init__(self, config: dict):\n        self.config = config\n        self.message_converter = GewechatMessageConverter(config)\n\n    @staticmethod\n    async def yiri2target(event: platform_events.MessageEvent) -> dict:\n        pass\n\n    async def target2yiri(self, event: dict, bot_account_id: str) -> platform_events.MessageEvent:\n        # print(event)\n        # 排除自己发消息回调回答问题\n        if event['Wxid'] == event['Data']['FromUserName']['string']:\n            return None\n        # 排除公众号以及微信团队消息\n        if event['Data']['FromUserName']['string'].startswith('gh_') or event['Data']['FromUserName'][\n            'string'\n        ].startswith('weixin'):\n            return None\n        message_chain = await self.message_converter.target2yiri(copy.deepcopy(event), bot_account_id)\n\n        if not message_chain:\n            return None\n\n        if '@chatroom' in event['Data']['FromUserName']['string']:\n            # 找出开头的 wxid_ 字符串，以:结尾\n            sender_wxid = event['Data']['Content']['string'].split(':')[0]\n\n            return platform_events.GroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=sender_wxid,\n                    member_name=event['Data']['FromUserName']['string'],\n                    permission=platform_entities.Permission.Member,\n                    group=platform_entities.Group(\n                        id=event['Data']['FromUserName']['string'],\n                        name=event['Data']['FromUserName']['string'],\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title='',\n                ),\n                message_chain=message_chain,\n                time=event['Data']['CreateTime'],\n                source_platform_object=event,\n            )\n        else:\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event['Data']['FromUserName']['string'],\n                    nickname=event['Data']['FromUserName']['string'],\n                    remark='',\n                ),\n                message_chain=message_chain,\n                time=event['Data']['CreateTime'],\n                source_platform_object=event,\n            )\n\n\nclass GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    name: str = 'gewechat'  # 定义适配器名称\n\n    bot: gewechat_client.GewechatClient\n    quart_app: quart.Quart\n\n    bot_account_id: str\n\n    config: dict\n\n    ap: app.Application\n\n    message_converter: GewechatMessageConverter\n    event_converter: GewechatEventConverter\n\n    listeners: typing.Dict[\n        typing.Type[platform_events.Event],\n        typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],\n    ] = {}\n\n    def __init__(self, config: dict, ap: app.Application, logger: EventLogger):\n        self.config = config\n        self.ap = ap\n        self.logger = logger\n        self.quart_app = quart.Quart(__name__)\n\n        self.message_converter = GewechatMessageConverter(config)\n        self.event_converter = GewechatEventConverter(config)\n\n        @self.quart_app.route('/gewechat/callback', methods=['POST'])\n        async def gewechat_callback():\n            data = await quart.request.json\n            # print(json.dumps(data, indent=4, ensure_ascii=False))\n            await self.logger.debug(f'Gewechat callback event: {data}')\n\n            if 'data' in data:\n                data['Data'] = data['data']\n            if 'type_name' in data:\n                data['TypeName'] = data['type_name']\n            # print(json.dumps(data, indent=4, ensure_ascii=False))\n\n            if 'testMsg' in data:\n                return 'ok'\n            elif 'TypeName' in data and data['TypeName'] == 'AddMsg':\n                try:\n                    event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id)\n                except Exception:\n                    await self.logger.error(f'Error in gewechat callback: {traceback.format_exc()}')\n\n                if event.__class__ in self.listeners:\n                    await self.listeners[event.__class__](event, self)\n\n                return 'ok'\n\n    async def _handle_message(self, message: platform_message.MessageChain, target_id: str):\n        \"\"\"统一消息处理核心逻辑\"\"\"\n        content_list = await self.message_converter.yiri2target(message)\n        at_targets = [item['target'] for item in content_list if item['type'] == 'at']\n\n        # 处理@逻辑\n        at_targets = at_targets or []\n        member_info = []\n        if at_targets:\n            member_info = self.bot.get_chatroom_member_detail(self.config['app_id'], target_id, at_targets[::-1])[\n                'data'\n            ]\n\n        # 处理消息组件\n        for msg in content_list:\n            # 文本消息处理@\n            if msg['type'] == 'text' and at_targets:\n                for member in member_info:\n                    msg['content'] = f'@{member[\"nickName\"]} {msg[\"content\"]}'\n\n            # 统一消息派发\n            handler_map = {\n                'text': lambda msg: self.bot.post_text(\n                    app_id=self.config['app_id'],\n                    to_wxid=target_id,\n                    content=msg['content'],\n                    ats=','.join(at_targets),\n                ),\n                'image': lambda msg: self.bot.post_image(\n                    app_id=self.config['app_id'],\n                    to_wxid=target_id,\n                    img_url=msg['image'],\n                ),\n                'WeChatForwardMiniPrograms': lambda msg: self.bot.forward_mini_app(\n                    app_id=self.config['app_id'],\n                    to_wxid=target_id,\n                    xml=msg['xml_data'],\n                    cover_img_url=msg.get('image_url'),\n                ),\n                'WeChatEmoji': lambda msg: self.bot.post_emoji(\n                    app_id=self.config['app_id'],\n                    to_wxid=target_id,\n                    emoji_md5=msg['emoji_md5'],\n                    emoji_size=msg['emoji_size'],\n                ),\n                'WeChatLink': lambda msg: self.bot.post_link(\n                    app_id=self.config['app_id'],\n                    to_wxid=target_id,\n                    title=msg['link_title'],\n                    desc=msg['link_desc'],\n                    link_url=msg['link_url'],\n                    thumb_url=msg['link_thumb_url'],\n                ),\n                'WeChatMiniPrograms': lambda msg: self.bot.post_mini_app(\n                    app_id=self.config['app_id'],\n                    to_wxid=target_id,\n                    mini_app_id=msg['mini_app_id'],\n                    display_name=msg['display_name'],\n                    page_path=msg['page_path'],\n                    cover_img_url=msg['cover_img_url'],\n                    title=msg['title'],\n                    user_name=msg['user_name'],\n                ),\n                'WeChatForwardLink': lambda msg: self.bot.forward_url(\n                    app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']\n                ),\n                'WeChatForwardImage': lambda msg: self.bot.forward_image(\n                    app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']\n                ),\n                'WeChatForwardFile': lambda msg: self.bot.forward_file(\n                    app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']\n                ),\n                'voice': lambda msg: self.bot.post_voice(\n                    app_id=self.config['app_id'],\n                    to_wxid=target_id,\n                    voice_url=msg['url'],\n                    voice_duration=msg['length'],\n                ),\n                'WeChatAppMsg': lambda msg: self.bot.post_app_msg(\n                    app_id=self.config['app_id'],\n                    to_wxid=target_id,\n                    appmsg=msg['app_msg'],\n                ),\n                'at': lambda msg: None,\n            }\n\n            if handler := handler_map.get(msg['type']):\n                handler(msg)\n            else:\n                await self.logger.warning(f'未处理的消息类型: {msg[\"type\"]}')\n                continue\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        \"\"\"主动发送消息\"\"\"\n        return await self._handle_message(message, target_id)\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        \"\"\"回复消息\"\"\"\n        if message_source.source_platform_object:\n            target_id = message_source.source_platform_object['Data']['FromUserName']['string']\n            return await self._handle_message(message, target_id)\n\n    async def is_muted(self, group_id: int) -> bool:\n        pass\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        self.listeners[event_type] = callback\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        pass\n\n    async def run_async(self):\n        if not self.config['token']:\n            session = httpclient.get_session()\n            async with session.post(\n                f'{self.config[\"gewechat_url\"]}/v2/api/tools/getTokenId',\n                json={'app_id': self.config['app_id']},\n            ) as response:\n                if response.status != 200:\n                    raise Exception(f'获取gewechat token失败: {await response.text()}')\n                self.config['token'] = (await response.json())['data']\n\n        self.bot = gewechat_client.GewechatClient(f'{self.config[\"gewechat_url\"]}/v2/api', self.config['token'])\n\n        def gewechat_login_process():\n            app_id, error_msg = self.bot.login(self.config['app_id'])\n            if error_msg:\n                raise Exception(f'Gewechat 登录失败: {error_msg}')\n\n            self.config['app_id'] = app_id\n\n            print(f'Gewechat 登录成功，app_id: {app_id}')\n\n            # 获取 nickname\n            profile = self.bot.get_profile(self.config['app_id'])\n            self.bot_account_id = profile['data']['nickName']\n\n            time.sleep(2)\n\n            try:\n                # gewechat-server容器重启, token会变，但是还会登录成功\n                # 换新token也会收不到回调，要重新登陆下。\n                self.bot.set_callback(self.config['token'], self.config['callback_url'])\n            except Exception as e:\n                raise Exception(f'设置 Gewechat 回调失败， token失效： {e}')\n\n        threading.Thread(target=gewechat_login_process).start()\n\n        async def shutdown_trigger_placeholder():\n            while True:\n                await asyncio.sleep(1)\n\n        await self.quart_app.run_task(\n            host='0.0.0.0',\n            port=self.config['port'],\n            shutdown_trigger=shutdown_trigger_placeholder,\n        )\n\n    async def kill(self) -> bool:\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/legacy/gewechat.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: gewechat\n  label:\n    en_US: GeWeChat\n    zh_Hans: GeWeChat（个人微信）\n  description:\n    en_US: GeWeChat Adapter\n    zh_Hans: GeWeChat 适配器，请查看文档了解使用方式\n  icon: gewechat.png\nspec:\n  config:\n    - name: gewechat_url\n      label:\n        en_US: GeWeChat URL\n        zh_Hans: GeWeChat URL\n      type: string\n      required: true\n      default: \"\"\n    - name: gewechat_file_url\n      label:\n        en_US: GeWeChat file download URL\n        zh_Hans: GeWeChat 文件下载URL\n      type: string\n      required: true\n      default: \"\"\n    - name: port\n      label:\n        en_US: Port\n        zh_Hans: 端口\n      type: integer\n      required: true\n      default: 2286\n    - name: callback_url\n      label:\n        en_US: Callback URL\n        zh_Hans: 回调URL\n      type: string\n      required: true\n      default: \"\"\n    - name: app_id\n      label:\n        en_US: App ID\n        zh_Hans: 应用ID\n      type: string\n      required: true\n      default: \"\"\n    - name: token\n      label:\n        en_US: Token\n        zh_Hans: 令牌\n      type: string\n      required: true\n      default: \"\"\nexecution:\n  python:\n    path: ./gewechat.py\n    attr: GeWeChatAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/legacy/nakuru.py",
    "content": "# 加了之后会导致：https://github.com/Lxns-Network/nakuru-project/issues/25\n# from __future__ import annotations\n\nimport asyncio\nimport typing\nimport traceback\n\n\nimport nakuru\nimport nakuru.entities.components as nkc\n\nfrom ....pipeline.longtext.strategies import forward\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nfrom ...logger import EventLogger\n\n\nclass NakuruProjectMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    \"\"\"消息转换器\"\"\"\n\n    @staticmethod\n    def yiri2target(message_chain: platform_message.MessageChain) -> list:\n        msg_list = []\n        if type(message_chain) is platform_message.MessageChain:\n            msg_list = message_chain.__root__\n        elif type(message_chain) is list:\n            msg_list = message_chain\n        elif type(message_chain) is str:\n            msg_list = [platform_message.Plain(message_chain)]\n        else:\n            raise Exception('Unknown message type: ' + str(message_chain) + str(type(message_chain)))\n\n        nakuru_msg_list = []\n\n        # 遍历并转换\n        for component in msg_list:\n            if type(component) is platform_message.Plain:\n                nakuru_msg_list.append(nkc.Plain(component.text, False))\n            elif type(component) is platform_message.Image:\n                if component.url is not None:\n                    nakuru_msg_list.append(nkc.Image.fromURL(component.url))\n                elif component.base64 is not None:\n                    nakuru_msg_list.append(nkc.Image.fromBase64(component.base64))\n                elif component.path is not None:\n                    nakuru_msg_list.append(nkc.Image.fromFileSystem(component.path))\n            elif type(component) is platform_message.At:\n                nakuru_msg_list.append(nkc.At(qq=component.target))\n            elif type(component) is platform_message.AtAll:\n                nakuru_msg_list.append(nkc.AtAll())\n            elif type(component) is platform_message.Voice:\n                if component.url is not None:\n                    nakuru_msg_list.append(nkc.Record.fromURL(component.url))\n                elif component.path is not None:\n                    nakuru_msg_list.append(nkc.Record.fromFileSystem(component.path))\n            elif type(component) is forward.Forward:\n                # 转发消息\n                yiri_forward_node_list = component.node_list\n                nakuru_forward_node_list = []\n\n                # 遍历并转换\n                for yiri_forward_node in yiri_forward_node_list:\n                    try:\n                        content_list = NakuruProjectMessageConverter.yiri2target(yiri_forward_node.message_chain)\n                        nakuru_forward_node = nkc.Node(\n                            name=yiri_forward_node.sender_name,\n                            uin=yiri_forward_node.sender_id,\n                            time=int(yiri_forward_node.time.timestamp())\n                            if yiri_forward_node.time is not None\n                            else None,\n                            content=content_list,\n                        )\n                        nakuru_forward_node_list.append(nakuru_forward_node)\n                    except Exception:\n                        import traceback\n\n                        traceback.print_exc()\n\n                nakuru_msg_list.append(nakuru_forward_node_list)\n            else:\n                nakuru_msg_list.append(nkc.Plain(str(component)))\n\n        return nakuru_msg_list\n\n    @staticmethod\n    def target2yiri(message_chain: typing.Any, message_id: int = -1) -> platform_message.MessageChain:\n        \"\"\"将Yiri的消息链转换为YiriMirai的消息链\"\"\"\n        assert type(message_chain) is list\n\n        yiri_msg_list = []\n        import datetime\n\n        # 添加Source组件以标记message_id等信息\n        yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))\n        for component in message_chain:\n            if type(component) is nkc.Plain:\n                yiri_msg_list.append(platform_message.Plain(text=component.text))\n            elif type(component) is nkc.Image:\n                yiri_msg_list.append(platform_message.Image(url=component.url))\n            elif type(component) is nkc.At:\n                yiri_msg_list.append(platform_message.At(target=component.qq))\n            elif type(component) is nkc.AtAll:\n                yiri_msg_list.append(platform_message.AtAll())\n            else:\n                pass\n        # logging.debug(\"转换后的消息链: \" + str(yiri_msg_list))\n        chain = platform_message.MessageChain(yiri_msg_list)\n        return chain\n\n\nclass NakuruProjectEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    \"\"\"事件转换器\"\"\"\n\n    @staticmethod\n    def yiri2target(event: typing.Type[platform_events.Event]):\n        if event is platform_events.GroupMessage:\n            return nakuru.GroupMessage\n        elif event is platform_events.FriendMessage:\n            return nakuru.FriendMessage\n        else:\n            raise Exception('未支持转换的事件类型: ' + str(event))\n\n    @staticmethod\n    def target2yiri(event: typing.Any) -> platform_events.Event:\n        yiri_chain = NakuruProjectMessageConverter.target2yiri(event.message, event.message_id)\n        if type(event) is nakuru.FriendMessage:  # 私聊消息事件\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event.sender.user_id,\n                    nickname=event.sender.nickname,\n                    remark=event.sender.nickname,\n                ),\n                message_chain=yiri_chain,\n                time=event.time,\n            )\n        elif type(event) is nakuru.GroupMessage:  # 群聊消息事件\n            permission = 'MEMBER'\n\n            if event.sender.role == 'admin':\n                permission = 'ADMINISTRATOR'\n            elif event.sender.role == 'owner':\n                permission = 'OWNER'\n\n            return platform_events.GroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=event.sender.user_id,\n                    member_name=event.sender.nickname,\n                    permission=permission,\n                    group=platform_entities.Group(\n                        id=event.group_id,\n                        name=event.sender.nickname,\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title=event.sender.title,\n                ),\n                message_chain=yiri_chain,\n                time=event.time,\n            )\n        else:\n            raise Exception('未支持转换的事件类型: ' + str(event))\n\n\nclass NakuruAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    \"\"\"nakuru-project适配器\"\"\"\n\n    bot: nakuru.CQHTTP\n    bot_account_id: int\n\n    message_converter: NakuruProjectMessageConverter = NakuruProjectMessageConverter()\n    event_converter: NakuruProjectEventConverter = NakuruProjectEventConverter()\n\n    listener_list: list[dict]\n\n    # ap: app.Application\n\n    cfg: dict\n\n    def __init__(self, cfg: dict, ap, logger: EventLogger):\n        \"\"\"初始化nakuru-project的对象\"\"\"\n        cfg['port'] = cfg['ws_port']\n        del cfg['ws_port']\n        self.cfg = cfg\n        self.ap = ap\n        self.logger = logger\n        self.listener_list = []\n        self.bot = nakuru.CQHTTP(**self.cfg)\n\n    async def send_message(\n        self,\n        target_type: str,\n        target_id: str,\n        message: typing.Union[platform_message.MessageChain, list],\n        converted: bool = False,\n    ):\n        task = None\n\n        converted_msg = self.message_converter.yiri2target(message) if not converted else message\n\n        # 检查是否有转发消息\n        has_forward = False\n        for msg in converted_msg:\n            if type(msg) is list:  # 转发消息，仅回复此消息组件\n                has_forward = True\n                converted_msg = msg\n                break\n        if has_forward:\n            if target_type == 'group':\n                task = self.bot.sendGroupForwardMessage(int(target_id), converted_msg)\n            elif target_type == 'person':\n                task = self.bot.sendPrivateForwardMessage(int(target_id), converted_msg)\n            else:\n                raise Exception('Unknown target type: ' + target_type)\n        else:\n            if target_type == 'group':\n                task = self.bot.sendGroupMessage(int(target_id), converted_msg)\n            elif target_type == 'person':\n                task = self.bot.sendFriendMessage(int(target_id), converted_msg)\n            else:\n                raise Exception('Unknown target type: ' + target_type)\n\n        await task\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        message = self.message_converter.yiri2target(message)\n        if quote_origin:\n            # 在前方添加引用组件\n            message.insert(\n                0,\n                nkc.Reply(\n                    id=message_source.message_chain.message_id,\n                ),\n            )\n        if type(message_source) is platform_events.GroupMessage:\n            await self.send_message('group', message_source.sender.group.id, message, converted=True)\n        elif type(message_source) is platform_events.FriendMessage:\n            await self.send_message('person', message_source.sender.id, message, converted=True)\n        else:\n            raise Exception('Unknown message source type: ' + str(type(message_source)))\n\n    def is_muted(self, group_id: int) -> bool:\n        import time\n\n        # 检查是否被禁言\n        group_member_info = asyncio.run(self.bot.getGroupMemberInfo(group_id, self.bot_account_id))\n        return group_member_info.shut_up_timestamp > int(time.time())\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        try:\n            source_cls = NakuruProjectEventConverter.yiri2target(event_type)\n\n            # 包装函数\n            async def listener_wrapper(app: nakuru.CQHTTP, source: source_cls):  # type: ignore\n                await callback(self.event_converter.target2yiri(source), self)\n\n            # 将包装函数和原函数的对应关系存入列表\n            self.listener_list.append(\n                {\n                    'event_type': event_type,\n                    'callable': callback,\n                    'wrapper': listener_wrapper,\n                }\n            )\n\n            # 注册监听器\n            self.bot.receiver(source_cls.__name__)(listener_wrapper)\n        except Exception as e:\n            self.logger.error(f'Error in nakuru register_listener: {traceback.format_exc()}')\n            raise e\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        nakuru_event_name = self.event_converter.yiri2target(event_type).__name__\n\n        new_event_list = []\n\n        # 从本对象的监听器列表中查找并删除\n        target_wrapper = None\n        for listener in self.listener_list:\n            if listener['event_type'] == event_type and listener['callable'] == callback:\n                target_wrapper = listener['wrapper']\n                self.listener_list.remove(listener)\n                break\n\n        if target_wrapper is None:\n            raise Exception('未找到对应的监听器')\n\n        for func in self.bot.event[nakuru_event_name]:\n            if func.callable != target_wrapper:\n                new_event_list.append(func)\n\n        self.bot.event[nakuru_event_name] = new_event_list\n\n    async def run_async(self):\n        try:\n            import requests\n\n            resp = requests.get(\n                url='http://{}:{}/get_login_info'.format(self.cfg['host'], self.cfg['http_port']),\n                headers={'Authorization': 'Bearer ' + self.cfg['token'] if 'token' in self.cfg else ''},\n                timeout=5,\n                proxies=None,\n            )\n            if resp.status_code == 403:\n                raise Exception('go-cqhttp拒绝访问，请检查配置文件中nakuru适配器的配置')\n            self.bot_account_id = int(resp.json()['data']['user_id'])\n        except Exception:\n            raise Exception('获取go-cqhttp账号信息失败, 请检查是否已启动go-cqhttp并配置正确')\n        await self.bot._run()\n        while True:\n            await asyncio.sleep(1)\n\n    async def kill(self) -> bool:\n        return False\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/legacy/nakuru.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: nakuru\n  label:\n    en_US: Nakuru\n    zh_Hans: Nakuru\n  description:\n    en_US: Nakuru Adapter\n    zh_Hans: Nakuru 适配器(go-cqhttp)，请查看文档了解使用方式\n  icon: nakuru.png\nspec:\n  config:\n    - name: host\n      label:\n        en_US: Host\n        zh_Hans: 主机\n      type: string\n      required: true\n      default: \"127.0.0.1\"\n    - name: http_port\n      label:\n        en_US: HTTP Port\n        zh_Hans: HTTP端口\n      type: integer\n      required: true\n      default: 5700\n    - name: ws_port\n      label:\n        en_US: WebSocket Port\n        zh_Hans: WebSocket端口\n      type: integer\n      required: true\n      default: 8080\n    - name: token\n      label:\n        en_US: Token\n        zh_Hans: 令牌\n      type: string\n      required: true\n      default: \"\"\nexecution:\n  python:\n    path: ./nakuru.py\n    attr: NakuruAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/legacy/qqbotpy.py",
    "content": "from __future__ import annotations\n\nimport logging\nimport typing\nimport datetime\nimport re\nimport traceback\n\nimport botpy\nimport botpy.message as botpy_message\nimport botpy.types.message as botpy_message_type\n\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nfrom ....pipeline.longtext.strategies import forward\nfrom ....core import app\nfrom ....config import manager as cfg_mgr\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nfrom ...logger import EventLogger\n\n\nclass OfficialGroupMessage(platform_events.GroupMessage):\n    pass\n\n\nclass OfficialFriendMessage(platform_events.FriendMessage):\n    pass\n\n\nevent_handler_mapping = {\n    platform_events.GroupMessage: [\n        'on_at_message_create',\n        'on_group_at_message_create',\n    ],\n    platform_events.FriendMessage: [\n        'on_direct_message_create',\n        'on_c2c_message_create',\n    ],\n}\n\n\ncached_message_ids = {}\n\"\"\"由于QQ官方的消息id是字符串，而YiriMirai的消息id是整数，所以需要一个索引来进行转换\"\"\"\n\nid_index = 0\n\n\ndef save_msg_id(message_id: str) -> int:\n    \"\"\"保存消息id\"\"\"\n    global id_index, cached_message_ids\n\n    crt_index = id_index\n    id_index += 1\n    cached_message_ids[str(crt_index)] = message_id\n    return crt_index\n\n\ndef char_to_value(char):\n    \"\"\"将单个字符转换为相应的数值。\"\"\"\n    if '0' <= char <= '9':\n        return ord(char) - ord('0')\n    elif 'A' <= char <= 'Z':\n        return ord(char) - ord('A') + 10\n\n    return ord(char) - ord('a') + 36\n\n\ndef digest(s: str) -> int:\n    \"\"\"计算字符串的hash值。\"\"\"\n    # 取末尾的8位\n    sub_s = s[-10:]\n\n    number = 0\n    base = 36\n\n    for i in range(len(sub_s)):\n        number = number * base + char_to_value(sub_s[i])\n\n    return number\n\n\nK = typing.TypeVar('K')\nV = typing.TypeVar('V')\n\n\nclass OpenIDMapping(typing.Generic[K, V]):\n    map: dict[K, V]\n\n    dump_func: typing.Callable\n\n    digest_func: typing.Callable[[K], V]\n\n    def __init__(\n        self,\n        map: dict[K, V],\n        dump_func: typing.Callable,\n        digest_func: typing.Callable[[K], V] = digest,\n    ):\n        self.map = map\n\n        self.dump_func = dump_func\n\n        self.digest_func = digest_func\n\n    def __getitem__(self, key: K) -> V:\n        return self.map[key]\n\n    def __setitem__(self, key: K, value: V):\n        self.map[key] = value\n        self.dump_func()\n\n    def __contains__(self, key: K) -> bool:\n        return key in self.map\n\n    def __delitem__(self, key: K):\n        del self.map[key]\n        self.dump_func()\n\n    def getkey(self, value: V) -> K:\n        return list(self.map.keys())[list(self.map.values()).index(value)]\n\n    def save_openid(self, key: K) -> V:\n        if key in self.map:\n            return self.map[key]\n\n        value = self.digest_func(key)\n\n        self.map[key] = value\n\n        self.dump_func()\n\n        return value\n\n\nclass OfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    \"\"\"QQ 官方消息转换器\"\"\"\n\n    @staticmethod\n    def yiri2target(message_chain: platform_message.MessageChain):\n        \"\"\"将 YiriMirai 的消息链转换为 QQ 官方消息\"\"\"\n\n        msg_list = []\n        if type(message_chain) is platform_message.MessageChain:\n            msg_list = message_chain.__root__\n        elif type(message_chain) is list:\n            msg_list = message_chain\n        elif type(message_chain) is str:\n            msg_list = [platform_message.Plain(text=message_chain)]\n        else:\n            raise Exception('Unknown message type: ' + str(message_chain) + str(type(message_chain)))\n\n        offcial_messages: list[dict] = []\n        \"\"\"\n        {\n            \"type\": \"text\",\n            \"content\": \"Hello World!\"\n        }\n\n        {\n            \"type\": \"image\",\n            \"content\": \"https://example.com/example.jpg\"\n        }\n        \"\"\"\n\n        # 遍历并转换\n        for component in msg_list:\n            if type(component) is platform_message.Plain:\n                offcial_messages.append({'type': 'text', 'content': component.text})\n            elif type(component) is platform_message.Image:\n                if component.url is not None:\n                    offcial_messages.append({'type': 'image', 'content': component.url})\n                elif component.path is not None:\n                    offcial_messages.append({'type': 'file_image', 'content': component.path})\n            elif type(component) is platform_message.At:\n                offcial_messages.append({'type': 'at', 'content': ''})\n            elif type(component) is platform_message.AtAll:\n                print('上层组件要求发送 AtAll 消息，但 QQ 官方 API 不支持此消息类型，忽略此消息。')\n            elif type(component) is platform_message.Voice:\n                print('上层组件要求发送 Voice 消息，但 QQ 官方 API 不支持此消息类型，忽略此消息。')\n            elif type(component) is forward.Forward:\n                # 转发消息\n                yiri_forward_node_list = component.node_list\n\n                # 遍历并转换\n                for yiri_forward_node in yiri_forward_node_list:\n                    try:\n                        message_chain = yiri_forward_node.message_chain\n\n                        # 平铺\n                        offcial_messages.extend(OfficialMessageConverter.yiri2target(message_chain))\n                    except Exception:\n                        import traceback\n\n                        traceback.print_exc()\n\n        return offcial_messages\n\n    @staticmethod\n    def extract_message_chain_from_obj(\n        message: typing.Union[\n            botpy_message.Message,\n            botpy_message.DirectMessage,\n            botpy_message.GroupMessage,\n            botpy_message.C2CMessage,\n        ],\n        message_id: str = None,\n        bot_account_id: int = 0,\n    ) -> platform_message.MessageChain:\n        yiri_msg_list = []\n        # 存id\n\n        yiri_msg_list.append(platform_message.Source(id=save_msg_id(message_id), time=datetime.datetime.now()))\n\n        if type(message) not in [botpy_message.DirectMessage, botpy_message.C2CMessage]:\n            yiri_msg_list.append(platform_message.At(target=bot_account_id))\n\n        if hasattr(message, 'mentions'):\n            for mention in message.mentions:\n                if mention.bot:\n                    continue\n\n                yiri_msg_list.append(platform_message.At(target=mention.id))\n\n        for attachment in message.attachments:\n            if attachment.content_type.startswith('image'):\n                yiri_msg_list.append(platform_message.Image(url=attachment.url))\n            else:\n                logging.warning('不支持的附件类型：' + attachment.content_type + '，忽略此附件。')\n\n        content = re.sub(r'<@!\\d+>', '', str(message.content))\n        if content.strip() != '':\n            yiri_msg_list.append(platform_message.Plain(text=content))\n\n        chain = platform_message.MessageChain(yiri_msg_list)\n\n        return chain\n\n\nclass OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    \"\"\"事件转换器\"\"\"\n\n    def __init__(self):\n        pass\n\n    def yiri2target(self, event: typing.Type[platform_events.Event]):\n        if event == platform_events.GroupMessage:\n            return botpy_message.Message\n        elif event == platform_events.FriendMessage:\n            return botpy_message.DirectMessage\n        else:\n            raise Exception('未支持转换的事件类型(YiriMirai -> Official): ' + str(event))\n\n    def target2yiri(\n        self,\n        event: typing.Union[\n            botpy_message.Message,\n            botpy_message.DirectMessage,\n            botpy_message.GroupMessage,\n            botpy_message.C2CMessage,\n        ],\n    ) -> platform_events.Event:\n        if isinstance(event, botpy_message.Message):  # 频道内，转群聊事件\n            permission = 'MEMBER'\n\n            if '2' in event.member.roles:\n                permission = 'ADMINISTRATOR'\n            elif '4' in event.member.roles:\n                permission = 'OWNER'\n\n            return platform_events.GroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=event.author.id,\n                    member_name=event.author.username,\n                    permission=permission,\n                    group=platform_entities.Group(\n                        id=event.channel_id,\n                        name=event.author.username,\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title='',\n                ),\n                message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),\n                time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),\n            )\n        elif isinstance(event, botpy_message.DirectMessage):  # 频道私聊，转私聊事件\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event.guild_id,\n                    nickname=event.author.username,\n                    remark=event.author.username,\n                ),\n                message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),\n                time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),\n            )\n        elif isinstance(event, botpy_message.GroupMessage):  # 群聊，转群聊事件\n            author_member_id = event.author.member_openid\n\n            return OfficialGroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=author_member_id,\n                    member_name=author_member_id,\n                    permission='MEMBER',\n                    group=platform_entities.Group(\n                        id=event.group_openid,\n                        name=author_member_id,\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title='',\n                ),\n                message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),\n                time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),\n            )\n        elif isinstance(event, botpy_message.C2CMessage):  # 私聊，转私聊事件\n            user_id_alter = event.author.user_openid\n\n            return OfficialFriendMessage(\n                sender=platform_entities.Friend(\n                    id=user_id_alter,\n                    nickname=user_id_alter,\n                    remark=user_id_alter,\n                ),\n                message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),\n                time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),\n            )\n\n\nclass OfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    \"\"\"QQ 官方消息适配器\"\"\"\n\n    bot: botpy.Client = None\n\n    bot_account_id: int = 0\n\n    message_converter: OfficialMessageConverter\n    event_converter: OfficialEventConverter\n\n    cfg: dict = None\n\n    cached_official_messages: dict = {}\n    \"\"\"缓存的 qq-botpy 框架消息对象\n    \n    message_id: botpy_message.Message | botpy_message.DirectMessage\n    \"\"\"\n\n    ap: app.Application\n\n    metadata: cfg_mgr.ConfigManager = None\n\n    group_msg_seq = None\n    c2c_msg_seq = None\n\n    def __init__(self, cfg: dict, ap: app.Application, logger: EventLogger):\n        \"\"\"初始化适配器\"\"\"\n        self.cfg = cfg\n        self.ap = ap\n        self.logger = logger\n\n        self.group_msg_seq = 1\n        self.c2c_msg_seq = 1\n\n        switchs = {}\n\n        for intent in cfg['intents']:\n            switchs[intent] = True\n\n        del cfg['intents']\n\n        intents = botpy.Intents(**switchs)\n\n        self.bot = botpy.Client(intents=intents)\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        message_list = self.message_converter.yiri2target(message)\n\n        for msg in message_list:\n            args = {}\n\n            if msg['type'] == 'text':\n                args['content'] = msg['content']\n            elif msg['type'] == 'image':\n                args['image'] = msg['content']\n            elif msg['type'] == 'file_image':\n                args['file_image'] = msg['content']\n            else:\n                continue\n\n            if target_type == 'group':\n                args['channel_id'] = str(target_id)\n\n                await self.bot.api.post_message(**args)\n            elif target_type == 'person':\n                args['guild_id'] = str(target_id)\n\n                await self.bot.api.post_dms(**args)\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        message_list = self.message_converter.yiri2target(message)\n\n        for msg in message_list:\n            args = {}\n\n            if msg['type'] == 'text':\n                args['content'] = msg['content']\n            elif msg['type'] == 'image':\n                args['image'] = msg['content']\n            elif msg['type'] == 'file_image':\n                args['file_image'] = msg['content']\n            else:\n                continue\n\n            if quote_origin:\n                args['message_reference'] = botpy_message_type.Reference(\n                    message_id=cached_message_ids[str(message_source.message_chain.message_id)]\n                )\n\n            if isinstance(message_source, platform_events.GroupMessage):\n                args['channel_id'] = str(message_source.sender.group.id)\n                args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)]\n                await self.bot.api.post_message(**args)\n            elif isinstance(message_source, platform_events.FriendMessage):\n                args['guild_id'] = str(message_source.sender.id)\n                args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)]\n                await self.bot.api.post_dms(**args)\n            elif isinstance(message_source, OfficialGroupMessage):\n                if 'file_image' in args:  # 暂不支持发送文件图片\n                    continue\n\n                args['group_openid'] = message_source.sender.group.id\n\n                if 'image' in args:\n                    uploadMedia = await self.bot.api.post_group_file(\n                        group_openid=args['group_openid'],\n                        file_type=1,\n                        url=str(args['image']),\n                    )\n\n                    del args['image']\n                    args['media'] = uploadMedia\n                    args['msg_type'] = 7\n\n                args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)]\n                args['msg_seq'] = self.group_msg_seq\n                self.group_msg_seq += 1\n\n                await self.bot.api.post_group_message(**args)\n            elif isinstance(message_source, OfficialFriendMessage):\n                if 'file_image' in args:\n                    continue\n                args['openid'] = message_source.sender.id\n\n                if 'image' in args:\n                    uploadMedia = await self.bot.api.post_c2c_file(\n                        openid=args['openid'], file_type=1, url=str(args['image'])\n                    )\n\n                    del args['image']\n                    args['media'] = uploadMedia\n                    args['msg_type'] = 7\n\n                args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)]\n\n                args['msg_seq'] = self.c2c_msg_seq\n                self.c2c_msg_seq += 1\n\n                await self.bot.api.post_c2c_message(**args)\n\n    async def is_muted(self, group_id: int) -> bool:\n        return False\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        try:\n\n            async def wrapper(\n                message: typing.Union[\n                    botpy_message.Message,\n                    botpy_message.DirectMessage,\n                    botpy_message.GroupMessage,\n                ],\n            ):\n                self.cached_official_messages[str(message.id)] = message\n                await callback(self.event_converter.target2yiri(message), self)\n\n            for event_handler in event_handler_mapping[event_type]:\n                setattr(self.bot, event_handler, wrapper)\n        except Exception as e:\n            self.logger.error(f'Error in qqbotpy callback: {traceback.format_exc()}')\n            raise e\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        delattr(self.bot, event_handler_mapping[event_type])\n\n    async def run_async(self):\n        self.metadata = self.ap.adapter_qq_botpy_meta\n\n        self.message_converter = OfficialMessageConverter()\n        self.event_converter = OfficialEventConverter()\n\n        self.cfg['ret_coro'] = True\n\n        await self.logger.info('运行 QQ 官方适配器')\n        await (await self.bot.start(**self.cfg))\n\n    async def kill(self) -> bool:\n        if not self.bot.is_closed():\n            await self.bot.close()\n            return True\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/legacy/qqbotpy.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: qq-botpy\n  label:\n    en_US: QQBotPy\n    zh_Hans: QQBotPy\n  description:\n    en_US: QQ Official API (WebSocket)\n    zh_Hans: QQ 官方 API (WebSocket)，请查看文档了解使用方式\n  icon: qqbotpy.svg\nspec:\n  config:\n    - name: appid\n      label:\n        en_US: App ID\n        zh_Hans: 应用ID\n      type: string\n      required: true\n      default: \"\"\n    - name: secret\n      label:\n        en_US: Secret\n        zh_Hans: 密钥\n      type: string\n      required: true\n      default: \"\"\n    - name: intents\n      label:\n        en_US: Intents\n        zh_Hans: 权限\n      type: array\n      required: true\n      default: []\n      items:\n        type: string\nexecution:\n  python:\n    path: ./qqbotpy.py\n    attr: OfficialAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/line.py",
    "content": "import typing\nimport quart\n\n\nimport traceback\nimport asyncio\nimport base64\nimport datetime\n\n\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nfrom ..logger import EventLogger\n\n\nfrom linebot.v3 import WebhookHandler\nfrom linebot.v3.exceptions import InvalidSignatureError\nfrom linebot.v3.messaging import Configuration, ApiClient, MessagingApi, ReplyMessageRequest, TextMessage, ImageMessage\nfrom linebot.v3.webhooks import (\n    MessageEvent,\n    TextMessageContent,\n    ImageMessageContent,\n    VideoMessageContent,\n    AudioMessageContent,\n)\n\n# from linebot import WebhookParser\nfrom linebot.v3.webhook import WebhookParser\nfrom linebot.v3.messaging import MessagingApiBlob\n\n\nclass LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain, api_client: ApiClient) -> typing.Tuple[list]:\n        content_list = []\n        for component in message_chain:\n            if isinstance(component, platform_message.At):\n                content_list.append({'type': 'at', 'target': component.target})\n            elif isinstance(component, platform_message.Plain):\n                content_list.append({'type': 'text', 'content': component.text})\n            elif isinstance(component, platform_message.Image):\n                # Only add image if it has a valid URL\n                if component.url:\n                    content_list.append({'type': 'image', 'image': component.url})\n            elif isinstance(component, platform_message.Voice):\n                content_list.append({'type': 'voice', 'url': component.url, 'length': component.length})\n\n        return content_list\n\n    @staticmethod\n    async def target2yiri(message, bot_client) -> platform_message.MessageChain:\n        lb_msg_list = []\n        msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)\n\n        lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))\n\n        if isinstance(message.message, TextMessageContent):\n            lb_msg_list.append(platform_message.Plain(text=message.message.text))\n        elif isinstance(message.message, AudioMessageContent):\n            pass\n        elif isinstance(message.message, VideoMessageContent):\n            pass\n        elif isinstance(message.message, ImageMessageContent):\n            message_content = MessagingApiBlob(bot_client).get_message_content(message.message.id)\n\n            base64_string = base64.b64encode(message_content).decode('utf-8')\n\n            # 如果需要Data URI格式（用于直接嵌入HTML等）\n            # 首先需要知道图片类型，LINE图片通常是JPEG\n            data_uri = f'data:image/jpeg;base64,{base64_string}'\n            lb_msg_list.append(platform_message.Image(base64=data_uri))\n        return platform_message.MessageChain(lb_msg_list)\n\n\nclass LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def yiri2target(\n        event: platform_events.MessageEvent,\n    ) -> MessageEvent:\n        pass\n\n    @staticmethod\n    async def target2yiri(event, bot_client) -> platform_events.Event:\n        message_chain = await LINEMessageConverter.target2yiri(event, bot_client)\n\n        if event.source.type == 'user':\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event.message.id,\n                    nickname=event.source.user_id,\n                    remark='',\n                ),\n                message_chain=message_chain,\n                time=event.timestamp,\n                source_platform_object=event,\n            )\n        else:\n            return platform_events.GroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=event.event.sender.sender_id.open_id,\n                    member_name=event.event.sender.sender_id.union_id,\n                    permission=platform_entities.Permission.Member,\n                    group=platform_entities.Group(\n                        id=event.message.id,\n                        name='',\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title='',\n                ),\n                message_chain=message_chain,\n                time=event.timestamp,\n                source_platform_object=event,\n            )\n\n\nclass LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: MessagingApi\n    api_client: ApiClient\n    parser: WebhookParser\n\n    bot_account_id: str  # 用于在流水线中识别at是否是本bot，直接以bot_name作为标识\n    message_converter: LINEMessageConverter\n    event_converter: LINEEventConverter\n\n    listeners: typing.Dict[\n        typing.Type[platform_events.Event],\n        typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],\n    ]\n\n    config: dict\n    bot_uuid: str = None\n\n    card_id_dict: dict[str, str]  # 消息id到卡片id的映射，便于创建卡片后的发送消息到指定卡片\n\n    seq: int  # 用于在发送卡片消息中识别消息顺序，直接以seq作为标识\n\n    def __init__(self, config: dict, logger: EventLogger):\n        configuration = Configuration(access_token=config['channel_access_token'])\n        line_webhook = WebhookHandler(config['channel_secret'])\n        parser = WebhookParser(config['channel_secret'])\n        api_client = ApiClient(configuration)\n\n        bot_account_id = config.get('bot_account_id', 'langbot')\n\n        super().__init__(\n            config=config,\n            logger=logger,\n            listeners={},\n            card_id_dict={},\n            seq=1,\n            event_converter=LINEEventConverter(),\n            message_converter=LINEMessageConverter(),\n            line_webhook=line_webhook,\n            parser=parser,\n            configuration=configuration,\n            api_client=api_client,\n            bot=MessagingApi(api_client),\n            bot_account_id=bot_account_id,\n        )\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        pass\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        content_list = await self.message_converter.yiri2target(message, self.api_client)\n\n        for content in content_list:\n            if content['type'] == 'text':\n                self.bot.reply_message_with_http_info(\n                    ReplyMessageRequest(\n                        reply_token=message_source.source_platform_object.reply_token,\n                        messages=[TextMessage(text=content['content'])],\n                    )\n                )\n            elif content['type'] == 'image':\n                # LINE ImageMessage requires original_content_url and preview_image_url\n                image_url = content['image']\n                self.bot.reply_message_with_http_info(\n                    ReplyMessageRequest(\n                        reply_token=message_source.source_platform_object.reply_token,\n                        messages=[ImageMessage(original_content_url=image_url, preview_image_url=image_url)],\n                    )\n                )\n\n    async def is_muted(self, group_id: int) -> bool:\n        return False\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        self.listeners[event_type] = callback\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        self.listeners.pop(event_type)\n\n    def set_bot_uuid(self, bot_uuid: str):\n        \"\"\"设置 bot UUID（用于生成 webhook URL）\"\"\"\n        self.bot_uuid = bot_uuid\n\n    async def handle_unified_webhook(self, bot_uuid: str, path: str, request):\n        \"\"\"处理统一 webhook 请求。\n\n        Args:\n            bot_uuid: Bot 的 UUID\n            path: 子路径（如果有的话）\n            request: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        try:\n            signature = request.headers.get('X-Line-Signature')\n            body = await request.get_data(as_text=True)\n\n            # Check if signature header exists\n            if not signature:\n                await self.logger.warning('Missing X-Line-Signature header')\n                return quart.Response('Missing X-Line-Signature header', status=400)\n\n            try:\n                events = self.parser.parse(body, signature)  # 解密解析消息\n            except InvalidSignatureError:\n                await self.logger.info(\n                    f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}'\n                )\n                return quart.Response('Invalid signature', status=400)\n\n            # 处理事件\n            if events and len(events) > 0:\n                lb_event = await self.event_converter.target2yiri(events[0], self.api_client)\n                if lb_event.__class__ in self.listeners:\n                    await self.listeners[lb_event.__class__](lb_event, self)\n\n            return {'code': 200, 'message': 'ok'}\n        except Exception:\n            await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}')\n            print(traceback.format_exc())\n            return {'code': 500, 'message': 'error'}\n\n    async def run_async(self):\n        # 统一 webhook 模式下，不启动独立的 Quart 应用\n        # 保持运行但不启动独立端口\n\n        # 打印 webhook 回调地址\n        async def keep_alive():\n            while True:\n                await asyncio.sleep(1)\n\n        await keep_alive()\n\n    async def kill(self) -> bool:\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/line.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: LINE\n  label:\n    en_US: LINE\n    zh_Hans: LINE\n  description:\n    en_US: LINE Adapter\n    zh_Hans: LINE适配器，请查看文档了解使用方式\n    ja_JP: LINEアダプター、ドキュメントを参照してください\n    zh_Hant: LINE適配器，請查看文檔了解使用方式\n  icon: line.png\nspec:\n  config:\n    - name: channel_access_token\n      label:\n        en_US: Channel access token\n        zh_Hans: 频道访问令牌\n        ja_JP: チャンネルアクセストークン\n        zh_Hant: 頻道訪問令牌\n      type: string\n      required: true\n      default: \"\"\n    - name: channel_secret\n      label:\n        en_US: Channel secret\n        zh_Hans: 消息密钥\n        ja_JP: チャンネルシークレット\n        zh_Hant: 消息密钥\n      description:\n        en_US: Only valid when webhook mode is enabled, please fill in the encrypt key\n        zh_Hans: 请填写加密密钥\n        ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください\n        zh_Hant: 請填寫加密密钥\n      type: string\n      required: true\n      default: \"\"\nexecution:\n  python:\n    path: ./line.py\n    attr: LINEAdapter"
  },
  {
    "path": "src/langbot/pkg/platform/sources/officialaccount.py",
    "content": "from __future__ import annotations\nimport typing\nimport asyncio\nimport traceback\nimport pydantic\nimport datetime\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nfrom langbot.libs.official_account_api.oaevent import OAEvent\nfrom langbot.libs.official_account_api.api import OAClient\nfrom langbot.libs.official_account_api.api import OAClientForLongerResponse\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nfrom ..logger import EventLogger\n\n\nclass OAMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain):\n        for msg in message_chain:\n            if type(msg) is platform_message.Plain:\n                return msg.text\n\n    @staticmethod\n    async def target2yiri(message: str, message_id=-1):\n        yiri_msg_list = []\n        yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))\n\n        yiri_msg_list.append(platform_message.Plain(text=message))\n        chain = platform_message.MessageChain(yiri_msg_list)\n\n        return chain\n\n\nclass OAEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def target2yiri(event: OAEvent):\n        if event.type == 'text':\n            yiri_chain = await OAMessageConverter.target2yiri(event.message, event.message_id)\n\n            friend = platform_entities.Friend(\n                id=event.user_id,\n                nickname=str(event.user_id),\n                remark='',\n            )\n\n            return platform_events.FriendMessage(\n                sender=friend,\n                message_chain=yiri_chain,\n                time=event.timestamp,\n                source_platform_object=event,\n            )\n        else:\n            return None\n\n\nclass OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    message_converter: OAMessageConverter = OAMessageConverter()\n    event_converter: OAEventConverter = OAEventConverter()\n    bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True)\n    bot_uuid: str = None\n\n    def __init__(self, config: dict, logger: EventLogger):\n        # 校验必填项\n        required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']\n        missing_keys = [k for k in required_keys if k not in config]\n        if missing_keys:\n            raise Exception(f'OfficialAccount 缺少配置项: {missing_keys}')\n\n        # 创建运行时 bot 对象，始终使用统一 webhook 模式\n        if config['Mode'] == 'drop':\n            bot = OAClient(\n                token=config['token'],\n                EncodingAESKey=config['EncodingAESKey'],\n                Appsecret=config['AppSecret'],\n                AppID=config['AppID'],\n                logger=logger,\n                unified_mode=True,\n                api_base_url=config.get('api_base_url', 'https://api.weixin.qq.com'),\n            )\n        elif config['Mode'] == 'passive':\n            bot = OAClientForLongerResponse(\n                token=config['token'],\n                EncodingAESKey=config['EncodingAESKey'],\n                Appsecret=config['AppSecret'],\n                AppID=config['AppID'],\n                LoadingMessage=config.get('LoadingMessage', ''),\n                logger=logger,\n                unified_mode=True,\n                api_base_url=config.get('api_base_url', 'https://api.weixin.qq.com'),\n            )\n        else:\n            raise KeyError('请设置微信公众号通信模式')\n\n        bot_account_id = config.get('AppID', '')\n\n        super().__init__(\n            bot=bot,\n            bot_account_id=bot_account_id,\n            config=config,\n            logger=logger,\n        )\n\n    async def reply_message(\n        self,\n        message_source: platform_events.FriendMessage,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        content = await OAMessageConverter.yiri2target(message)\n        if isinstance(self.bot, OAClient):\n            await self.bot.set_message(message_source.message_chain.message_id, content)\n        elif isinstance(self.bot, OAClientForLongerResponse):\n            from_user = message_source.sender.id\n            await self.bot.set_message(from_user, message_source.message_chain.message_id, content)\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        pass\n\n    def register_listener(\n        self,\n        event_type: type,\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        async def on_message(event: OAEvent):\n            self.bot_account_id = event.receiver_id\n            try:\n                return await callback(await self.event_converter.target2yiri(event), self)\n            except Exception:\n                await self.logger.error(f'Error in officialaccount callback: {traceback.format_exc()}')\n\n        if event_type == platform_events.FriendMessage:\n            self.bot.on_message('text')(on_message)\n        elif event_type == platform_events.GroupMessage:\n            pass\n\n    def set_bot_uuid(self, bot_uuid: str):\n        \"\"\"设置 bot UUID（用于生成 webhook URL）\"\"\"\n        self.bot_uuid = bot_uuid\n\n    async def handle_unified_webhook(self, bot_uuid: str, path: str, request):\n        \"\"\"处理统一 webhook 请求。\n\n        Args:\n            bot_uuid: Bot 的 UUID\n            path: 子路径（如果有的话）\n            request: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self.bot.handle_unified_webhook(request)\n\n    async def run_async(self):\n        # 统一 webhook 模式下，不启动独立的 Quart 应用\n        # 保持运行但不启动独立端口\n\n        async def keep_alive():\n            while True:\n                await asyncio.sleep(1)\n\n        await keep_alive()\n\n    async def kill(self) -> bool:\n        return False\n\n    async def unregister_listener(\n        self,\n        event_type: type,\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        return super().unregister_listener(event_type, callback)\n\n    async def is_muted(\n        self,\n        group_id: str,\n    ) -> bool:\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/officialaccount.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: officialaccount\n  label:\n    en_US: Official Account\n    zh_Hans: 微信公众号\n  description:\n    en_US: Official Account Adapter\n    zh_Hans: 微信公众号适配器，请查看文档了解使用方式\n  icon: officialaccount.png\nspec:\n  config:\n    - name: token\n      label:\n        en_US: Token\n        zh_Hans: 令牌\n      type: string\n      required: true\n      default: \"\"\n    - name: EncodingAESKey\n      label:\n        en_US: EncodingAESKey\n        zh_Hans: 消息加解密密钥\n      type: string\n      required: true\n      default: \"\"\n    - name: AppID\n      label:\n        en_US: App ID\n        zh_Hans: 应用ID\n      type: string\n      required: true\n      default: \"\"\n    - name: AppSecret\n      label:\n        en_US: App Secret\n        zh_Hans: 应用密钥\n      type: string\n      required: true\n      default: \"\"\n    - name: Mode\n      label:\n        en_US: Mode\n        zh_Hans: 接入模式\n      type: string\n      required: true\n      default: \"drop\"\n    - name: LoadingMessage\n      label:\n        en_US: Loading Message\n        zh_Hans: 加载消息\n      type: string\n      required: true\n      default: \"AI正在思考中，请发送任意内容获取回复。\"\n    - name: api_base_url\n      label:\n        en_US: API Base URL\n        zh_Hans: API 基础 URL\n      description:\n        en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.\n        zh_Hans: 可选，若您部署在内网环境并通过反向代理访问微信公众号 API，可根据文档修改此项\n      type: string\n      required: false\n      default: \"https://api.weixin.qq.com\"\nexecution:\n  python:\n    path: ./officialaccount.py\n    attr: OfficialAccountAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/qqofficial.py",
    "content": "from __future__ import annotations\nimport typing\nimport asyncio\nimport traceback\n\nimport datetime\n\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nfrom langbot.libs.qq_official_api.api import QQOfficialClient\nfrom langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent\nfrom ...utils import image\nfrom ..logger import EventLogger\n\n\nclass QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain):\n        content_list = []\n        # 只实现了发文字\n        for msg in message_chain:\n            if type(msg) is platform_message.Plain:\n                content_list.append(\n                    {\n                        'type': 'text',\n                        'content': msg.text,\n                    }\n                )\n\n        return content_list\n\n    @staticmethod\n    async def target2yiri(message: str, message_id: str, pic_url: str, content_type):\n        yiri_msg_list = []\n        yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))\n        if pic_url is not None:\n            base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type)\n            yiri_msg_list.append(platform_message.Image(base64=base64_url))\n\n        yiri_msg_list.append(platform_message.Plain(text=message))\n        chain = platform_message.MessageChain(yiri_msg_list)\n        return chain\n\n\nclass QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def yiri2target(event: platform_events.MessageEvent) -> QQOfficialEvent:\n        return event.source_platform_object\n\n    @staticmethod\n    async def target2yiri(event: QQOfficialEvent):\n        \"\"\"\n        QQ官方消息转换为LB对象\n        \"\"\"\n        yiri_chain = await QQOfficialMessageConverter.target2yiri(\n            message=event.content,\n            message_id=event.d_id,\n            pic_url=event.attachments,\n            content_type=event.content_type,\n        )\n\n        if event.t == 'C2C_MESSAGE_CREATE':\n            friend = platform_entities.Friend(\n                id=event.user_openid,\n                nickname=event.t,\n                remark='',\n            )\n            return platform_events.FriendMessage(\n                sender=friend,\n                message_chain=yiri_chain,\n                time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),\n                source_platform_object=event,\n            )\n\n        if event.t == 'DIRECT_MESSAGE_CREATE':\n            friend = platform_entities.Friend(\n                id=event.guild_id,\n                nickname=event.t,\n                remark='',\n            )\n            return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, source_platform_object=event)\n        if event.t == 'GROUP_AT_MESSAGE_CREATE':\n            yiri_chain.insert(0, platform_message.At(target='justbot'))\n\n            sender = platform_entities.GroupMember(\n                id=event.group_openid,\n                member_name=event.t,\n                permission='MEMBER',\n                group=platform_entities.Group(\n                    id=event.group_openid,\n                    name='MEMBER',\n                    permission=platform_entities.Permission.Member,\n                ),\n                special_title='',\n            )\n            time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())\n            return platform_events.GroupMessage(\n                sender=sender,\n                message_chain=yiri_chain,\n                time=time,\n                source_platform_object=event,\n            )\n        if event.t == 'AT_MESSAGE_CREATE':\n            yiri_chain.insert(0, platform_message.At(target='justbot'))\n            sender = platform_entities.GroupMember(\n                id=event.channel_id,\n                member_name=event.t,\n                permission='MEMBER',\n                group=platform_entities.Group(\n                    id=event.channel_id,\n                    name='MEMBER',\n                    permission=platform_entities.Permission.Member,\n                ),\n                special_title='',\n            )\n            time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())\n            return platform_events.GroupMessage(\n                sender=sender,\n                message_chain=yiri_chain,\n                time=time,\n                source_platform_object=event,\n            )\n\n\nclass QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: QQOfficialClient\n    config: dict\n    bot_account_id: str\n    bot_uuid: str = None\n    message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()\n    event_converter: QQOfficialEventConverter = QQOfficialEventConverter()\n\n    def __init__(self, config: dict, logger: EventLogger):\n        bot = QQOfficialClient(\n            app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True\n        )\n\n        super().__init__(\n            config=config,\n            logger=logger,\n            bot=bot,\n            bot_account_id=config['appid'],\n        )\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        qq_official_event = await QQOfficialEventConverter.yiri2target(\n            message_source,\n        )\n\n        content_list = await QQOfficialMessageConverter.yiri2target(message)\n\n        # 私聊消息\n        if qq_official_event.t == 'C2C_MESSAGE_CREATE':\n            for content in content_list:\n                if content['type'] == 'text':\n                    await self.bot.send_private_text_msg(\n                        qq_official_event.user_openid,\n                        content['content'],\n                        qq_official_event.d_id,\n                    )\n\n        # 群聊消息\n        if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':\n            for content in content_list:\n                if content['type'] == 'text':\n                    await self.bot.send_group_text_msg(\n                        qq_official_event.group_openid,\n                        content['content'],\n                        qq_official_event.d_id,\n                    )\n\n        # 频道群聊\n        if qq_official_event.t == 'AT_MESSAGE_CREATE':\n            for content in content_list:\n                if content['type'] == 'text':\n                    await self.bot.send_channle_group_text_msg(\n                        qq_official_event.channel_id,\n                        content['content'],\n                        qq_official_event.d_id,\n                    )\n\n        # 频道私聊\n        if qq_official_event.t == 'DIRECT_MESSAGE_CREATE':\n            for content in content_list:\n                if content['type'] == 'text':\n                    await self.bot.send_channle_private_text_msg(\n                        qq_official_event.guild_id,\n                        content['content'],\n                        qq_official_event.d_id,\n                    )\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        pass\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        async def on_message(event: QQOfficialEvent):\n            self.bot_account_id = 'justbot'\n            try:\n                return await callback(await self.event_converter.target2yiri(event), self)\n            except Exception:\n                await self.logger.error(f'Error in qqofficial callback: {traceback.format_exc()}')\n\n        if event_type == platform_events.FriendMessage:\n            self.bot.on_message('DIRECT_MESSAGE_CREATE')(on_message)\n            self.bot.on_message('C2C_MESSAGE_CREATE')(on_message)\n        elif event_type == platform_events.GroupMessage:\n            self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message)\n            self.bot.on_message('AT_MESSAGE_CREATE')(on_message)\n\n    def set_bot_uuid(self, bot_uuid: str):\n        \"\"\"设置 bot UUID（用于生成 webhook URL）\"\"\"\n        self.bot_uuid = bot_uuid\n\n    async def handle_unified_webhook(self, bot_uuid: str, path: str, request):\n        \"\"\"处理统一 webhook 请求。\n\n        Args:\n            bot_uuid: Bot 的 UUID\n            path: 子路径（如果有的话）\n            request: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self.bot.handle_unified_webhook(request)\n\n    async def run_async(self):\n        # 统一 webhook 模式下，不启动独立的 Quart 应用\n        # 保持运行但不启动独立端口\n\n        async def keep_alive():\n            while True:\n                await asyncio.sleep(1)\n\n        await keep_alive()\n\n    async def kill(self) -> bool:\n        return False\n\n    def unregister_listener(\n        self,\n        event_type: type,\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        return super().unregister_listener(event_type, callback)\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/qqofficial.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: qqofficial\n  label:\n    en_US: QQ Official API\n    zh_Hans: QQ 官方 API\n  description:\n    en_US: QQ Official API (Webhook)\n    zh_Hans: QQ 官方 API (Webhook)，请查看文档了解使用方式\n  icon: qqofficial.svg\nspec:\n  config:\n    - name: appid\n      label:\n        en_US: App ID\n        zh_Hans: 应用ID\n      type: string\n      required: true\n      default: \"\"\n    - name: secret\n      label:\n        en_US: Secret\n        zh_Hans: 密钥\n      type: string\n      required: true\n      default: \"\"\n    - name: token\n      label:\n        en_US: Token\n        zh_Hans: 令牌\n      type: string\n      required: true\n      default: \"\"\nexecution:\n  python:\n    path: ./qqofficial.py\n    attr: QQOfficialAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/satori.py",
    "content": "from __future__ import annotations\r\n\r\nimport typing\r\nimport time\r\nimport datetime\r\nimport json\r\nimport asyncio\r\nimport traceback\r\nimport re\r\nimport base64\r\n\r\nimport aiohttp\r\nimport pydantic\r\nimport websockets\r\n\r\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\r\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\r\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\r\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\r\nimport langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger\r\n\r\n\r\nclass SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\r\n    \"\"\"Convert between LangBot MessageChain and Satori message format\"\"\"\r\n\r\n    @staticmethod\r\n    async def yiri2target(message_chain: platform_message.MessageChain, adapter: 'SatoriAdapter') -> str:\r\n        \"\"\"Convert LangBot MessageChain to Satori message format\"\"\"\r\n        content_parts = []\r\n\r\n        for component in message_chain:\r\n            if isinstance(component, platform_message.Plain):\r\n                text = component.text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')\r\n                content_parts.append(text)\r\n            elif isinstance(component, platform_message.Image):\r\n                # Prefer URL over base64 to avoid buffer overflow issues with large images\r\n                if component.url:\r\n                    content_parts.append(f'<img src=\"{component.url}\"/>')\r\n                elif hasattr(component, 'base64') and component.base64:\r\n                    # Process base64 data\r\n                    base64_data = component.base64\r\n                    # Remove whitespace that might corrupt the data\r\n                    base64_data = base64_data.replace('\\n', '').replace('\\r', '').replace(' ', '')\r\n\r\n                    # Check size - if too large, try to upload\r\n                    MAX_INLINE_SIZE = 32 * 1024  # 32KB limit for inline base64\r\n\r\n                    # Extract raw base64 and mime type\r\n                    raw_b64 = base64_data\r\n                    mime_type = 'image/png'\r\n                    if base64_data.startswith('data:'):\r\n                        try:\r\n                            header, raw_b64 = base64_data.split(',', 1)\r\n                            if ';' in header:\r\n                                mime_type = header.split(':')[1].split(';')[0]\r\n                        except (ValueError, IndexError):\r\n                            pass\r\n\r\n                    if len(raw_b64) > MAX_INLINE_SIZE:\r\n                        # Try to upload large image\r\n                        try:\r\n                            # Fix base64 padding if needed\r\n                            padding = 4 - len(raw_b64) % 4\r\n                            if padding != 4:\r\n                                raw_b64 += '=' * padding\r\n                            image_bytes = base64.b64decode(raw_b64)\r\n                            uploaded_url = await adapter.upload_image(image_bytes, mime_type)\r\n                            if uploaded_url:\r\n                                await adapter.logger.info(f'Satori 图片上传成功: {len(image_bytes)} 字节')\r\n                                content_parts.append(f'<img src=\"{uploaded_url}\"/>')\r\n                            else:\r\n                                # Upload failed, use inline (may fail)\r\n                                await adapter.logger.warning('Satori 图片上传失败，使用内联模式')\r\n                                content_parts.append(f'<img src=\"data:{mime_type};base64,{raw_b64}\"/>')\r\n                        except Exception as e:\r\n                            await adapter.logger.error(f'Satori 图片处理失败: {e}')\r\n                            content_parts.append(f'<img src=\"data:{mime_type};base64,{raw_b64}\"/>')\r\n                    else:\r\n                        # Small image, use inline\r\n                        content_parts.append(f'<img src=\"data:{mime_type};base64,{raw_b64}\"/>')\r\n            elif isinstance(component, platform_message.At):\r\n                if component.target:\r\n                    content_parts.append(f'<at id=\"{component.target}\"/>')\r\n            elif isinstance(component, platform_message.AtAll):\r\n                content_parts.append('<at type=\"all\"/>')\r\n            elif isinstance(component, platform_message.Reply):\r\n                content_parts.append(f'<reply id=\"{component.id}\"/>')\r\n            elif isinstance(component, platform_message.Quote):\r\n                content_parts.append(f'<quote id=\"{component.message_id}\"/>')\r\n            elif isinstance(component, platform_message.Face):\r\n                # Satori中的表情可以使用emoticon元素\r\n                face_id = getattr(component, 'face_id', 'unknown')\r\n                content_parts.append(f'<emoticon id=\"{face_id}\"/>')\r\n            elif isinstance(component, platform_message.Voice):\r\n                if hasattr(component, 'url') and component.url:\r\n                    content_parts.append(f'<audio src=\"{component.url}\"/>')\r\n            elif isinstance(component, platform_message.File):\r\n                if hasattr(component, 'url') and component.url:\r\n                    content_parts.append(f'<file url=\"{component.url}\" name=\"{getattr(component, \"name\", \"\")}\"/>')\r\n\r\n        return ''.join(content_parts)\r\n\r\n    @staticmethod\r\n    async def target2yiri(\r\n        message_data: dict, adapter: 'SatoriAdapter', bot_account_id: str = ''\r\n    ) -> platform_message.MessageChain:\r\n        \"\"\"Convert Satori message to LangBot MessageChain\r\n\r\n        Parses Satori's XML-like message format and converts to LangBot MessageChain.\r\n        Handles text, images, mentions, replies, quotes, emoticons, audio, and files.\r\n        \"\"\"\r\n        content = message_data.get('content', '')\r\n\r\n        components = []\r\n\r\n        if content:\r\n            # HTML实体解码 - 注意顺序：先解码 &amp; 再解码其他实体\r\n            # 这样可以正确处理 &amp;lt; -> &lt; -> <\r\n            content = content.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>')\r\n\r\n            # 定义各种消息组件的正则模式 - 支持更灵活的属性顺序\r\n            # 使用 (?:...) 非捕获组来支持可选属性\r\n            patterns = [\r\n                # 图片 - 支持 src 在任意位置\r\n                (r'<img\\s+[^>]*src=[\"\\']([^\"\\']+)[\"\\'][^>]*/?\\s*>', 'image'),\r\n                # @提及用户 - id 属性\r\n                (r'<at\\s+[^>]*id=[\"\\']([^\"\\']+)[\"\\'][^>]*/?\\s*>', 'mention'),\r\n                # @全体 - type=\"all\"\r\n                (r'<at\\s+[^>]*type=[\"\\']all[\"\\'][^>]*/?\\s*>', 'mention_all'),\r\n                # 回复\r\n                (r'<reply\\s+[^>]*id=[\"\\']([^\"\\']+)[\"\\'][^>]*/?\\s*>', 'reply'),\r\n                # 引用\r\n                (r'<quote\\s+[^>]*id=[\"\\']([^\"\\']+)[\"\\'][^>]*/?\\s*>', 'quote'),\r\n                # 表情\r\n                (r'<emoticon\\s+[^>]*id=[\"\\']([^\"\\']+)[\"\\'][^>]*/?\\s*>', 'emoticon'),\r\n                (r'<face\\s+[^>]*id=[\"\\']([^\"\\']+)[\"\\'][^>]*/?\\s*>', 'face'),\r\n                # 音频\r\n                (r'<audio\\s+[^>]*src=[\"\\']([^\"\\']+)[\"\\'][^>]*/?\\s*>', 'audio'),\r\n                (r'<record\\s+[^>]*(?:src|url)=[\"\\']([^\"\\']+)[\"\\'][^>]*/?\\s*>', 'audio'),\r\n                # 视频\r\n                (r'<video\\s+[^>]*src=[\"\\']([^\"\\']+)[\"\\'][^>]*/?\\s*>', 'video'),\r\n                # 文件 - 支持 url 或 src 属性\r\n                (r'<file\\s+[^>]*(?:url|src)=[\"\\']([^\"\\']+)[\"\\'][^>]*/?\\s*>', 'file'),\r\n            ]\r\n\r\n            # 构建联合正则表达式\r\n            combined_pattern = '|'.join([f'({p[0]})' for p in patterns])\r\n\r\n            # 分割消息内容，按顺序处理各种组件\r\n            pos = 0\r\n            for match in re.finditer(combined_pattern, content, re.IGNORECASE):\r\n                # 添加匹配前的纯文本\r\n                if pos < match.start():\r\n                    text = content[pos : match.start()]\r\n                    # 保留文本（包括空白），但跳过完全空的文本\r\n                    if text:\r\n                        components.append(platform_message.Plain(text=text))\r\n\r\n                # 处理匹配到的组件\r\n                match_text = match.group(0)\r\n                matched = False\r\n                for pattern, msg_type in patterns:\r\n                    sub_match = re.search(pattern, match_text, re.IGNORECASE)\r\n                    if sub_match:\r\n                        matched = True\r\n                        if msg_type == 'image':\r\n                            img_url = sub_match.group(1)\r\n                            components.append(platform_message.Image(url=img_url))\r\n                        elif msg_type == 'mention':\r\n                            target_id = sub_match.group(1)\r\n                            components.append(platform_message.At(target=str(target_id)))\r\n                        elif msg_type == 'mention_all':\r\n                            components.append(platform_message.AtAll())\r\n                        elif msg_type == 'reply':\r\n                            reply_id = sub_match.group(1)\r\n                            components.append(platform_message.Reply(id=str(reply_id)))\r\n                        elif msg_type == 'quote':\r\n                            quote_id = sub_match.group(1)\r\n                            # Quote requires origin field - use empty list as placeholder\r\n                            components.append(platform_message.Quote(message_id=str(quote_id), origin=[]))\r\n                        elif msg_type == 'emoticon' or msg_type == 'face':\r\n                            emoticon_id = sub_match.group(1)\r\n                            components.append(\r\n                                platform_message.Face(\r\n                                    face_id=str(emoticon_id),\r\n                                    face_name=f'emoticon_{emoticon_id}',\r\n                                )\r\n                            )\r\n                        elif msg_type == 'audio':\r\n                            audio_url = sub_match.group(1)\r\n                            components.append(platform_message.Voice(url=audio_url))\r\n                        elif msg_type == 'video':\r\n                            # 视频作为文件处理\r\n                            video_url = sub_match.group(1)\r\n                            components.append(platform_message.File(url=video_url, name='video'))\r\n                        elif msg_type == 'file':\r\n                            file_url = sub_match.group(1)\r\n                            # 尝试从标签中提取文件名\r\n                            name_match = re.search(r'name=[\"\\']([^\"\\']*)[\"\\']', match_text, re.IGNORECASE)\r\n                            file_name = name_match.group(1) if name_match else ''\r\n                            components.append(platform_message.File(url=file_url, name=file_name))\r\n                        break\r\n\r\n                # 如果没有匹配到任何已知模式，将其作为纯文本\r\n                if not matched:\r\n                    components.append(platform_message.Plain(text=match_text))\r\n\r\n                pos = match.end()\r\n\r\n            # 添加剩余的文本\r\n            if pos < len(content):\r\n                remaining_text = content[pos:]\r\n                # 保留文本（包括空白），但跳过完全空的文本\r\n                if remaining_text:\r\n                    components.append(platform_message.Plain(text=remaining_text))\r\n\r\n        # 如果没有解析出任何组件，但内容不为空，则作为纯文本\r\n        if not components and content:\r\n            components.append(platform_message.Plain(text=content))\r\n\r\n        message_chain = platform_message.MessageChain(components)\r\n        await adapter.logger.debug(f'Satori 消息解析完成: 共 {len(components)} 个组件 内容长度={len(content)} 字符')\r\n        return message_chain\r\n\r\n\r\nclass SatoriEventConverter(abstract_platform_adapter.AbstractEventConverter):\r\n    \"\"\"Convert between Satori events and LangBot events\"\"\"\r\n\r\n    @staticmethod\r\n    def _ensure_string(value: typing.Any, default: str = '') -> str:\r\n        \"\"\"Ensure value is string type\"\"\"\r\n        if value is None:\r\n            return default\r\n        if isinstance(value, str):\r\n            return value\r\n        return str(value)\r\n\r\n    @staticmethod\r\n    async def target2yiri(\r\n        event_data: dict, adapter: 'SatoriAdapter', bot_account_id: str = ''\r\n    ) -> typing.Optional[platform_events.MessageEvent]:\r\n        \"\"\"Convert Satori event to LangBot event\r\n\r\n        This method is used for standalone event conversion.\r\n        Note: The adapter's convert_satori_message method is preferred for better handling.\r\n        \"\"\"\r\n        event_type = event_data.get('type', '')\r\n\r\n        if event_type == 'message-created':\r\n            message = event_data.get('message', {})\r\n            user = event_data.get('user', {})\r\n            guild = event_data.get('guild')\r\n            channel = event_data.get('channel', {})\r\n            login = event_data.get('login', {})\r\n\r\n            user_name = SatoriEventConverter._ensure_string(user.get('name') or user.get('nick'), '')\r\n            user_id = SatoriEventConverter._ensure_string(user.get('id'), '')\r\n            message_id = SatoriEventConverter._ensure_string(message.get('id'), '')\r\n            message_content = SatoriEventConverter._ensure_string(message.get('content'), '')\r\n\r\n            # Log received message\r\n            await adapter.logger.info(\r\n                f'Satori EventConverter 消息接收: 用户ID={user_id}, 用户名={user_name}, 内容长度={len(message_content)}'\r\n            )\r\n\r\n            # Convert message content to MessageChain\r\n            message_chain = await SatoriMessageConverter.target2yiri(\r\n                {'content': message_content}, adapter, bot_account_id\r\n            )\r\n\r\n            # Insert Source component at the beginning of the message chain\r\n            message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.datetime.now()))\r\n\r\n            # Build original event object for source_platform_object\r\n            original_event = {\r\n                'type': event_type,\r\n                'message': message,\r\n                'user': user,\r\n                'channel': channel,\r\n                'guild': guild,\r\n                'login': login,\r\n            }\r\n\r\n            # Try to get timestamp from message or use current time\r\n            msg_timestamp = message.get('timestamp') or message.get('created_at')\r\n            if msg_timestamp:\r\n                try:\r\n                    if isinstance(msg_timestamp, (int, float)):\r\n                        event_time = int(msg_timestamp) if msg_timestamp > 1e12 else int(msg_timestamp * 1000)\r\n                        event_time = event_time // 1000 if event_time > 1e12 else event_time\r\n                    else:\r\n                        # Try parsing ISO format\r\n                        event_time = int(\r\n                            datetime.datetime.fromisoformat(str(msg_timestamp).replace('Z', '+00:00')).timestamp()\r\n                        )\r\n                except (ValueError, TypeError):\r\n                    event_time = int(time.time())\r\n            else:\r\n                event_time = int(time.time())\r\n\r\n            # Determine message type based on channel.type or guild presence\r\n            # In Satori protocol:\r\n            # - channel.type = 0: TEXT channel (group/guild message)\r\n            # - channel.type = 1: DIRECT channel (private message)\r\n            channel_type = channel.get('type')\r\n            channel_id = SatoriEventConverter._ensure_string(channel.get('id'), '')\r\n\r\n            # Check if it's a private/direct message\r\n            is_private = channel_type == 1\r\n\r\n            # Check if it's a group message\r\n            is_group = (guild and guild.get('id')) or (channel_type == 0)\r\n\r\n            if is_private:\r\n                # Private/friend message\r\n                sender = platform_entities.Friend(\r\n                    id=user_id,\r\n                    nickname=user_name,\r\n                    remark=user_name,\r\n                )\r\n                friend_message = platform_events.FriendMessage(\r\n                    message_chain=message_chain,\r\n                    sender=sender,\r\n                    time=event_time,\r\n                    source_platform_object=original_event,\r\n                )\r\n                await adapter.logger.info(f'Satori 私聊消息已构建: 用户ID={user_id}, 用户名={user_name}')\r\n                return friend_message\r\n            elif is_group:\r\n                # Group message\r\n                # Use channel.id as group_id (NOT guild.id) to ensure each channel is a unique session\r\n                # This is important for platforms with guild/channel hierarchy (Discord, KOOK, etc.)\r\n                # Using guild.id would incorrectly merge different channels into same session\r\n                group_id = channel_id\r\n\r\n                # Build group name: include guild name if available for context\r\n                guild_name = guild.get('name', '') if guild else ''\r\n                channel_name = channel.get('name', '') if channel else ''\r\n                if guild_name and channel_name:\r\n                    group_name = f'{guild_name}#{channel_name}'\r\n                elif guild_name:\r\n                    group_name = guild_name\r\n                elif channel_name:\r\n                    group_name = channel_name\r\n                else:\r\n                    group_name = 'Unknown Group'\r\n\r\n                group = platform_entities.Group(\r\n                    id=group_id,\r\n                    name=group_name,\r\n                    permission=platform_entities.Permission.Member,\r\n                )\r\n                sender = platform_entities.GroupMember(\r\n                    id=user_id,\r\n                    member_name=user_name,\r\n                    permission=platform_entities.Permission.Member,\r\n                    group=group,\r\n                    special_title='',\r\n                )\r\n                group_message = platform_events.GroupMessage(\r\n                    message_chain=message_chain,\r\n                    sender=sender,\r\n                    time=event_time,\r\n                    source_platform_object=original_event,\r\n                )\r\n                await adapter.logger.info(f'Satori 群消息已构建: 群ID={group_id}, 发送者={user_name}')\r\n                return group_message\r\n            else:\r\n                # Fallback: treat as private message if cannot determine type\r\n                sender = platform_entities.Friend(\r\n                    id=user_id,\r\n                    nickname=user_name,\r\n                    remark=user_name,\r\n                )\r\n                friend_message = platform_events.FriendMessage(\r\n                    message_chain=message_chain,\r\n                    sender=sender,\r\n                    time=event_time,\r\n                    source_platform_object=original_event,\r\n                )\r\n                await adapter.logger.info(f'Satori 私聊消息已构建 (fallback): 用户ID={user_id}, 用户名={user_name}')\r\n                return friend_message\r\n        return None\r\n\r\n\r\nclass SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\r\n    \"\"\"Satori protocol adapter for LangBot - Native implementation\"\"\"\r\n\r\n    ws: typing.Optional[typing.Any] = pydantic.Field(exclude=True, default=None)\r\n    session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)\r\n    running: bool = pydantic.Field(exclude=True, default=False)\r\n    sequence: int = pydantic.Field(exclude=True, default=0)\r\n    logins: typing.List[dict] = pydantic.Field(exclude=True, default_factory=list)\r\n    ready_received: bool = pydantic.Field(exclude=True, default=False)\r\n    heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)\r\n    listeners: typing.Dict[typing.Type, typing.Callable] = pydantic.Field(exclude=True, default_factory=dict)\r\n\r\n    message_converter: SatoriMessageConverter = pydantic.Field(default_factory=SatoriMessageConverter)\r\n    event_converter: SatoriEventConverter = pydantic.Field(default_factory=SatoriEventConverter)\r\n\r\n    platform: str = pydantic.Field(exclude=True, default='llonebot')\r\n    host: str = pydantic.Field(exclude=True, default='127.0.0.1')\r\n    api_base_url: str = pydantic.Field(exclude=True, default='')\r\n    token: str = pydantic.Field(exclude=True, default='')\r\n    endpoint: str = pydantic.Field(exclude=True, default='')\r\n    port: int = pydantic.Field(exclude=True, default=5600)\r\n    auto_reconnect: bool = pydantic.Field(exclude=True, default=True)\r\n    heartbeat_interval: int = pydantic.Field(exclude=True, default=10)\r\n    reconnect_delay: int = pydantic.Field(exclude=True, default=5)\r\n\r\n    def __init__(\r\n        self,\r\n        config: dict,\r\n        logger: abstract_platform_logger.AbstractEventLogger,\r\n    ):\r\n        \"\"\"Initialize Satori adapter\"\"\"\r\n        host = config.get('host', '127.0.0.1')\r\n        port = config.get('port', 5600)\r\n\r\n        # 初始化基类\r\n        super().__init__(\r\n            config=config,\r\n            logger=logger,\r\n            platform=config.get('platform', 'llonebot'),\r\n            host=host,\r\n            api_base_url=config.get('satori_api_base_url', f'http://{host}:{port}/v1'),\r\n            token=config.get('token', ''),\r\n            endpoint=config.get('satori_endpoint', f'ws://{host}:{port}/v1/events'),\r\n            auto_reconnect=True,\r\n            port=port,\r\n            heartbeat_interval=10,\r\n            reconnect_delay=5,\r\n        )\r\n\r\n    def _is_websocket_closed(self, ws) -> bool:\r\n        \"\"\"Check if WebSocket connection is closed\"\"\"\r\n        if not ws:\r\n            return True\r\n        try:\r\n            if hasattr(ws, 'closed'):\r\n                return ws.closed\r\n            if hasattr(ws, 'close_code'):\r\n                return ws.close_code is not None\r\n            return False\r\n        except AttributeError:\r\n            return True\r\n\r\n    async def run(self):\r\n        \"\"\"Start the adapter\"\"\"\r\n        self.running = True\r\n        self.session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30))\r\n\r\n        retry_count = 0\r\n        max_retries = 10\r\n\r\n        await self.logger.info(f'Satori 适配器启动中 - 连接到 {self.endpoint}')\r\n\r\n        while self.running:\r\n            try:\r\n                await self.connect_websocket()\r\n                retry_count = 0\r\n            except websockets.exceptions.ConnectionClosed as e:\r\n                await self.logger.warning(f'Satori WebSocket 连接关闭: {e}')\r\n                retry_count += 1\r\n            except Exception as e:\r\n                await self.logger.error(f'Satori WebSocket 连接失败: {e}')\r\n                retry_count += 1\r\n\r\n            if not self.running:\r\n                break\r\n\r\n            if retry_count >= max_retries:\r\n                await self.logger.error(f'达到最大重试次数 ({max_retries})，停止重试')\r\n                break\r\n\r\n            if not self.auto_reconnect:\r\n                break\r\n\r\n            delay = min(self.reconnect_delay * (2 ** (retry_count - 1)), 60)\r\n            await self.logger.info(f'{delay}秒后重新连接...')\r\n            await asyncio.sleep(delay)\r\n\r\n        if self.session:\r\n            await self.session.close()\r\n\r\n    async def connect_websocket(self):\r\n        \"\"\"Connect to WebSocket\"\"\"\r\n        await self.logger.info(f'Satori 正在连接到 WebSocket: {self.endpoint}')\r\n        await self.logger.info(f'Satori HTTP API 地址: {self.api_base_url}')\r\n\r\n        if not self.endpoint.startswith(('ws://', 'wss://')):\r\n            raise ValueError(f'WebSocket URL必须以ws://或wss://开头: {self.endpoint}')\r\n\r\n        try:\r\n            self.ws = await websockets.connect(self.endpoint)\r\n            await asyncio.sleep(0.1)\r\n\r\n            await self.send_identify()\r\n\r\n            # Cancel any existing heartbeat task before creating a new one\r\n            # to avoid race condition on rapid reconnection\r\n            if self.heartbeat_task and not self.heartbeat_task.done():\r\n                self.heartbeat_task.cancel()\r\n                try:\r\n                    await self.heartbeat_task\r\n                except asyncio.CancelledError:\r\n                    pass\r\n            self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())\r\n\r\n            async for message in self.ws:\r\n                try:\r\n                    await self.handle_message(message)\r\n                except Exception as e:\r\n                    await self.logger.error(f'Satori 处理消息异常: {e}')\r\n\r\n        except websockets.exceptions.ConnectionClosed as e:\r\n            await self.logger.warning(f'Satori WebSocket 连接关闭: {e}')\r\n            raise\r\n        except Exception as e:\r\n            await self.logger.error(f'Satori WebSocket 连接异常: {e}')\r\n            raise\r\n        finally:\r\n            if self.heartbeat_task:\r\n                self.heartbeat_task.cancel()\r\n                try:\r\n                    await self.heartbeat_task\r\n                except asyncio.CancelledError:\r\n                    pass\r\n            if self.ws:\r\n                try:\r\n                    await self.ws.close()\r\n                except Exception as e:\r\n                    await self.logger.error(f'Satori WebSocket 关闭异常: {e}')\r\n\r\n    async def send_identify(self):\r\n        \"\"\"Send IDENTIFY signal\"\"\"\r\n        if not self.ws:\r\n            raise Exception('WebSocket连接未建立')\r\n\r\n        if self._is_websocket_closed(self.ws):\r\n            raise Exception('WebSocket连接已关闭')\r\n\r\n        identify_payload = {\r\n            'op': 3,  # IDENTIFY\r\n            'body': {\r\n                'token': str(self.token) if self.token else '',\r\n            },\r\n        }\r\n\r\n        if self.sequence > 0:\r\n            identify_payload['body']['sn'] = self.sequence\r\n\r\n        try:\r\n            message_str = json.dumps(identify_payload, ensure_ascii=False)\r\n            await self.ws.send(message_str)\r\n            await self.logger.info('Satori IDENTIFY 信令已发送')\r\n        except Exception as e:\r\n            await self.logger.error(f'发送 IDENTIFY 信令失败: {e}')\r\n            raise\r\n\r\n    async def heartbeat_loop(self):\r\n        \"\"\"Heartbeat loop\"\"\"\r\n        try:\r\n            while self.running and self.ws:\r\n                await asyncio.sleep(self.heartbeat_interval)\r\n\r\n                if self.ws and not self._is_websocket_closed(self.ws):\r\n                    try:\r\n                        ping_payload = {\r\n                            'op': 1,  # PING\r\n                            'body': {},\r\n                        }\r\n                        await self.ws.send(json.dumps(ping_payload, ensure_ascii=False))\r\n                    except Exception as e:\r\n                        await self.logger.error(f'Satori WebSocket 发送心跳失败: {e}')\r\n                        break\r\n                else:\r\n                    break\r\n        except asyncio.CancelledError:\r\n            pass\r\n        except Exception as e:\r\n            await self.logger.error(f'心跳任务异常: {e}')\r\n\r\n    async def handle_message(self, message: str):\r\n        \"\"\"Handle WebSocket message\"\"\"\r\n        try:\r\n            data = json.loads(message)\r\n            op = data.get('op')\r\n            body = data.get('body', {})\r\n\r\n            if op == 4:  # READY\r\n                self.logins = body.get('logins', [])\r\n                self.ready_received = True\r\n\r\n                if self.logins:\r\n                    for i, login in enumerate(self.logins):\r\n                        platform = login.get('platform', '')\r\n                        user = login.get('user', {})\r\n                        user_id = user.get('id', '')\r\n                        user_name = user.get('name', '')\r\n                        await self.logger.info(\r\n                            f'Satori 连接成功 - Bot {i + 1}: platform={platform}, user_id={user_id}, user_name={user_name}'\r\n                        )\r\n\r\n                if 'sn' in body:\r\n                    self.sequence = body['sn']\r\n\r\n            elif op == 2:  # PONG\r\n                pass\r\n\r\n            elif op == 0:  # EVENT\r\n                await self.handle_event(body)\r\n                if 'sn' in body:\r\n                    self.sequence = body['sn']\r\n\r\n            elif op == 5:  # META\r\n                if 'sn' in body:\r\n                    self.sequence = body['sn']\r\n\r\n        except json.JSONDecodeError as e:\r\n            await self.logger.error(f'解析 WebSocket 消息失败: {e}, 消息内容: {message}')\r\n        except Exception as e:\r\n            await self.logger.error(f'处理 WebSocket 消息异常: {e}')\r\n\r\n    async def handle_event(self, event_data: dict):\r\n        \"\"\"Handle event\"\"\"\r\n        try:\r\n            event_type = event_data.get('type')\r\n\r\n            if event_type == 'message-created':\r\n                message = event_data.get('message', {})\r\n                user = event_data.get('user', {})\r\n                channel = event_data.get('channel', {})\r\n                guild = event_data.get('guild')\r\n                login = event_data.get('login', {})\r\n\r\n                # Skip messages from self\r\n                bot_user_id = login.get('user', {}).get('id')\r\n                msg_user_id = user.get('id')\r\n                if bot_user_id and msg_user_id and str(bot_user_id) == str(msg_user_id):\r\n                    return\r\n\r\n                lb_event = await self.convert_satori_message(message, user, channel, guild, login)\r\n                if lb_event and type(lb_event) in self.listeners:\r\n                    await self.listeners[type(lb_event)](lb_event, self)\r\n\r\n        except Exception as e:\r\n            await self.logger.error(f'处理事件失败: {e}\\n{traceback.format_exc()}')\r\n\r\n    async def convert_satori_message(\r\n        self,\r\n        message: dict,\r\n        user: dict,\r\n        channel: dict,\r\n        guild: typing.Optional[dict],\r\n        login: dict,\r\n    ) -> typing.Optional[platform_events.MessageEvent]:\r\n        \"\"\"Convert Satori message to LangBot event\r\n\r\n        This is the main method for converting Satori messages to LangBot events.\r\n        It handles both private and group messages based on channel.type and guild info.\r\n        \"\"\"\r\n        try:\r\n            # Extract basic info with type safety\r\n            user_id = str(user.get('id', '') or '')\r\n            user_name = str(user.get('name', '') or user.get('nick', '') or '')\r\n            message_id = str(message.get('id', '') or '')\r\n            message_content = str(message.get('content', '') or '')\r\n\r\n            # Log received message (truncate long content for debug preview)\r\n            log_content = message_content[:100] + '...' if len(message_content) > 100 else message_content\r\n            # At info level, avoid logging raw content to protect privacy and reduce log volume\r\n            await self.logger.info(\r\n                f'Satori 消息接收: 用户ID={user_id}, 用户名={user_name}, 内容长度={len(message_content)}, 消息ID={message_id}'\r\n            )\r\n            # Detailed content preview only at debug level\r\n            await self.logger.debug(f\"Satori 消息内容预览: 用户ID={user_id}, 消息ID={message_id}, 预览='{log_content}'\")\r\n\r\n            # Convert message content\r\n            message_chain = await SatoriMessageConverter.target2yiri({'content': message_content}, self, '')\r\n\r\n            # Insert Source component at the beginning of the message chain\r\n            message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.datetime.now()))\r\n\r\n            # Build original event object for source_platform_object\r\n            original_event = {\r\n                'type': 'message-created',\r\n                'message': message,\r\n                'user': user,\r\n                'channel': channel,\r\n                'guild': guild,\r\n                'login': login,\r\n            }\r\n\r\n            # Try to get timestamp from message or use current time\r\n            msg_timestamp = message.get('timestamp') or message.get('created_at')\r\n            if msg_timestamp:\r\n                try:\r\n                    if isinstance(msg_timestamp, (int, float)):\r\n                        # Handle milliseconds vs seconds\r\n                        event_time = int(msg_timestamp) if msg_timestamp < 1e12 else int(msg_timestamp / 1000)\r\n                    else:\r\n                        # Try parsing ISO format\r\n                        event_time = int(\r\n                            datetime.datetime.fromisoformat(str(msg_timestamp).replace('Z', '+00:00')).timestamp()\r\n                        )\r\n                except (ValueError, TypeError):\r\n                    event_time = int(time.time())\r\n            else:\r\n                event_time = int(time.time())\r\n\r\n            # Determine message type based on channel.type or guild presence\r\n            # In Satori protocol:\r\n            # - channel.type = 0: TEXT channel (group/guild message)\r\n            # - channel.type = 1: DIRECT channel (private message)\r\n            # Some implementations (like LLOneBot) may not provide guild info for group chats\r\n            channel_type = channel.get('type')\r\n            channel_id = str(channel.get('id', '') or '')\r\n\r\n            # Check if it's a private/direct message\r\n            # Private message: channel.type == 1, or no guild and no channel type (legacy)\r\n            is_private = channel_type == 1\r\n\r\n            # Check if it's a group message\r\n            # Group message: has guild info, or channel.type == 0\r\n            is_group = (guild and guild.get('id')) or (channel_type == 0)\r\n\r\n            if is_private:\r\n                # Private/friend message\r\n                sender = platform_entities.Friend(\r\n                    id=user_id,\r\n                    nickname=user_name,\r\n                    remark=user_name,\r\n                )\r\n                friend_message = platform_events.FriendMessage(\r\n                    message_chain=message_chain,\r\n                    sender=sender,\r\n                    time=event_time,\r\n                    source_platform_object=original_event,\r\n                )\r\n                await self.logger.debug(\r\n                    f'Satori 私聊消息已构建: 用户ID={user_id}, 用户名={user_name}, 组件数={len(message_chain)}'\r\n                )\r\n                return friend_message\r\n            elif is_group:\r\n                # Group message\r\n                # Use channel.id as group_id (NOT guild.id) to ensure each channel is a unique session\r\n                # This is important for platforms with guild/channel hierarchy (Discord, KOOK, etc.)\r\n                # Using guild.id would incorrectly merge different channels into same session\r\n                group_id = channel_id\r\n\r\n                # Build group name: include guild name if available for context\r\n                guild_name = str(guild.get('name', '') if guild else '')\r\n                channel_name = str(channel.get('name', '') if channel else '')\r\n                if guild_name and channel_name:\r\n                    group_name = f'{guild_name}#{channel_name}'\r\n                elif guild_name:\r\n                    group_name = guild_name\r\n                elif channel_name:\r\n                    group_name = channel_name\r\n                else:\r\n                    group_name = 'Unknown Group'\r\n\r\n                group = platform_entities.Group(\r\n                    id=group_id,\r\n                    name=group_name,\r\n                    permission=platform_entities.Permission.Member,\r\n                )\r\n                sender = platform_entities.GroupMember(\r\n                    id=user_id,\r\n                    member_name=user_name,\r\n                    permission=platform_entities.Permission.Member,\r\n                    group=group,\r\n                    special_title='',\r\n                )\r\n                group_message = platform_events.GroupMessage(\r\n                    message_chain=message_chain,\r\n                    sender=sender,\r\n                    time=event_time,\r\n                    source_platform_object=original_event,\r\n                )\r\n                await self.logger.debug(\r\n                    f'Satori 群消息已构建: 群ID={group_id}, 发送者={user_name}, 组件数={len(message_chain)}'\r\n                )\r\n                return group_message\r\n            else:\r\n                # Fallback: treat as private message if cannot determine type\r\n                await self.logger.warning(f'Satori 无法确定消息类型，使用私聊作为fallback: channel_type={channel_type}')\r\n                sender = platform_entities.Friend(\r\n                    id=user_id,\r\n                    nickname=user_name,\r\n                    remark=user_name,\r\n                )\r\n                friend_message = platform_events.FriendMessage(\r\n                    message_chain=message_chain,\r\n                    sender=sender,\r\n                    time=event_time,\r\n                    source_platform_object=original_event,\r\n                )\r\n                await self.logger.info(f'Satori 私聊消息已构建 (fallback): 用户ID={user_id}, 用户名={user_name}')\r\n                return friend_message\r\n\r\n        except Exception as e:\r\n            await self.logger.error(f'转换 Satori 消息失败: {e}\\n{traceback.format_exc()}')\r\n            return None\r\n\r\n    async def send_http_request(\r\n        self,\r\n        method: str,\r\n        path: str,\r\n        data: typing.Optional[dict] = None,\r\n        platform: typing.Optional[str] = None,\r\n        user_id: typing.Optional[str] = None,\r\n    ) -> typing.Optional[dict]:\r\n        \"\"\"Send HTTP request to Satori API\"\"\"\r\n        if not self.session:\r\n            await self.logger.error('HTTP session 未初始化')\r\n            return None\r\n\r\n        url = f'{self.api_base_url}{path}'\r\n        headers = {\r\n            'Content-Type': 'application/json',\r\n            'Authorization': f'Bearer {self.token}',\r\n        }\r\n\r\n        if platform:\r\n            headers['Satori-Platform'] = platform\r\n        if user_id:\r\n            headers['Satori-User-ID'] = user_id\r\n\r\n        try:\r\n            async with self.session.request(method, url, headers=headers, json=data) as response:\r\n                if response.status == 200:\r\n                    return await response.json()\r\n                else:\r\n                    text = await response.text()\r\n                    await self.logger.error(f'Satori API 请求失败: {response.status} - {text}')\r\n                    return None\r\n        except Exception as e:\r\n            await self.logger.error(f'Satori API 请求异常: {e}')\r\n            return None\r\n\r\n    async def upload_image(\r\n        self,\r\n        image_bytes: bytes,\r\n        mime_type: str = 'image/png',\r\n    ) -> typing.Optional[str]:\r\n        \"\"\"Upload image to Satori server and return the URL\r\n\r\n        Uses multipart/form-data to upload the image file via upload.create API.\r\n        Returns the URL of the uploaded image, or None if upload fails.\r\n        \"\"\"\r\n        if not self.session:\r\n            await self.logger.error('HTTP session 未初始化')\r\n            return None\r\n\r\n        url = f'{self.api_base_url}/upload.create'\r\n        headers = {}\r\n\r\n        if self.token:\r\n            headers['Authorization'] = f'Bearer {self.token}'\r\n\r\n        platform = ''\r\n        user_id = ''\r\n        if self.logins:\r\n            current_login = self.logins[0]\r\n            platform = current_login.get('platform', '')\r\n            user = current_login.get('user', {})\r\n            user_id = user.get('id', '')\r\n\r\n        if platform:\r\n            headers['Satori-Platform'] = platform\r\n        if user_id:\r\n            headers['Satori-User-ID'] = user_id\r\n\r\n        try:\r\n            # Determine file extension from mime type\r\n            ext = 'png'\r\n            if 'jpeg' in mime_type or 'jpg' in mime_type:\r\n                ext = 'jpg'\r\n            elif 'gif' in mime_type:\r\n                ext = 'gif'\r\n            elif 'webp' in mime_type:\r\n                ext = 'webp'\r\n\r\n            # Create multipart form data\r\n            form_data = aiohttp.FormData()\r\n            form_data.add_field('file', image_bytes, filename=f'image.{ext}', content_type=mime_type)\r\n\r\n            async with self.session.post(url, headers=headers, data=form_data) as response:\r\n                if response.status == 200:\r\n                    result = await response.json()\r\n                    # The response should contain the URL of the uploaded file\r\n                    if isinstance(result, dict) and 'url' in result:\r\n                        return result['url']\r\n                    elif isinstance(result, list) and len(result) > 0 and 'url' in result[0]:\r\n                        return result[0]['url']\r\n                    else:\r\n                        await self.logger.warning(f'Satori 图片上传响应格式未知: {result}')\r\n                        return None\r\n                else:\r\n                    text = await response.text()\r\n                    await self.logger.error(f'Satori 图片上传失败: {response.status} - {text}')\r\n                    return None\r\n        except Exception as e:\r\n            await self.logger.error(f'Satori 图片上传异常: {e}')\r\n            return None\r\n\r\n    async def kill(self) -> bool:\r\n        \"\"\"Stop the adapter\"\"\"\r\n        self.running = False\r\n        if self.heartbeat_task:\r\n            self.heartbeat_task.cancel()\r\n        if self.ws:\r\n            try:\r\n                await self.ws.close()\r\n            except Exception:\r\n                pass\r\n        if self.session:\r\n            await self.session.close()\r\n        await self.logger.info('Satori 适配器已停止')\r\n        return True\r\n\r\n    async def send_message(\r\n        self,\r\n        target_type: str,\r\n        target_id: str,\r\n        message: platform_message.MessageChain,\r\n    ):\r\n        \"\"\"Send message\r\n\r\n        Args:\r\n            target_type: Message target type ('group' for channels, 'person' for DM)\r\n            target_id: For 'group': channel_id. For 'person': user_id (will create DM channel first)\r\n            message: Message content to send\r\n\r\n        Note:\r\n            - For group messages: target_id should be channel_id (NOT guild_id)\r\n            - For private messages: target_id should be user_id, DM channel will be created automatically\r\n        \"\"\"\r\n        try:\r\n            content = await self.message_converter.yiri2target(message, self)\r\n\r\n            platform = ''\r\n            bot_user_id = ''\r\n            if self.logins:\r\n                current_login = self.logins[0]\r\n                platform = current_login.get('platform', '')\r\n                user = current_login.get('user', {})\r\n                bot_user_id = user.get('id', '')\r\n\r\n            channel_id = ''\r\n\r\n            if target_type == 'group':\r\n                # For group/channel messages, target_id is channel_id directly\r\n                channel_id = target_id\r\n            elif target_type == 'person':\r\n                # For private/DM messages, need to create DM channel first using user.channel.create\r\n                # Satori protocol requires creating a private channel with the user\r\n                dm_data = {'user_id': target_id}\r\n                dm_result = await self.send_http_request('POST', '/user.channel.create', dm_data, platform, bot_user_id)\r\n                if dm_result and dm_result.get('id'):\r\n                    channel_id = dm_result.get('id')\r\n                    await self.logger.debug(f'Satori 已创建私聊频道: user_id={target_id}, channel_id={channel_id}')\r\n                else:\r\n                    await self.logger.error(f'Satori 创建私聊频道失败: user_id={target_id}, response={dm_result}')\r\n                    return\r\n            else:\r\n                # Unknown target_type - log error and attempt to use target_id as channel_id\r\n                await self.logger.warning(\r\n                    f\"Satori send_message: 未知的 target_type='{target_type}'，将尝试使用 target_id 作为 channel_id\"\r\n                )\r\n                channel_id = target_id\r\n\r\n            if not channel_id:\r\n                await self.logger.error(\r\n                    f'Satori send_message: 无法确定 channel_id (target_type={target_type}, target_id={target_id})'\r\n                )\r\n                return\r\n\r\n            data = {'channel_id': channel_id, 'content': content}\r\n            await self.send_http_request('POST', '/message.create', data, platform, bot_user_id)\r\n\r\n        except Exception as e:\r\n            await self.logger.error(f'Satori 发送消息失败: {e}')\r\n\r\n    async def reply_message(\r\n        self,\r\n        message_source: platform_events.MessageEvent,\r\n        message: platform_message.MessageChain,\r\n        quote_origin: bool = False,\r\n    ):\r\n        \"\"\"Reply to message\"\"\"\r\n        try:\r\n            content = await self.message_converter.yiri2target(message, self)\r\n\r\n            # Try to get channel_id from source_platform_object first (Satori protocol needs original channel.id)\r\n            channel_id = ''\r\n            original_message_id = ''\r\n            if hasattr(message_source, 'source_platform_object') and message_source.source_platform_object:\r\n                source_obj = message_source.source_platform_object\r\n                if isinstance(source_obj, dict):\r\n                    channel = source_obj.get('channel', {})\r\n                    if channel and channel.get('id'):\r\n                        channel_id = str(channel.get('id'))\r\n                    # Get original message ID for quoting (Satori protocol)\r\n                    msg_obj = source_obj.get('message', {})\r\n                    if msg_obj and msg_obj.get('id'):\r\n                        original_message_id = str(msg_obj.get('id'))\r\n\r\n            # Fallback: get channel_id from message source\r\n            if not channel_id:\r\n                if isinstance(message_source, platform_events.GroupMessage):\r\n                    # Group message: use group ID\r\n                    if hasattr(message_source.sender, 'group') and hasattr(message_source.sender.group, 'id'):\r\n                        channel_id = message_source.sender.group.id\r\n                elif isinstance(message_source, platform_events.FriendMessage):\r\n                    # Private message: use sender ID as channel_id\r\n                    if hasattr(message_source.sender, 'id'):\r\n                        channel_id = message_source.sender.id\r\n\r\n            # Last fallback\r\n            if not channel_id:\r\n                if hasattr(message_source, 'sender') and hasattr(message_source.sender, 'id'):\r\n                    channel_id = message_source.sender.id\r\n\r\n            if not channel_id:\r\n                await self.logger.error('无法获取频道ID')\r\n                return\r\n\r\n            # Handle quote_origin: prepend <quote /> element per Satori protocol\r\n            if quote_origin:\r\n                if original_message_id:\r\n                    # Prepend quote element before content per Satori protocol\r\n                    content = f'<quote id=\"{original_message_id}\"/>{content}'\r\n                    await self.logger.debug(f'Satori 引用消息: message_id={original_message_id}')\r\n                else:\r\n                    # quote_origin requested but message ID not available - log and proceed without quote\r\n                    await self.logger.warning('Satori quote_origin=True 但无法获取原消息ID，将不使用引用发送')\r\n\r\n            platform = ''\r\n            user_id = ''\r\n            if self.logins:\r\n                current_login = self.logins[0]\r\n                platform = current_login.get('platform', '')\r\n                user = current_login.get('user', {})\r\n                user_id = user.get('id', '')\r\n\r\n            data = {'channel_id': channel_id, 'content': content}\r\n            await self.send_http_request('POST', '/message.create', data, platform, user_id)\r\n\r\n        except Exception as e:\r\n            await self.logger.error(f'Satori 回复消息失败: {e}')\r\n\r\n    async def is_muted(self, group_id: int) -> bool:\r\n        \"\"\"Check if the bot is muted in a group\"\"\"\r\n        return False\r\n\r\n    def register_listener(\r\n        self,\r\n        event_type: typing.Type[platform_events.Event],\r\n        callback: typing.Callable[\r\n            [\r\n                platform_events.Event,\r\n                abstract_platform_adapter.AbstractMessagePlatformAdapter,\r\n            ],\r\n            None,\r\n        ],\r\n    ):\r\n        \"\"\"Register an event listener\"\"\"\r\n        self.listeners[event_type] = callback\r\n\r\n    def unregister_listener(\r\n        self,\r\n        event_type: typing.Type[platform_events.Event],\r\n        callback: typing.Callable[\r\n            [\r\n                platform_events.Event,\r\n                abstract_platform_adapter.AbstractMessagePlatformAdapter,\r\n            ],\r\n            None,\r\n        ],\r\n    ):\r\n        \"\"\"Unregister an event listener\"\"\"\r\n        if event_type in self.listeners:\r\n            del self.listeners[event_type]\r\n\r\n    async def run_async(self):\r\n        \"\"\"Async run wrapper\"\"\"\r\n        await self.run()\r\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/satori.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: satori\n  label:\n    en_US: Satori\n    zh_Hans: Satori\n  description:\n    en_US: SatoriAdapter\n    zh_Hans: 古明地觉协议适配器\n  icon: satori.png\nspec:\n  config:\n    - name: platform\n      label:\n        en_US: Platform\n        zh_Hans: 平台名称\n      type: string\n      required: true\n      default: \"llonebot\"\n      description:\n        en_US: The platform name (e.g., llonebot, discord, telegram)\n        zh_Hans: 平台名称（如 llonebot, discord, telegram）\n    - name: host\n      label:\n        en_US: Host\n        zh_Hans: 主机地址\n      type: string\n      required: true\n      default: \"127.0.0.1\"\n      description:\n        en_US: The host address of LLOneBot Satori server (e.g., 127.0.0.1, localhost, 192.168.1.100)\n        zh_Hans: LLOneBot Satori服务器的主机地址（如 127.0.0.1, localhost, 192.168.1.100）\n    - name: port\n      label:\n        en_US: Port\n        zh_Hans: 监听端口\n      type: integer\n      required: true\n      default: 5600\n    - name: satori_api_base_url\n      label:\n        en_US: Satori API Endpoint\n        zh_Hans: Satori API 终结点\n      type: string\n      required: true\n      default: \"http://localhost:5600/v1\"\n    - name: satori_endpoint\n      label:\n        en_US: Satori WebSocket Endpoint\n        zh_Hans: Satori WebSocket 终结点\n      type: string\n      required: true\n      default: \"ws://localhost:5600/v1/events\"\n    - name: token\n      label:\n        en_US: Token\n        zh_Hans: 令牌\n      type: string\n      required: true\n      default: \"\"\nexecution:\n  python:\n    path: ./satori.py\n    attr: SatoriAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/slack.py",
    "content": "from __future__ import annotations\nimport typing\nimport asyncio\nimport traceback\n\nimport datetime\n\nfrom langbot.libs.slack_api.api import SlackClient\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nfrom langbot.libs.slack_api.slackevent import SlackEvent\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nfrom langbot_plugin.api.entities.builtin.command import errors as command_errors\nfrom langbot.pkg.utils import image\nfrom langbot.pkg.platform.logger import EventLogger\n\n\nclass SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain):\n        content_list = []\n        for msg in message_chain:\n            if type(msg) is platform_message.Plain:\n                content_list.append(\n                    {\n                        'type': 'text',\n                        'content': msg.text,\n                    }\n                )\n            elif type(msg) is platform_message.Image:\n                # Slack supports images via unfurling URLs\n                # Include image URL in the message so Slack can unfurl it\n                if msg.url:\n                    content_list.append(\n                        {\n                            'type': 'image',\n                            'content': msg.url,\n                        }\n                    )\n\n        return content_list\n\n    @staticmethod\n    async def target2yiri(message: str, message_id: str, pic_url: str, bot: SlackClient):\n        yiri_msg_list = []\n        yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))\n        if pic_url is not None:\n            base64_url = await image.get_slack_image_to_base64(pic_url=pic_url, bot_token=bot.bot_token)\n            yiri_msg_list.append(platform_message.Image(base64=base64_url))\n\n        yiri_msg_list.append(platform_message.Plain(text=message))\n        chain = platform_message.MessageChain(yiri_msg_list)\n        return chain\n\n\nclass SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def yiri2target(event: platform_events.MessageEvent) -> SlackEvent:\n        return event.source_platform_object\n\n    @staticmethod\n    async def target2yiri(event: SlackEvent, bot: SlackClient):\n        yiri_chain = await SlackMessageConverter.target2yiri(\n            message=event.text, message_id=event.message_id, pic_url=event.pic_url, bot=bot\n        )\n\n        if event.type == 'channel':\n            yiri_chain.insert(0, platform_message.At(target='SlackBot'))\n\n            sender = platform_entities.GroupMember(\n                id=event.user_id,\n                member_name=str(event.sender_name),\n                permission='MEMBER',\n                group=platform_entities.Group(\n                    id=event.channel_id, name='MEMBER', permission=platform_entities.Permission.Member\n                ),\n                special_title='',\n            )\n            time = int(datetime.datetime.utcnow().timestamp())\n            return platform_events.GroupMessage(\n                sender=sender, message_chain=yiri_chain, time=time, source_platform_object=event\n            )\n\n        if event.type == 'im':\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(id=event.user_id, nickname=event.sender_name, remark=''),\n                message_chain=yiri_chain,\n                time=float(datetime.datetime.now().timestamp()),\n                source_platform_object=event,\n            )\n\n\nclass SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: SlackClient\n    bot_account_id: str\n    bot_uuid: str = None\n    message_converter: SlackMessageConverter = SlackMessageConverter()\n    event_converter: SlackEventConverter = SlackEventConverter()\n    config: dict\n\n    def __init__(self, config: dict, logger: EventLogger):\n        required_keys = [\n            'bot_token',\n            'signing_secret',\n        ]\n        missing_keys = [key for key in required_keys if key not in config]\n        if missing_keys:\n            raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项，请查看文档或联系管理员')\n\n        bot = SlackClient(\n            bot_token=config['bot_token'], signing_secret=config['signing_secret'], logger=logger, unified_mode=True\n        )\n\n        super().__init__(\n            config=config,\n            logger=logger,\n            bot=bot,\n            bot_account_id=config['bot_token'],\n        )\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        slack_event = await SlackEventConverter.yiri2target(message_source)\n\n        content_list = await SlackMessageConverter.yiri2target(message)\n\n        for content in content_list:\n            # Both text and image (URL) are sent as text messages\n            # Slack will auto-unfurl image URLs\n            message_content = content['content']\n            if slack_event.type == 'channel':\n                await self.bot.send_message_to_channel(message_content, slack_event.channel_id)\n            if slack_event.type == 'im':\n                await self.bot.send_message_to_one(message_content, slack_event.user_id)\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        content_list = await SlackMessageConverter.yiri2target(message)\n        for content in content_list:\n            # Both text and image (URL) are sent as text messages\n            # Slack will auto-unfurl image URLs\n            message_content = content['content']\n            if target_type == 'person':\n                await self.bot.send_message_to_one(message_content, target_id)\n            if target_type == 'group':\n                await self.bot.send_message_to_channel(message_content, target_id)\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        async def on_message(event: SlackEvent):\n            self.bot_account_id = 'SlackBot'\n            try:\n                return await callback(await self.event_converter.target2yiri(event, self.bot), self)\n            except Exception:\n                await self.logger.error(f'Error in slack callback: {traceback.format_exc()}')\n\n        if event_type == platform_events.FriendMessage:\n            self.bot.on_message('im')(on_message)\n        elif event_type == platform_events.GroupMessage:\n            self.bot.on_message('channel')(on_message)\n\n    def set_bot_uuid(self, bot_uuid: str):\n        \"\"\"设置 bot UUID（用于生成 webhook URL）\"\"\"\n        self.bot_uuid = bot_uuid\n\n    async def handle_unified_webhook(self, bot_uuid: str, path: str, request):\n        \"\"\"处理统一 webhook 请求。\n\n        Args:\n            bot_uuid: Bot 的 UUID\n            path: 子路径（如果有的话）\n            request: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self.bot.handle_unified_webhook(request)\n\n    async def run_async(self):\n        # 统一 webhook 模式下，不启动独立的 Quart 应用\n        # 保持运行但不启动独立端口\n        async def keep_alive():\n            while True:\n                await asyncio.sleep(1)\n\n        await keep_alive()\n\n    async def kill(self) -> bool:\n        return False\n\n    async def unregister_listener(\n        self,\n        event_type: type,\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        return super().unregister_listener(event_type, callback)\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/slack.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: slack\n  label:\n    en_US: Slack\n    zh_Hans: Slack\n  description:\n    en_US: Slack Adapter\n    zh_Hans: Slack 适配器，请查看文档了解使用方式\n  icon: slack.png\nspec:\n  config:\n    - name: bot_token\n      label:\n        en_US: Bot Token\n        zh_Hans: 机器人令牌\n      type: string\n      required: true\n      default: \"\"\n    - name: signing_secret\n      label:\n        en_US: signing_secret\n        zh_Hans: 密钥\n      type: string\n      required: true\n      default: \"\"\nexecution:\n  python:\n    path: ./slack.py\n    attr: SlackAdapter"
  },
  {
    "path": "src/langbot/pkg/platform/sources/telegram.py",
    "content": "from __future__ import annotations\nimport time\n\n\nimport telegram\nimport telegram.ext\nfrom telegram import Update\nfrom telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters\nimport telegramify_markdown\nimport typing\nimport traceback\nimport base64\nimport pydantic\n\nfrom langbot.pkg.utils import httpclient\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger\n\n\nclass TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:\n        components = []\n\n        for component in message_chain:\n            if isinstance(component, platform_message.Plain):\n                components.append({'type': 'text', 'text': component.text})\n            elif isinstance(component, platform_message.Image):\n                photo_bytes = None\n\n                if component.base64:\n                    photo_bytes = base64.b64decode(component.base64)\n                elif component.url:\n                    session = httpclient.get_session()\n                    async with session.get(component.url) as response:\n                        photo_bytes = await response.read()\n                elif component.path:\n                    with open(component.path, 'rb') as f:\n                        photo_bytes = f.read()\n\n                components.append({'type': 'photo', 'photo': photo_bytes})\n            elif isinstance(component, platform_message.Forward):\n                for node in component.node_list:\n                    components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot))\n\n        return components\n\n    @staticmethod\n    async def target2yiri(message: telegram.Message, bot: telegram.Bot, bot_account_id: str):\n        message_components = []\n\n        def parse_message_text(text: str) -> list[platform_message.MessageComponent]:\n            msg_components = []\n\n            if f'@{bot_account_id}' in text:\n                msg_components.append(platform_message.At(target=bot_account_id))\n                text = text.replace(f'@{bot_account_id}', '')\n            msg_components.append(platform_message.Plain(text=text))\n\n            return msg_components\n\n        if message.text:\n            message_text = message.text\n            message_components.extend(parse_message_text(message_text))\n\n        if message.photo:\n            if message.caption:\n                message_components.extend(parse_message_text(message.caption))\n\n            file = await message.photo[-1].get_file()\n\n            file_bytes = None\n            file_format = ''\n\n            async with httpclient.get_session(trust_env=True).get(file.file_path) as response:\n                file_bytes = await response.read()\n                file_format = 'image/jpeg'\n\n            message_components.append(\n                platform_message.Image(\n                    base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode(\"utf-8\")}'\n                )\n            )\n\n        if message.voice:\n            if message.caption:\n                message_components.extend(parse_message_text(message.caption))\n\n            file = await message.voice.get_file()\n\n            file_bytes = None\n            file_format = message.voice.mime_type or 'audio/ogg'\n\n            async with httpclient.get_session(trust_env=True).get(file.file_path) as response:\n                file_bytes = await response.read()\n\n            message_components.append(\n                platform_message.Voice(\n                    base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode(\"utf-8\")}',\n                    length=message.voice.duration,\n                )\n            )\n\n        return platform_message.MessageChain(message_components)\n\n\nclass TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def yiri2target(event: platform_events.MessageEvent, bot: telegram.Bot):\n        return event.source_platform_object\n\n    @staticmethod\n    async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):\n        lb_message = await TelegramMessageConverter.target2yiri(event.message, bot, bot_account_id)\n\n        if event.effective_chat.type == 'private':\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event.effective_chat.id,\n                    nickname=event.effective_chat.first_name,\n                    remark=str(event.effective_chat.id),\n                ),\n                message_chain=lb_message,\n                time=event.message.date.timestamp(),\n                source_platform_object=event,\n            )\n        elif event.effective_chat.type == 'group' or 'supergroup':\n            return platform_events.GroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=event.effective_chat.id,\n                    member_name=event.effective_chat.title,\n                    permission=platform_entities.Permission.Member,\n                    group=platform_entities.Group(\n                        id=event.effective_chat.id,\n                        name=event.effective_chat.title,\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title='',\n                ),\n                message_chain=lb_message,\n                time=event.message.date.timestamp(),\n                source_platform_object=event,\n            )\n\n\nclass TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: telegram.Bot = pydantic.Field(exclude=True)\n    application: telegram.ext.Application = pydantic.Field(exclude=True)\n\n    message_converter: TelegramMessageConverter = TelegramMessageConverter()\n    event_converter: TelegramEventConverter = TelegramEventConverter()\n\n    config: dict\n\n    msg_stream_id: dict  # 流式消息id字典，key为流式消息id，value为首次消息源id，用于在流式消息时判断编辑那条消息\n\n    seq: int  # 消息中识别消息顺序，直接以seq作为标识\n\n    listeners: typing.Dict[\n        typing.Type[platform_events.Event],\n        typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],\n    ] = {}\n\n    def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):\n        async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):\n            if update.message.from_user.is_bot:\n                return\n\n            try:\n                lb_event = await self.event_converter.target2yiri(update, self.bot, self.bot_account_id)\n                await self.listeners[type(lb_event)](lb_event, self)\n                await self.is_stream_output_supported()\n            except Exception:\n                await self.logger.error(f'Error in telegram callback: {traceback.format_exc()}')\n\n        application = ApplicationBuilder().token(config['token']).build()\n        bot = application.bot\n        application.add_handler(\n            MessageHandler(filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE, telegram_callback)\n        )\n        super().__init__(\n            config=config,\n            logger=logger,\n            msg_stream_id={},\n            seq=1,\n            bot=bot,\n            application=application,\n            bot_account_id='',\n            listeners={},\n        )\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        components = await TelegramMessageConverter.yiri2target(message, self.bot)\n\n        chat_id_str, _, thread_id_str = str(target_id).partition('#')\n        chat_id: int | str = int(chat_id_str) if chat_id_str.lstrip('-').isdigit() else chat_id_str\n        message_thread_id = int(thread_id_str) if thread_id_str and thread_id_str.isdigit() else None\n\n        for component in components:\n            component_type = component.get('type')\n            args = {'chat_id': chat_id}\n            if message_thread_id is not None:\n                args['message_thread_id'] = message_thread_id\n\n            if component_type == 'text':\n                text = component.get('text', '')\n                if self.config['markdown_card'] is True:\n                    text = telegramify_markdown.markdownify(content=text)\n                    args['parse_mode'] = 'MarkdownV2'\n                args['text'] = text\n                await self.bot.send_message(**args)\n            elif component_type == 'photo':\n                photo = component.get('photo')\n                if photo is None:\n                    continue\n                args['photo'] = telegram.InputFile(photo)\n                await self.bot.send_photo(**args)\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        assert isinstance(message_source.source_platform_object, Update)\n        components = await TelegramMessageConverter.yiri2target(message, self.bot)\n\n        for component in components:\n            if component['type'] == 'text':\n                if self.config['markdown_card'] is True:\n                    content = telegramify_markdown.markdownify(\n                        content=component['text'],\n                    )\n                else:\n                    content = component['text']\n                args = {\n                    'chat_id': message_source.source_platform_object.effective_chat.id,\n                    'text': content,\n                }\n                if self.config['markdown_card'] is True:\n                    args['parse_mode'] = 'MarkdownV2'\n\n        if message_source.source_platform_object.message.message_thread_id:\n            args['message_thread_id'] = message_source.source_platform_object.message.message_thread_id\n\n        if quote_origin:\n            args['reply_to_message_id'] = message_source.source_platform_object.message.id\n\n        await self.bot.send_message(**args)\n\n    def _process_markdown(self, text: str) -> str:\n        if self.config.get('markdown_card', False):\n            return telegramify_markdown.markdownify(content=text)\n        return text\n\n    def _build_message_args(self, chat_id: int, text: str, message_thread_id: int = None, **extra_args) -> dict:\n        args = {'chat_id': chat_id, 'text': self._process_markdown(text), **extra_args}\n        if message_thread_id:\n            args['message_thread_id'] = message_thread_id\n        if self.config.get('markdown_card', False):\n            args['parse_mode'] = 'MarkdownV2'\n        return args\n\n    async def create_message_card(self, message_id, event):\n        assert isinstance(event.source_platform_object, Update)\n        update = event.source_platform_object\n        chat_id = update.effective_chat.id\n        chat_type = update.effective_chat.type\n        message_thread_id = update.message.message_thread_id\n\n        if chat_type == 'private':\n            draft_id = int(time.time() * 1000)\n            self.msg_stream_id[message_id] = ('private', draft_id)\n\n            args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)\n            await self.bot.send_message_draft(**args)\n        else:\n            args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)\n            send_msg = await self.bot.send_message(**args)\n            self.msg_stream_id[message_id] = ('group', send_msg.message_id)\n\n        return True\n\n    async def reply_message_chunk(\n        self,\n        message_source: platform_events.MessageEvent,\n        bot_message,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n        is_final: bool = False,\n    ):\n        message_id = bot_message.resp_message_id\n        msg_seq = bot_message.msg_sequence\n        assert isinstance(message_source.source_platform_object, Update)\n        update = message_source.source_platform_object\n        chat_id = update.effective_chat.id\n        message_thread_id = update.message.message_thread_id\n\n        if message_id not in self.msg_stream_id:\n            return\n\n        chat_mode, draft_id = self.msg_stream_id[message_id]\n        components = await TelegramMessageConverter.yiri2target(message, self.bot)\n\n        if not components or components[0]['type'] != 'text':\n            if is_final and bot_message.tool_calls is None:\n                self.msg_stream_id.pop(message_id)\n            return\n\n        content = components[0]['text']\n\n        if chat_mode == 'private':\n            args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id)\n            await self.bot.send_message_draft(**args)\n            if is_final and bot_message.tool_calls is None:\n                del args['draft_id']\n                await self.bot.send_message(**args)\n                self.msg_stream_id.pop(message_id)\n        else:\n            stream_id = draft_id\n            if (msg_seq - 1) % 8 == 0 or is_final:\n                args = {\n                    'message_id': stream_id,\n                    'chat_id': chat_id,\n                    'text': self._process_markdown(content),\n                }\n                if self.config.get('markdown_card', False):\n                    args['parse_mode'] = 'MarkdownV2'\n                await self.bot.edit_message_text(**args)\n\n            if is_final and bot_message.tool_calls is None:\n                self.msg_stream_id.pop(message_id)\n\n    def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:\n        if not isinstance(event.source_platform_object, Update):\n            return None\n\n        message = event.source_platform_object.message\n        if not message:\n            return None\n\n        # specifically handle telegram forum topic and private thread(not supported by official client yet but supported by bot api)\n        if message.message_thread_id:\n            # check if it is a group\n            if isinstance(event, platform_events.GroupMessage):\n                return f'{event.group.id}#{message.message_thread_id}'\n            elif isinstance(event, platform_events.FriendMessage):\n                return f'{event.sender.id}#{message.message_thread_id}'\n\n        return None\n\n    async def is_stream_output_supported(self) -> bool:\n        is_stream = False\n        if self.config.get('enable-stream-reply', None):\n            is_stream = True\n        return is_stream\n\n    async def is_muted(self, group_id: int) -> bool:\n        return False\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        self.listeners[event_type] = callback\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        self.listeners.pop(event_type)\n\n    async def run_async(self):\n        await self.application.initialize()\n        self.bot_account_id = (await self.bot.get_me()).username\n        await self.application.updater.start_polling(allowed_updates=Update.ALL_TYPES)\n        await self.application.start()\n        await self.logger.info('Telegram adapter running')\n\n    async def kill(self) -> bool:\n        if self.application.running:\n            await self.application.stop()\n            if self.application.updater:\n                await self.application.updater.stop()\n            await self.logger.info('Telegram adapter stopped')\n        return True\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/telegram.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: telegram\n  label:\n    en_US: Telegram\n    zh_Hans: 电报\n  description:\n    en_US: Telegram Adapter\n    zh_Hans: 电报适配器，请查看文档了解使用方式\n  icon: telegram.svg\nspec:\n  config:\n    - name: token\n      label:\n        en_US: Token\n        zh_Hans: 令牌\n      type: string\n      required: true\n      default: \"\"\n    - name: markdown_card\n      label:\n        en_US: Markdown Card\n        zh_Hans: 是否使用 Markdown 卡片\n      type: boolean\n      required: false\n      default: true\n    - name: enable-stream-reply\n      label:\n        en_US: Enable Stream Reply Mode\n        zh_Hans: 启用电报流式回复模式\n      description:\n        en_US: If enabled, the bot will use the stream of telegram reply mode\n        zh_Hans: 如果启用，将使用电报流式方式来回复内容\n      type: boolean\n      required: true\n      default: false\nexecution:\n  python:\n    path: ./telegram.py\n    attr: TelegramAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/websocket.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: websocket\n  label:\n    en_US: \"WebSocket Chat\"\n    zh_Hans: \"WebSocket 聊天\"\n  description:\n    en_US: \"WebSocket adapter for bidirectional real-time communication\"\n    zh_Hans: \"用于双向实时通信的 WebSocket 适配器\"\n  icon: \"\"\nspec:\n  config: []\nexecution:\n  python:\n    path: \"websocket_adapter.py\"\n    attr: \"WebSocketAdapter\"\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/websocket_adapter.py",
    "content": "\"\"\"WebSocket适配器 - 支持双向通信的IM系统\"\"\"\n\nimport asyncio\nimport logging\nimport typing\nfrom datetime import datetime\n\nimport pydantic\n\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger\nfrom ...core import app\nfrom .websocket_manager import ws_connection_manager, WebSocketConnection\n\nlogger = logging.getLogger(__name__)\n\n\nclass WebSocketMessage(pydantic.BaseModel):\n    \"\"\"WebSocket消息格式\"\"\"\n\n    id: int\n    role: str  # 'user' or 'assistant'\n    content: str\n    message_chain: list[dict]\n    timestamp: str\n    is_final: bool = False\n    connection_id: str = ''\n    \"\"\"发送者连接ID\"\"\"\n\n\nclass WebSocketSession:\n    \"\"\"WebSocket会话 - 管理单个会话的消息历史\"\"\"\n\n    id: str\n    message_lists: dict[str, list[WebSocketMessage]] = {}\n    \"\"\"消息列表 {pipeline_uuid: [messages]}\"\"\"\n    stream_message_indexes: dict[str, dict[str, int]] = {}\n    \"\"\"流式消息索引 {pipeline_uuid: {resp_message_id: message_index}}\"\"\"\n\n    def __init__(self, id: str):\n        self.id = id\n        self.message_lists = {}\n        self.stream_message_indexes = {}\n\n    def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:\n        if pipeline_uuid not in self.message_lists:\n            self.message_lists[pipeline_uuid] = []\n        return self.message_lists[pipeline_uuid]\n\n    def get_stream_message_indexes(self, pipeline_uuid: str) -> dict[str, int]:\n        if pipeline_uuid not in self.stream_message_indexes:\n            self.stream_message_indexes[pipeline_uuid] = {}\n        return self.stream_message_indexes[pipeline_uuid]\n\n\nclass WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    \"\"\"WebSocket适配器 - 支持双向实时通信\"\"\"\n\n    websocket_person_session: WebSocketSession = pydantic.Field(exclude=True, default_factory=WebSocketSession)\n    websocket_group_session: WebSocketSession = pydantic.Field(exclude=True, default_factory=WebSocketSession)\n\n    listeners: dict[\n        typing.Type[platform_events.Event],\n        typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],\n    ] = pydantic.Field(default_factory=dict, exclude=True)\n\n    ap: app.Application = pydantic.Field(exclude=True)\n\n    # 主动推送消息的队列\n    outbound_message_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)\n    \"\"\"后端主动推送消息的队列\"\"\"\n\n    # 流式输出开关\n    stream_enabled: bool = pydantic.Field(default=True, exclude=True)\n    \"\"\"是否启用流式输出\"\"\"\n\n    def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):\n        super().__init__(\n            config=config,\n            logger=logger,\n            **kwargs,\n        )\n\n        self.websocket_person_session = WebSocketSession(id='websocketperson')\n        self.websocket_group_session = WebSocketSession(id='websocketgroup')\n\n        self.bot_account_id = 'websocketbot'\n        self.outbound_message_queue = asyncio.Queue()\n        self.stream_enabled = True\n\n    async def send_message(\n        self,\n        target_type: str,\n        target_id: str,\n        message: platform_message.MessageChain,\n    ) -> dict:\n        \"\"\"发送消息 - 这里用于主动推送消息到前端\n\n        对于 WebSocket 适配器，我们需要将消息广播到正确的 pipeline 连接。\n        target_id 可能是 launcher_id（如 websocket_xxx）或 pipeline_uuid。\n        我们需要尝试两种方式来确保消息能够送达。\n        \"\"\"\n        # 获取当前的 pipeline_uuid\n        pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid\n        session_type = 'group' if target_type == 'group' else 'person'\n\n        # 选择会话\n        session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session\n\n        # 生成唯一消息ID\n        msg_id = len(session.get_message_list(pipeline_uuid)) + 1\n\n        message_data = WebSocketMessage(\n            id=msg_id,\n            role='assistant',\n            content=str(message),\n            message_chain=[component.__dict__ for component in message],\n            timestamp=datetime.now().isoformat(),\n            is_final=True,\n        )\n\n        # 保存到历史记录\n        session.get_message_list(pipeline_uuid).append(message_data)\n\n        # 直接广播到当前pipeline的连接\n        await ws_connection_manager.broadcast_to_pipeline(\n            pipeline_uuid,\n            {\n                'type': 'response',\n                'session_type': session_type,\n                'data': message_data.model_dump(),\n            },\n            session_type=session_type,\n        )\n\n        return message_data.model_dump()\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ) -> dict:\n        \"\"\"回复消息 - 非流式\"\"\"\n        # 获取会话和pipeline信息\n        session = (\n            self.websocket_group_session\n            if isinstance(message_source, platform_events.GroupMessage)\n            else self.websocket_person_session\n        )\n\n        # 从message_source获取pipeline_uuid和connection_id\n        pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid\n        session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'\n\n        # 生成新的消息ID\n        msg_id = len(session.get_message_list(pipeline_uuid)) + 1\n\n        message_data = WebSocketMessage(\n            id=msg_id,\n            role='assistant',\n            content=str(message),\n            message_chain=[component.__dict__ for component in message],\n            timestamp=datetime.now().isoformat(),\n            is_final=True,\n        )\n\n        # 保存到历史记录\n        session.get_message_list(pipeline_uuid).append(message_data)\n\n        # 直接广播到所有该pipeline的连接，包含session_type信息\n        await ws_connection_manager.broadcast_to_pipeline(\n            pipeline_uuid,\n            {\n                'type': 'response',\n                'session_type': session_type,\n                'data': message_data.model_dump(),\n            },\n            session_type=session_type,\n        )\n\n        return message_data.model_dump()\n\n    async def reply_message_chunk(\n        self,\n        message_source: platform_events.MessageEvent,\n        bot_message,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n        is_final: bool = False,\n    ) -> dict:\n        \"\"\"回复消息块 - 流式\"\"\"\n        # 获取会话和pipeline信息\n        session = (\n            self.websocket_group_session\n            if isinstance(message_source, platform_events.GroupMessage)\n            else self.websocket_person_session\n        )\n\n        pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid\n        session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'\n        message_list = session.get_message_list(pipeline_uuid)\n        stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid)\n\n        # Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.\n        # Use it as the primary key to avoid overwriting an old card from a previous reply.\n        resp_message_id = str(getattr(bot_message, 'resp_message_id', '') or '')\n        existing_index = stream_message_indexes.get(resp_message_id) if resp_message_id else None\n\n        message_is_final = is_final and bot_message.tool_calls is None\n\n        if existing_index is None or existing_index >= len(message_list):\n            # 创建新消息\n            msg_id = len(message_list) + 1\n            message_data = WebSocketMessage(\n                id=msg_id,\n                role='assistant',\n                content=str(message),\n                message_chain=[component.__dict__ for component in message],\n                timestamp=datetime.now().isoformat(),\n                is_final=message_is_final,\n            )\n\n            # 立即添加到历史记录（即使is_final=False），以便后续块可以更新它\n            message_list.append(message_data)\n            if resp_message_id:\n                stream_message_indexes[resp_message_id] = len(message_list) - 1\n        else:\n            # 更新同一条流式消息\n            old_message = message_list[existing_index]\n            msg_id = old_message.id\n            message_data = WebSocketMessage(\n                id=msg_id,\n                role='assistant',\n                content=str(message),\n                message_chain=[component.__dict__ for component in message],\n                timestamp=old_message.timestamp,  # 保持原始时间戳\n                is_final=message_is_final,\n            )\n\n            # 更新历史记录中的对应消息\n            message_list[existing_index] = message_data\n\n        if message_is_final and resp_message_id:\n            stream_message_indexes.pop(resp_message_id, None)\n\n        # 直接广播到所有该pipeline的连接，包含session_type信息\n        await ws_connection_manager.broadcast_to_pipeline(\n            pipeline_uuid,\n            {\n                'type': 'response',\n                'session_type': session_type,\n                'data': message_data.model_dump(),\n            },\n            session_type=session_type,\n        )\n\n        return message_data.model_dump()\n\n    async def is_stream_output_supported(self) -> bool:\n        \"\"\"根据stream_enabled标志返回是否支持流式输出\"\"\"\n        return self.stream_enabled\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        func: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]\n        ],\n    ):\n        \"\"\"注册事件监听器\"\"\"\n        self.listeners[event_type] = func\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        func: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]\n        ],\n    ):\n        \"\"\"取消注册事件监听器\"\"\"\n        del self.listeners[event_type]\n\n    async def is_muted(self, group_id: int) -> bool:\n        return False\n\n    async def run_async(self):\n        \"\"\"运行适配器\"\"\"\n\n        try:\n            while True:\n                # 处理主动推送消息\n                if not self.outbound_message_queue.empty():\n                    try:\n                        message = await asyncio.wait_for(self.outbound_message_queue.get(), timeout=0.1)\n                        # 广播到所有相关连接\n                        target_id = message.get('target_id', '')\n                        await ws_connection_manager.broadcast_to_pipeline(target_id, message)\n                    except asyncio.TimeoutError:\n                        pass\n\n                await asyncio.sleep(0.1)\n        except asyncio.CancelledError:\n            raise\n\n    async def kill(self):\n        \"\"\"停止适配器\"\"\"\n        pass\n\n    async def _process_image_components(self, message_chain_obj: list):\n        \"\"\"\n        处理消息链中的图片组件，将path转换为base64\n\n        Args:\n            message_chain_obj: 消息链对象列表\n        \"\"\"\n        import base64\n\n        storage_mgr = self.ap.storage_mgr\n\n        for component in message_chain_obj:\n            if component.get('type') == 'Image' and component.get('path'):\n                try:\n                    # 从storage读取文件\n                    file_content = await storage_mgr.storage_provider.load(component['path'])\n\n                    # 转换为base64\n                    base64_str = base64.b64encode(file_content).decode('utf-8')\n\n                    # 添加data URI前缀（根据文件扩展名判断MIME类型）\n                    file_key = component['path']\n                    if file_key.lower().endswith(('.jpg', '.jpeg')):\n                        mime_type = 'image/jpeg'\n                    elif file_key.lower().endswith('.png'):\n                        mime_type = 'image/png'\n                    elif file_key.lower().endswith('.gif'):\n                        mime_type = 'image/gif'\n                    elif file_key.lower().endswith('.webp'):\n                        mime_type = 'image/webp'\n                    else:\n                        mime_type = 'image/png'  # 默认\n\n                    component['base64'] = f'data:{mime_type};base64,{base64_str}'\n                    await storage_mgr.storage_provider.delete(component['path'])\n                    component['path'] = ''\n                    # 保留path字段用于后端处理，前端使用base64显示\n                except Exception as e:\n                    await self.logger.error(f'加载图片文件失败 {component[\"path\"]}: {e}')\n\n    async def handle_websocket_message(\n        self,\n        connection: WebSocketConnection,\n        message_data: dict,\n    ):\n        \"\"\"\n        处理从WebSocket接收的消息\n\n        这个方法只负责接收消息、保存到历史记录、并触发事件处理\n        不等待任何响应，响应消息会通过reply_message/reply_message_chunk直接发送\n\n        Args:\n            connection: WebSocket连接对象\n            message_data: 消息数据，包含:\n                - message: 消息链\n                - stream: 是否启用流式输出 (可选，默认True)\n        \"\"\"\n        pipeline_uuid = connection.pipeline_uuid\n        session_type = connection.session_type\n\n        # 获取stream参数，默认为True\n        self.stream_enabled = message_data.get('stream', True)\n\n        # 选择会话\n        use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session\n\n        # 解析消息链\n        message_chain_obj = message_data.get('message', [])\n\n        # 处理图片组件：将path转换为base64\n        await self._process_image_components(message_chain_obj)\n\n        message_chain = platform_message.MessageChain.model_validate(message_chain_obj)\n\n        # 生成消息ID\n        message_id = len(use_session.get_message_list(pipeline_uuid)) + 1\n\n        # 保存用户消息\n        user_message = WebSocketMessage(\n            id=message_id,\n            role='user',\n            content=str(message_chain),\n            message_chain=message_chain_obj,\n            timestamp=datetime.now().isoformat(),\n            connection_id=connection.connection_id,\n            is_final=True,  # 用户消息始终是完整的，非流式\n        )\n        use_session.get_message_list(pipeline_uuid).append(user_message)\n\n        # 广播用户消息到所有连接（包括发送者），包含session_type信息\n        await ws_connection_manager.broadcast_to_pipeline(\n            pipeline_uuid,\n            {\n                'type': 'user_message',\n                'session_type': session_type,\n                'data': user_message.model_dump(),\n            },\n            session_type=session_type,\n        )\n\n        # 添加消息源\n        message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))\n\n        # 创建事件\n        if session_type == 'person':\n            sender = platform_entities.Friend(\n                id=f'websocket_{connection.connection_id}', nickname='User', remark='User'\n            )\n            event = platform_events.FriendMessage(\n                sender=sender, message_chain=message_chain, time=datetime.now().timestamp()\n            )\n        else:\n            group = platform_entities.Group(\n                id='websocketgroup', name='Group', permission=platform_entities.Permission.Member\n            )\n            sender = platform_entities.GroupMember(\n                id=f'websocket_{connection.connection_id}',\n                member_name='User',\n                group=group,\n                permission=platform_entities.Permission.Member,\n            )\n            event = platform_events.GroupMessage(\n                sender=sender, message_chain=message_chain, time=datetime.now().timestamp()\n            )\n\n        # 设置流水线UUID\n        self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid\n\n        # 异步触发事件处理（不等待结果）\n        if event.__class__ in self.listeners:\n            asyncio.create_task(self.listeners[event.__class__](event, self))\n\n    def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:\n        \"\"\"获取消息历史\"\"\"\n        if session_type == 'person':\n            return [message.model_dump() for message in self.websocket_person_session.get_message_list(pipeline_uuid)]\n        else:\n            return [message.model_dump() for message in self.websocket_group_session.get_message_list(pipeline_uuid)]\n\n    def reset_session(self, pipeline_uuid: str, session_type: str):\n        \"\"\"重置会话\"\"\"\n        if session_type == 'person':\n            if pipeline_uuid in self.websocket_person_session.message_lists:\n                self.websocket_person_session.message_lists[pipeline_uuid] = []\n            if pipeline_uuid in self.websocket_person_session.stream_message_indexes:\n                self.websocket_person_session.stream_message_indexes[pipeline_uuid] = {}\n        else:\n            if pipeline_uuid in self.websocket_group_session.message_lists:\n                self.websocket_group_session.message_lists[pipeline_uuid] = []\n            if pipeline_uuid in self.websocket_group_session.stream_message_indexes:\n                self.websocket_group_session.stream_message_indexes[pipeline_uuid] = {}\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/websocket_manager.py",
    "content": "\"\"\"WebSocket连接管理器 - 管理多个并发WebSocket连接\"\"\"\n\nimport asyncio\nimport logging\nimport typing\nimport uuid\nfrom datetime import datetime\n\nimport pydantic\n\nlogger = logging.getLogger(__name__)\n\n\nclass WebSocketConnection(pydantic.BaseModel):\n    \"\"\"单个WebSocket连接\"\"\"\n\n    model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)\n\n    connection_id: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4()))\n    \"\"\"连接唯一ID\"\"\"\n\n    pipeline_uuid: str\n    \"\"\"关联的流水线UUID\"\"\"\n\n    session_type: str  # 'person' or 'group'\n    \"\"\"会话类型\"\"\"\n\n    websocket: typing.Any = pydantic.Field(exclude=True)\n    \"\"\"WebSocket连接对象 (quart.websocket)\"\"\"\n\n    created_at: datetime = pydantic.Field(default_factory=datetime.now)\n    \"\"\"连接创建时间\"\"\"\n\n    last_active: datetime = pydantic.Field(default_factory=datetime.now)\n    \"\"\"最后活跃时间\"\"\"\n\n    send_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)\n    \"\"\"发送消息队列\"\"\"\n\n    is_active: bool = True\n    \"\"\"连接是否活跃\"\"\"\n\n    metadata: dict = pydantic.Field(default_factory=dict)\n    \"\"\"连接元数据（可存储额外信息）\"\"\"\n\n\nclass WebSocketConnectionManager:\n    \"\"\"WebSocket连接管理器 - 支持多连接并发\"\"\"\n\n    def __init__(self):\n        self.connections: dict[str, WebSocketConnection] = {}\n        \"\"\"所有活跃连接 {connection_id: connection}\"\"\"\n\n        self.pipeline_connections: dict[str, set[str]] = {}\n        \"\"\"流水线到连接的映射 {pipeline_uuid: {connection_id, ...}}\"\"\"\n\n        self.session_connections: dict[str, set[str]] = {}\n        \"\"\"会话类型到连接的映射 {session_type: {connection_id, ...}}\"\"\"\n\n        self._lock = asyncio.Lock()\n        \"\"\"线程锁，保护并发访问\"\"\"\n\n    async def add_connection(\n        self,\n        websocket: typing.Any,\n        pipeline_uuid: str,\n        session_type: str,\n        metadata: dict = None,\n    ) -> WebSocketConnection:\n        \"\"\"添加新的WebSocket连接\"\"\"\n        async with self._lock:\n            connection = WebSocketConnection(\n                pipeline_uuid=pipeline_uuid,\n                session_type=session_type,\n                websocket=websocket,\n                metadata=metadata or {},\n            )\n\n            self.connections[connection.connection_id] = connection\n\n            # 更新流水线映射\n            if pipeline_uuid not in self.pipeline_connections:\n                self.pipeline_connections[pipeline_uuid] = set()\n            self.pipeline_connections[pipeline_uuid].add(connection.connection_id)\n\n            # 更新会话类型映射\n            if session_type not in self.session_connections:\n                self.session_connections[session_type] = set()\n            self.session_connections[session_type].add(connection.connection_id)\n\n            logger.debug(\n                f'WebSocket connection established: {connection.connection_id} '\n                f'(pipeline={pipeline_uuid}, session_type={session_type})'\n            )\n\n            return connection\n\n    async def remove_connection(self, connection_id: str):\n        \"\"\"移除WebSocket连接\"\"\"\n        async with self._lock:\n            if connection_id not in self.connections:\n                return\n\n            connection = self.connections[connection_id]\n            connection.is_active = False\n\n            # 从流水线映射中移除\n            if connection.pipeline_uuid in self.pipeline_connections:\n                self.pipeline_connections[connection.pipeline_uuid].discard(connection_id)\n                if not self.pipeline_connections[connection.pipeline_uuid]:\n                    del self.pipeline_connections[connection.pipeline_uuid]\n\n            # 从会话类型映射中移除\n            if connection.session_type in self.session_connections:\n                self.session_connections[connection.session_type].discard(connection_id)\n                if not self.session_connections[connection.session_type]:\n                    del self.session_connections[connection.session_type]\n\n            del self.connections[connection_id]\n\n            logger.debug(f'WebSocket connection disconnected: {connection_id}')\n\n    async def get_connection(self, connection_id: str) -> typing.Optional[WebSocketConnection]:\n        \"\"\"获取指定连接\"\"\"\n        return self.connections.get(connection_id)\n\n    async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:\n        \"\"\"获取指定流水线的所有连接\"\"\"\n        connection_ids = self.pipeline_connections.get(pipeline_uuid, set())\n        return [self.connections[cid] for cid in connection_ids if cid in self.connections]\n\n    async def get_connections_by_session_type(self, session_type: str) -> list[WebSocketConnection]:\n        \"\"\"获取指定会话类型的所有连接\"\"\"\n        connection_ids = self.session_connections.get(session_type, set())\n        return [self.connections[cid] for cid in connection_ids if cid in self.connections]\n\n    async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict, session_type: str = None):\n        \"\"\"向指定流水线的所有连接广播消息\n\n        Args:\n            pipeline_uuid: 流水线UUID\n            message: 要广播的消息\n            session_type: 可选的会话类型过滤器，如果提供则只向匹配的session_type连接广播\n        \"\"\"\n        connections = await self.get_connections_by_pipeline(pipeline_uuid)\n\n        # 如果指定了session_type，只向匹配的连接广播\n        if session_type is not None:\n            connections = [conn for conn in connections if conn.session_type == session_type]\n\n        tasks = []\n        for conn in connections:\n            tasks.append(self.send_to_connection(conn.connection_id, message))\n        if tasks:\n            await asyncio.gather(*tasks, return_exceptions=True)\n\n    async def send_to_connection(self, connection_id: str, message: dict):\n        \"\"\"向指定连接发送消息\"\"\"\n        connection = await self.get_connection(connection_id)\n        if not connection or not connection.is_active:\n            logger.warning(f'Attempt to send message to invalid connection: {connection_id}')\n            return\n\n        try:\n            await connection.send_queue.put(message)\n            connection.last_active = datetime.now()\n        except Exception as e:\n            logger.error(f'Failed to send message to connection {connection_id}: {e}')\n            await self.remove_connection(connection_id)\n\n    async def update_activity(self, connection_id: str):\n        \"\"\"更新连接活跃时间\"\"\"\n        connection = await self.get_connection(connection_id)\n        if connection:\n            connection.last_active = datetime.now()\n\n    def get_stats(self) -> dict:\n        \"\"\"获取连接统计信息\"\"\"\n        return {\n            'total_connections': len(self.connections),\n            'pipelines': len(self.pipeline_connections),\n            'connections_by_pipeline': {k: len(v) for k, v in self.pipeline_connections.items()},\n            'connections_by_session_type': {k: len(v) for k, v in self.session_connections.items()},\n        }\n\n\n# 全局连接管理器实例\nws_connection_manager = WebSocketConnectionManager()\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/wechatpad.py",
    "content": "import requests\nimport websocket\nimport json\nimport time\nimport httpx\n\nfrom langbot.libs.wechatpad_api.client import WeChatPadClient\n\nimport typing\nimport asyncio\nimport traceback\nimport re\nimport base64\nimport copy\nimport threading\n\nimport quart\n\nfrom langbot.pkg.platform.logger import EventLogger\nimport xml.etree.ElementTree as ET\nfrom typing import Optional, Tuple\nfrom functools import partial\nimport logging\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger\n\n\nclass WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):\n        self.bot = WeChatPadClient(config['wechatpad_url'], config['token'])\n        self.config = config\n        self.logger = logger\n\n        # super().__init__(\n        #     config = config,\n        #     bot = bot,\n        #     logger = logger,\n        # )\n\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:\n        content_list = []\n\n        for component in message_chain:\n            if isinstance(component, platform_message.AtAll):\n                content_list.append({'type': 'at', 'target': 'all'})\n            elif isinstance(component, platform_message.At):\n                content_list.append({'type': 'at', 'target': component.target})\n            elif isinstance(component, platform_message.Plain):\n                content_list.append({'type': 'text', 'content': component.text})\n            elif isinstance(component, platform_message.Image):\n                if component.url:\n                    async with httpx.AsyncClient() as client:\n                        response = await client.get(component.url)\n\n                        if response.status_code == 200:\n                            file_bytes = response.content\n                            base64_str = base64.b64encode(file_bytes).decode('utf-8')  # 返回字符串格式\n                        else:\n                            raise Exception('获取文件失败')\n                    # pass\n                    content_list.append({'type': 'image', 'image': base64_str})\n                elif component.base64:\n                    content_list.append({'type': 'image', 'image': component.base64})\n\n            elif isinstance(component, platform_message.WeChatEmoji):\n                content_list.append(\n                    {'type': 'WeChatEmoji', 'emoji_md5': component.emoji_md5, 'emoji_size': component.emoji_size}\n                )\n            elif isinstance(component, platform_message.Voice):\n                content_list.append({'type': 'voice', 'data': component.url, 'duration': component.length, 'forma': 0})\n            elif isinstance(component, platform_message.WeChatAppMsg):\n                content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})\n            elif isinstance(component, platform_message.Forward):\n                for node in component.node_list:\n                    if node.message_chain:\n                        content_list.extend(await WeChatPadMessageConverter.yiri2target(node.message_chain))\n\n        return content_list\n\n    async def target2yiri(\n        self,\n        message: dict,\n        bot_account_id: str,\n    ) -> platform_message.MessageChain:\n        \"\"\"外部消息转平台消息\"\"\"\n        # 数据预处理\n        message_list = []\n        bot_wxid = self.config['wxid']\n        ats_bot = False  # 是否被@\n        content = message['content']['str']\n        content_no_preifx = content  # 群消息则去掉前缀\n        is_group_message = self._is_group_message(message)\n        if is_group_message:\n            ats_bot = self._ats_bot(message, bot_account_id)\n\n            self.logger.info(f'ats_bot: {ats_bot}; bot_account_id: {bot_account_id}; bot_wxid: {bot_wxid}')\n            if '@所有人' in content:\n                message_list.append(platform_message.AtAll())\n            if ats_bot:\n                message_list.append(platform_message.At(target=bot_account_id))\n\n            # 解析@信息并生成At组件\n            at_targets = self._extract_at_targets(message)\n            for target_id in at_targets:\n                if target_id != bot_wxid:  # 避免重复添加机器人的At\n                    message_list.append(platform_message.At(target=target_id))\n\n            content_no_preifx, _ = self._extract_content_and_sender(content)\n\n        msg_type = message['msg_type']\n\n        # 映射消息类型到处理器方法\n        handler_map = {\n            1: self._handler_text,\n            3: self._handler_image,\n            34: self._handler_voice,\n            49: self._handler_compound,  # 复合类型\n        }\n\n        # 分派处理\n        handler = handler_map.get(msg_type, self._handler_default)\n        handler_result = await handler(\n            message=message,  # 原始的message\n            content_no_preifx=content_no_preifx,  # 处理后的content\n        )\n\n        if handler_result and len(handler_result) > 0:\n            message_list.extend(handler_result)\n\n        return platform_message.MessageChain(message_list)\n\n    async def _handler_text(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:\n        \"\"\"处理文本消息 (msg_type=1)\"\"\"\n        if message and self._is_group_message(message):\n            pattern = r'@\\S{1,20}'\n            content_no_preifx = re.sub(pattern, '', content_no_preifx)\n\n        return platform_message.MessageChain([platform_message.Plain(text=content_no_preifx)])\n\n    async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:\n        \"\"\"处理图像消息 (msg_type=3)\"\"\"\n        try:\n            image_xml = content_no_preifx\n            if not image_xml:\n                return platform_message.MessageChain([platform_message.Unknown('[图片内容为空]')])\n            root = ET.fromstring(image_xml)\n\n            # 提取img标签的属性\n            img_tag = root.find('img')\n            if img_tag is not None:\n                aeskey = img_tag.get('aeskey')\n                cdnthumburl = img_tag.get('cdnthumburl')\n                # cdnmidimgurl = img_tag.get('cdnmidimgurl')\n\n            image_data = self.bot.cdn_download(aeskey=aeskey, file_type=1, file_url=cdnthumburl)\n            if image_data['Data']['FileData'] == '':\n                image_data = self.bot.cdn_download(aeskey=aeskey, file_type=2, file_url=cdnthumburl)\n            base64_str = image_data['Data']['FileData']\n            # self.logger.info(f\"data:image/png;base64,{base64_str}\")\n\n            elements = [\n                platform_message.Image(base64=f'data:image/png;base64,{base64_str}'),\n                # platform_message.WeChatForwardImage(xml_data=image_xml)  # 微信消息转发\n            ]\n            return platform_message.MessageChain(elements)\n        except Exception as e:\n            self.logger.error(f'处理图片失败: {str(e)}')\n            return platform_message.MessageChain([platform_message.Unknown('[图片处理失败]')])\n\n    async def _handler_voice(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:\n        \"\"\"处理语音消息 (msg_type=34)\"\"\"\n        message_List = []\n        try:\n            # 从消息中提取语音数据（需根据实际数据结构调整字段名）\n            # audio_base64 = message[\"img_buf\"][\"buffer\"]\n            voice_xml = content_no_preifx\n            new_msg_id = message['new_msg_id']\n            root = ET.fromstring(voice_xml)\n\n            # 提取voicemsg标签的属性\n            voicemsg = root.find('voicemsg')\n            if voicemsg is not None:\n                bufid = voicemsg.get('bufid')\n                length = voicemsg.get('voicelength')\n            voice_data = self.bot.get_msg_voice(buf_id=str(bufid), length=int(length), msgid=str(new_msg_id))\n            audio_base64 = voice_data['Data']['Base64']\n\n            # 验证语音数据有效性\n            if not audio_base64:\n                message_List.append(platform_message.Unknown(text='[语音内容为空]'))\n                return platform_message.MessageChain(message_List)\n\n            # 转换为平台支持的语音格式（如 Silk 格式）\n            voice_element = platform_message.Voice(base64=f'data:audio/silk;base64,{audio_base64}')\n            message_List.append(voice_element)\n\n        except KeyError as e:\n            self.logger.error(f'语音数据字段缺失: {str(e)}')\n            message_List.append(platform_message.Unknown(text='[语音数据解析失败]'))\n        except Exception as e:\n            self.logger.error(f'处理语音消息异常: {str(e)}')\n            message_List.append(platform_message.Unknown(text='[语音处理失败]'))\n\n        return platform_message.MessageChain(message_List)\n\n    async def _handler_compound(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:\n        \"\"\"处理复合消息 (msg_type=49)，根据子类型分派\"\"\"\n        try:\n            xml_data = ET.fromstring(content_no_preifx)\n            appmsg_data = xml_data.find('.//appmsg')\n            if appmsg_data:\n                data_type = appmsg_data.findtext('.//type', '')\n                # 二次分派处理器\n                sub_handler_map = {\n                    '57': self._handler_compound_quote,\n                    '5': self._handler_compound_link,\n                    '6': self._handler_compound_file,\n                    '74': self._handler_compound_file,\n                    '33': self._handler_compound_mini_program,\n                    '36': self._handler_compound_mini_program,\n                    '2000': partial(self._handler_compound_unsupported, text='[转账消息]'),\n                    '2001': partial(self._handler_compound_unsupported, text='[红包消息]'),\n                    '51': partial(self._handler_compound_unsupported, text='[视频号消息]'),\n                }\n\n                handler = sub_handler_map.get(data_type, self._handler_compound_unsupported)\n                return await handler(\n                    message=message,  # 原始msg\n                    xml_data=xml_data,  # xml数据\n                )\n            else:\n                return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])\n        except Exception as e:\n            self.logger.error(f'解析复合消息失败: {str(e)}')\n            return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])\n\n    async def _handler_compound_quote(\n        self, message: Optional[dict], xml_data: ET.Element\n    ) -> platform_message.MessageChain:\n        \"\"\"处理引用消息 (data_type=57)\"\"\"\n        message_list = []\n        #         self.logger.info(\"_handler_compound_quote\", ET.tostring(xml_data, encoding='unicode'))\n        appmsg_data = xml_data.find('.//appmsg')\n        quote_data = ''  # 引用原文\n        # quote_id = None  # 引用消息的原发送者\n        # tousername = None  # 接收方: 所属微信的wxid\n        user_data = ''  # 用户消息\n        sender_id = xml_data.findtext('.//fromusername')  # 发送方：单聊用户/群member\n\n        # 引用消息转发\n        if appmsg_data:\n            user_data = appmsg_data.findtext('.//title') or ''\n            quote_data = appmsg_data.find('.//refermsg').findtext('.//content')\n            # quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr')\n            message_list.append(platform_message.WeChatAppMsg(app_msg=ET.tostring(appmsg_data, encoding='unicode')))\n        # if message:\n        #     tousername = message['to_user_name']['str']\n\n        if quote_data:\n            quote_data_message_list = platform_message.MessageChain()\n            # 文本消息\n            try:\n                if '<msg>' not in quote_data:\n                    quote_data_message_list.append(platform_message.Plain(text=quote_data))\n                else:\n                    # 引用消息展开\n                    quote_data_xml = ET.fromstring(quote_data)\n                    if quote_data_xml.find('img'):\n                        quote_data_message_list.extend(await self._handler_image(None, quote_data))\n                    elif quote_data_xml.find('voicemsg'):\n                        quote_data_message_list.extend(await self._handler_voice(None, quote_data))\n                    elif quote_data_xml.find('videomsg'):\n                        quote_data_message_list.extend(await self._handler_default(None, quote_data))  # 先不处理\n                    else:\n                        # appmsg\n                        quote_data_message_list.extend(await self._handler_compound(None, quote_data))\n            except Exception as e:\n                self.logger.error(f'处理引用消息异常 expcetion:{e}')\n                quote_data_message_list.append(platform_message.Plain(text=quote_data))\n            message_list.append(\n                platform_message.Quote(\n                    sender_id=sender_id,\n                    origin=quote_data_message_list,\n                )\n            )\n            if len(user_data) > 0:\n                pattern = r'@\\S{1,20}'\n                user_data = re.sub(pattern, '', user_data)\n                message_list.append(platform_message.Plain(text=user_data))\n\n        return platform_message.MessageChain(message_list)\n\n    async def _handler_compound_file(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:\n        \"\"\"处理文件消息 (data_type=6)\"\"\"\n        file_data = xml_data.find('.//appmsg')\n\n        if file_data.findtext('.//type', '') == '74':\n            return None\n\n        else:\n            xml_data_str = ET.tostring(xml_data, encoding='unicode')\n            # print(xml_data_str)\n\n            # 提取img标签的属性\n            # print(xml_data)\n            file_name = file_data.find('title').text\n            file_id = file_data.find('md5').text\n            # file_szie = file_data.find('totallen')\n\n            # print(file_data)\n            if file_data is not None:\n                aeskey = xml_data.findtext('.//appattach/aeskey')\n                cdnthumburl = xml_data.findtext('.//appattach/cdnattachurl')\n                # cdnmidimgurl = img_tag.get('cdnmidimgurl')\n\n            # print(aeskey,cdnthumburl)\n\n            file_data = self.bot.cdn_download(aeskey=aeskey, file_type=5, file_url=cdnthumburl)\n\n            file_base64 = file_data['Data']['FileData']\n            # print(file_data)\n            file_size = file_data['Data']['TotalSize']\n\n            # print(file_base64)\n            return platform_message.MessageChain(\n                [\n                    platform_message.WeChatFile(\n                        file_id=file_id, file_name=file_name, file_size=file_size, file_base64=file_base64\n                    ),\n                    platform_message.WeChatForwardFile(xml_data=xml_data_str),\n                ]\n            )\n\n    async def _handler_compound_link(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:\n        \"\"\"处理链接消息（如公众号文章、外部网页）\"\"\"\n        message_list = []\n        try:\n            # 解析 XML 中的链接参数\n            appmsg = xml_data.find('.//appmsg')\n            if appmsg is None:\n                return platform_message.MessageChain()\n            message_list.append(\n                platform_message.WeChatLink(\n                    link_title=appmsg.findtext('title', ''),\n                    link_desc=appmsg.findtext('des', ''),\n                    link_url=appmsg.findtext('url', ''),\n                    link_thumb_url=appmsg.findtext('thumburl', ''),  # 这个字段拿不到\n                )\n            )\n            # 还没有发链接的接口, 暂时还需要自己构造appmsg, 先用WeChatAppMsg。\n            message_list.append(platform_message.WeChatAppMsg(app_msg=ET.tostring(appmsg, encoding='unicode')))\n        except Exception as e:\n            self.logger.error(f'解析链接消息失败: {str(e)}')\n        return platform_message.MessageChain(message_list)\n\n    async def _handler_compound_mini_program(\n        self, message: dict, xml_data: ET.Element\n    ) -> platform_message.MessageChain:\n        \"\"\"处理小程序消息（如小程序卡片、服务通知）\"\"\"\n        xml_data_str = ET.tostring(xml_data, encoding='unicode')\n        return platform_message.MessageChain([platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)])\n\n    async def _handler_default(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:\n        \"\"\"处理未知消息类型\"\"\"\n        if message:\n            msg_type = message['msg_type']\n        else:\n            msg_type = ''\n        return platform_message.MessageChain([platform_message.Unknown(text=f'[未知消息类型 msg_type:{msg_type}]')])\n\n    def _handler_compound_unsupported(\n        self, message: dict, xml_data: str, text: Optional[str] = None\n    ) -> platform_message.MessageChain:\n        \"\"\"处理未支持复合消息类型(msg_type=49)子类型\"\"\"\n        if not text:\n            text = f'[xml_data={xml_data}]'\n        content_list = []\n        content_list.append(platform_message.Unknown(text=f'[处理未支持复合消息类型[msg_type=49]|{text}'))\n\n        return platform_message.MessageChain(content_list)\n\n    # 返回是否被艾特\n    def _ats_bot(self, message: dict, bot_account_id: str) -> bool:\n        ats_bot = False\n        try:\n            to_user_name = message['to_user_name']['str']  # 接收方: 所属微信的wxid\n            raw_content = message['content']['str']  # 原始消息内容\n            content_no_prefix, _ = self._extract_content_and_sender(raw_content)\n            # 直接艾特机器人（这个有bug，当被引用的消息里面有@bot,会套娃\n            # ats_bot =  ats_bot or (f\"@{bot_account_id}\" in content_no_prefix)\n            # 文本类@bot\n            push_content = message.get('push_content', '')\n            ats_bot = ats_bot or ('在群聊中@了你' in push_content)\n            # 引用别人时@bot\n            msg_source = message.get('msg_source', '') or ''\n            if len(msg_source) > 0:\n                msg_source_data = ET.fromstring(msg_source)\n                at_user_list = msg_source_data.findtext('atuserlist') or ''\n                ats_bot = ats_bot or (to_user_name in at_user_list)\n            # 引用bot\n            if message.get('msg_type', 0) == 49:\n                xml_data = ET.fromstring(content_no_prefix)\n                appmsg_data = xml_data.find('.//appmsg')\n                tousername = message['to_user_name']['str']\n                if appmsg_data:  # 接收方: 所属微信的wxid\n                    quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr')  # 引用消息的原发送者\n                    ats_bot = ats_bot or (quote_id == tousername)\n        except Exception as e:\n            self.logger.error(f'_ats_bot got except: {e}')\n        finally:\n            return ats_bot\n\n    # 提取一下at的wxid列表\n    def _extract_at_targets(self, message: dict) -> list[str]:\n        \"\"\"从消息中提取被@用户的ID列表\"\"\"\n        at_targets = []\n        try:\n            # 从msg_source中解析atuserlist\n            msg_source = message.get('msg_source', '') or ''\n            if len(msg_source) > 0:\n                msg_source_data = ET.fromstring(msg_source)\n                at_user_list = msg_source_data.findtext('atuserlist') or ''\n                if at_user_list:\n                    # atuserlist格式通常是逗号分隔的用户ID列表\n                    at_targets = [user_id.strip() for user_id in at_user_list.split(',') if user_id.strip()]\n        except Exception as e:\n            self.logger.error(f'_extract_at_targets got except: {e}')\n        return at_targets\n\n    # 提取一下content前面的sender_id, 和去掉前缀的内容\n    def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]:\n        try:\n            # 检查消息开头，如果有 wxid_sbitaz0mt65n22:\\n 则删掉\n            # add: 有些用户的wxid不是上述格式。换成user_name:\n            regex = re.compile(r'^[a-zA-Z0-9_\\-]{5,20}:')\n            line_split = raw_content.split('\\n')\n            if len(line_split) > 0 and regex.match(line_split[0]):\n                raw_content = '\\n'.join(line_split[1:])\n                sender_id = line_split[0].strip(':')\n                return raw_content, sender_id\n        except Exception as e:\n            self.logger.error(f'_extract_content_and_sender got except: {e}')\n        finally:\n            return raw_content, None\n\n    # 是否是群消息\n    def _is_group_message(self, message: dict) -> bool:\n        from_user_name = message['from_user_name']['str']\n        return from_user_name.endswith('@chatroom')\n\n\nclass WeChatPadEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    def __init__(self, config: dict, logger: logging.Logger):\n        self.config = config\n        self.logger = logger\n        self.message_converter = WeChatPadMessageConverter(self.config, self.logger)\n        # super().__init__(\n        #     config=config,\n        #     message_converter=message_converter,\n        #     logger = logger,\n        # )\n\n    @staticmethod\n    async def yiri2target(event: platform_events.MessageEvent) -> dict:\n        pass\n\n    async def target2yiri(\n        self,\n        event: dict,\n        bot_account_id: str,\n    ) -> platform_events.MessageEvent:\n        # 排除公众号以及微信团队消息\n        if (\n            event['from_user_name']['str'].startswith('gh_')\n            or event['from_user_name']['str'] == 'weixin'\n            or event['from_user_name']['str'] == 'newsapp'\n            or event['from_user_name']['str'] == self.config['wxid']\n        ):\n            return None\n        message_chain = await self.message_converter.target2yiri(copy.deepcopy(event), bot_account_id)\n\n        if not message_chain:\n            return None\n\n        if '@chatroom' in event['from_user_name']['str']:\n            # 找出开头的 wxid_ 字符串，以:结尾\n            sender_wxid = event['content']['str'].split(':')[0]\n\n            return platform_events.GroupMessage(\n                sender=platform_entities.GroupMember(\n                    id=sender_wxid,\n                    member_name=event['from_user_name']['str'],\n                    permission=platform_entities.Permission.Member,\n                    group=platform_entities.Group(\n                        id=event['from_user_name']['str'],\n                        name=event['from_user_name']['str'],\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title='',\n                ),\n                message_chain=message_chain,\n                time=event['create_time'],\n                source_platform_object=event,\n            )\n        else:\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event['from_user_name']['str'],\n                    nickname=event['from_user_name']['str'],\n                    remark='',\n                ),\n                message_chain=message_chain,\n                time=event['create_time'],\n                source_platform_object=event,\n            )\n\n\nclass WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    name: str = 'WeChatPad'  # 定义适配器名称\n\n    bot: WeChatPadClient\n    quart_app: quart.Quart\n\n    bot_account_id: str\n\n    config: dict\n\n    logger: EventLogger\n\n    message_converter: WeChatPadMessageConverter\n    event_converter: WeChatPadEventConverter\n\n    listeners: typing.Dict[\n        typing.Type[platform_events.Event],\n        typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],\n    ] = {}\n\n    def __init__(self, config: dict, logger: EventLogger):\n        quart_app = quart.Quart(__name__)\n\n        message_converter = WeChatPadMessageConverter(config, logger)\n        event_converter = WeChatPadEventConverter(config, logger)\n        bot = WeChatPadClient(config['wechatpad_url'], config['token'])\n        super().__init__(\n            config=config,\n            logger=logger,\n            quart_app=quart_app,\n            message_converter=message_converter,\n            event_converter=event_converter,\n            listeners={},\n            bot_account_id='',\n            name='WeChatPad',\n            bot=bot,\n        )\n\n    async def ws_message(self, data):\n        \"\"\"处理接收到的消息\"\"\"\n\n        try:\n            event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id)\n        except Exception:\n            await self.logger.error(f'Error in wechatpad callback: {traceback.format_exc()}')\n\n        if event.__class__ in self.listeners:\n            await self.listeners[event.__class__](event, self)\n\n        return 'ok'\n\n    async def _handle_message(self, message: platform_message.MessageChain, target_id: str):\n        \"\"\"统一消息处理核心逻辑\"\"\"\n        content_list = await self.message_converter.yiri2target(message)\n        # print(content_list)\n        at_targets = [item['target'] for item in content_list if item['type'] == 'at']\n        # print(at_targets)\n        # 处理@逻辑\n        at_targets = at_targets or []\n        member_info = []\n        if at_targets:\n            member_info = self.bot.get_chatroom_member_detail(\n                target_id,\n            )['Data']['member_data']['chatroom_member_list']\n\n        # 处理消息组件\n        for msg in content_list:\n            # 文本消息处理@\n            if msg['type'] == 'text' and at_targets:\n                if 'all' in at_targets:\n                    msg['content'] = f'@所有人 {msg[\"content\"]}'\n                else:\n                    at_nick_name_list = []\n                    for member in member_info:\n                        if member['user_name'] in at_targets:\n                            at_nick_name_list.append(f'@{member[\"nick_name\"]}')\n                    msg['content'] = f'{\" \".join(at_nick_name_list)} {msg[\"content\"]}'\n\n            # 统一消息派发\n            handler_map = {\n                'text': lambda msg: self.bot.send_text_message(\n                    to_wxid=target_id, message=msg['content'], ats=['notify@all'] if 'all' in at_targets else at_targets\n                ),\n                'image': lambda msg: self.bot.send_image_message(\n                    to_wxid=target_id, img_url=msg['image'], ats=['notify@all'] if 'all' in at_targets else at_targets\n                ),\n                'WeChatEmoji': lambda msg: self.bot.send_emoji_message(\n                    to_wxid=target_id, emoji_md5=msg['emoji_md5'], emoji_size=msg['emoji_size']\n                ),\n                'voice': lambda msg: self.bot.send_voice_message(\n                    to_wxid=target_id,\n                    voice_data=msg['data'],\n                    voice_duration=msg['duration'],\n                    voice_forma=msg['forma'],\n                ),\n                'WeChatAppMsg': lambda msg: self.bot.send_app_message(\n                    to_wxid=target_id,\n                    app_message=msg['app_msg'],\n                    type=0,\n                ),\n                'at': lambda msg: None,\n            }\n\n            if handler := handler_map.get(msg['type']):\n                handler(msg)\n            else:\n                self.logger.warning(f'未处理的消息类型: {msg[\"type\"]}')\n                continue\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        \"\"\"主动发送消息\"\"\"\n        return await self._handle_message(message, target_id)\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        \"\"\"回复消息\"\"\"\n        if message_source.source_platform_object:\n            target_id = message_source.source_platform_object['from_user_name']['str']\n            return await self._handle_message(message, target_id)\n\n    async def is_muted(self, group_id: int) -> bool:\n        pass\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        self.listeners[event_type] = callback\n\n    def unregister_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        pass\n\n    async def run_async(self):\n        if not self.config['admin_key'] and not self.config['token']:\n            raise RuntimeError('无wechatpad管理密匙，请填入配置文件后重启')\n        else:\n            if self.config['token']:\n                self.bot = WeChatPadClient(self.config['wechatpad_url'], self.config['token'])\n                data = self.bot.get_login_status()\n                if data['Code'] == 300 and data['Text'] == '你已退出微信':\n                    response = requests.post(\n                        f'{self.config[\"wechatpad_url\"]}/admin/GenAuthKey1?key={self.config[\"admin_key\"]}',\n                        json={'Count': 1, 'Days': 365},\n                    )\n                    if response.status_code != 200:\n                        raise Exception(f'获取token失败: {response.text}')\n                    self.config['token'] = response.json()['Data'][0]\n\n            elif not self.config['token']:\n                response = requests.post(\n                    f'{self.config[\"wechatpad_url\"]}/admin/GenAuthKey1?key={self.config[\"admin_key\"]}',\n                    json={'Count': 1, 'Days': 365},\n                )\n                if response.status_code != 200:\n                    raise Exception(f'获取token失败: {response.text}')\n                self.config['token'] = response.json()['Data'][0]\n\n        self.bot = WeChatPadClient(self.config['wechatpad_url'], self.config['token'], logger=self.logger)\n        await self.logger.info(self.config['token'])\n        thread_1 = threading.Event()\n\n        def wechat_login_process():\n            # 不登录，这些先注释掉，避免登陆态尝试拉qrcode。\n            # login_data =self.bot.get_login_qr()\n\n            # url = login_data['Data'][\"QrCodeUrl\"]\n\n            profile = self.bot.get_profile()\n            # self.logger.info(profile)\n\n            self.bot_account_id = profile['Data']['userInfo']['nickName']['str']\n            self.config['wxid'] = profile['Data']['userInfo']['userName']['str']\n            thread_1.set()\n\n        # asyncio.create_task(wechat_login_process)\n        threading.Thread(target=wechat_login_process).start()\n\n        def connect_websocket_sync() -> None:\n            thread_1.wait()\n            uri = f'{self.config[\"wechatpad_ws\"]}/GetSyncMsg?key={self.config[\"token\"]}'\n            print(f'Connecting to WebSocket: {uri}')\n\n            def on_message(ws, message):\n                try:\n                    data = json.loads(message)\n                    # 这里需要确保ws_message是同步的，或者使用asyncio.run调用异步方法\n                    asyncio.run(self.ws_message(data))\n                except json.JSONDecodeError:\n                    self.logger.error(f'Non-JSON message: {message[:100]}...')\n\n            def on_error(ws, error):\n                self.logger.error(f'WebSocket error: {str(error)[:200]}')\n\n            def on_close(ws, close_status_code, close_msg):\n                self.logger.info('WebSocket closed, reconnecting...')\n                time.sleep(5)\n                connect_websocket_sync()  # 自动重连\n\n            def on_open(ws):\n                self.logger.info('WebSocket connected successfully!')\n\n            ws = websocket.WebSocketApp(\n                uri, on_message=on_message, on_error=on_error, on_close=on_close, on_open=on_open\n            )\n            ws.run_forever(ping_interval=60, ping_timeout=20)\n\n        # 直接调用同步版本（会阻塞）\n        # connect_websocket_sync()\n\n        # 这行代码会在WebSocket连接断开后才会执行\n        thread = threading.Thread(target=connect_websocket_sync, name='WebSocketClientThread', daemon=True)\n        thread.start()\n        self.logger.info('WebSocket client thread started')\n\n    async def kill(self) -> bool:\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/wechatpad.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: wechatpad\n  label:\n    en_US: WeChatPad\n    zh_CN: WeChatPad（个人微信ipad）\n  description:\n    en_US: WeChatPad Adapter\n    zh_CN: WeChatPad 适配器\n  icon: wechatpad.png\nspec:\n  config:\n    - name: wechatpad_url\n      label:\n        en_US: WeChatPad ERL\n        zh_CN: WeChatPad URL\n      type: string\n      required: true\n      default: \"\"\n    - name: wechatpad_ws\n      label:\n        en_US: WeChatPad_Ws\n        zh_CN: WeChatPad_Ws\n      type: string\n      required: true\n      default: \"\"\n    - name: admin_key\n      label:\n        en_US: Admin_Key\n        zh_CN: 管理员密匙\n      type: string\n      required: true\n      default: \"\"\n    - name: token\n      label:\n        en_US: Token\n        zh_CN: 令牌\n      type: string\n      required: true\n      default: \"\"\n    - name: wxid\n      label:\n        en_US: wxid\n        zh_CN: wxid\n      type: string\n      required: true\n      default: \"\"\nexecution:\n  python:\n    path: ./wechatpad.py\n    attr: WeChatPadAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/wecom.py",
    "content": "from __future__ import annotations\nimport typing\nimport asyncio\nimport traceback\n\nimport datetime\n\nfrom langbot.libs.wecom_api.api import WecomClient\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nfrom langbot.libs.wecom_api.wecomevent import WecomEvent\nfrom ...utils import image\nfrom ..logger import EventLogger\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\n\n\ndef split_string_by_bytes(text, limit=2048, encoding='utf-8'):\n    \"\"\"\n    Splits a string into a list of strings, where each part is at most 'limit' bytes.\n\n    Args:\n        text (str): The original string to split.\n        limit (int): The maximum byte size for each split part.\n        encoding (str): The encoding to use (default is 'utf-8').\n\n    Returns:\n        list: A list of split strings.\n    \"\"\"\n    # 1. Encode the entire string into bytes\n    bytes_data = text.encode(encoding)\n    total_len = len(bytes_data)\n\n    parts = []\n    start = 0\n\n    while start < total_len:\n        # 2. Determine the end index for the current chunk\n        # It shouldn't exceed the total length\n        end = min(start + limit, total_len)\n\n        # 3. Slice the byte array\n        chunk = bytes_data[start:end]\n\n        # 4. Attempt to decode the chunk\n        # Use errors='ignore' to drop any partial bytes at the end of the chunk\n        # (e.g., if a 3-byte character was cut after the 2nd byte)\n        part_str = chunk.decode(encoding, errors='ignore')\n\n        # 5. Calculate the actual byte length of the successfully decoded string\n        # This tells us exactly where the valid character boundary ended\n        part_bytes = part_str.encode(encoding)\n        part_len = len(part_bytes)\n\n        # Safety check: Prevent infinite loop if limit is too small (e.g., limit=1 for a Chinese char)\n        if part_len == 0 and end < total_len:\n            # Force advance by 1 byte to consume the un-decodable byte or raise error\n            # Here we just treat it as a part to avoid stuck loops, though it might be invalid\n            start += 1\n            continue\n\n        parts.append(part_str)\n\n        # 6. Move the start pointer by the actual length consumed\n        start += part_len\n\n    return parts\n\n\nclass WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomClient):\n        content_list = []\n\n        for msg in message_chain:\n            if type(msg) is platform_message.Plain:\n                chunks = split_string_by_bytes(msg.text)\n                content_list.extend(\n                    [\n                        {\n                            'type': 'text',\n                            'content': chunk,\n                        }\n                        for chunk in chunks\n                    ]\n                )\n            elif type(msg) is platform_message.Image:\n                content_list.append(\n                    {\n                        'type': 'image',\n                        'media_id': await bot.get_media_id(msg),\n                    }\n                )\n            elif type(msg) is platform_message.Voice:\n                content_list.append(\n                    {\n                        'type': 'voice',\n                        'media_id': await bot.get_media_id(msg),\n                    }\n                )\n            elif type(msg) is platform_message.File:\n                content_list.append(\n                    {\n                        'type': 'file',\n                        'media_id': await bot.get_media_id(msg),\n                    }\n                )\n            elif type(msg) is platform_message.Forward:\n                for node in msg.node_list:\n                    content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))\n            else:\n                content_list.append(\n                    {\n                        'type': 'text',\n                        'content': str(msg),\n                    }\n                )\n\n        return content_list\n\n    @staticmethod\n    async def target2yiri(message: str, message_id: int = -1):\n        yiri_msg_list = []\n        yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))\n\n        yiri_msg_list.append(platform_message.Plain(text=message))\n        chain = platform_message.MessageChain(yiri_msg_list)\n\n        return chain\n\n    @staticmethod\n    async def target2yiri_image(picurl: str, message_id: int = -1):\n        yiri_msg_list = []\n        yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))\n        image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)\n        yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))\n        chain = platform_message.MessageChain(yiri_msg_list)\n\n        return chain\n\n\nclass WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def yiri2target(event: platform_events.Event, bot_account_id: int, bot: WecomClient) -> WecomEvent:\n        # only for extracting user information\n\n        if type(event) is platform_events.GroupMessage:\n            pass\n\n        if type(event) is platform_events.FriendMessage:\n            payload = {\n                'MsgType': 'text',\n                'Content': '',\n                'FromUserName': event.sender.id,\n                'ToUserName': bot_account_id,\n                'CreateTime': int(datetime.datetime.now().timestamp()),\n                'AgentID': event.sender.nickname,\n            }\n            wecom_event = WecomEvent.from_payload(payload=payload)\n            if not wecom_event:\n                raise ValueError('无法从 message_data 构造 WecomEvent 对象')\n\n            return wecom_event\n\n    @staticmethod\n    async def target2yiri(event: WecomEvent):\n        \"\"\"\n        将 WecomEvent 转换为平台的 FriendMessage 对象。\n\n        Args:\n            event (WecomEvent): 企业微信事件。\n\n        Returns:\n            platform_events.FriendMessage: 转换后的 FriendMessage 对象。\n        \"\"\"\n        # 转换消息链\n        if event.type == 'text':\n            yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)\n            friend = platform_entities.Friend(\n                id=f'u{event.user_id}',\n                nickname=str(event.agent_id),\n                remark='',\n            )\n\n            return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)\n        elif event.type == 'image':\n            friend = platform_entities.Friend(\n                id=f'u{event.user_id}',\n                nickname=str(event.agent_id),\n                remark='',\n            )\n\n            yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)\n\n            return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)\n\n\nclass WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: WecomClient\n    bot_account_id: str\n    message_converter: WecomMessageConverter = WecomMessageConverter()\n    event_converter: WecomEventConverter = WecomEventConverter()\n    config: dict\n    bot_uuid: str = None\n\n    def __init__(self, config: dict, logger: EventLogger):\n        # 校验必填项\n        required_keys = [\n            'corpid',\n            'secret',\n            'token',\n            'EncodingAESKey',\n            'contacts_secret',\n        ]\n\n        missing_keys = [key for key in required_keys if key not in config]\n        if missing_keys:\n            raise Exception(f'Wecom 缺少配置项: {missing_keys}')\n\n        # 创建运行时 bot 对象，始终使用统一 webhook 模式\n        bot = WecomClient(\n            corpid=config['corpid'],\n            secret=config['secret'],\n            token=config['token'],\n            EncodingAESKey=config['EncodingAESKey'],\n            contacts_secret=config['contacts_secret'],\n            logger=logger,\n            unified_mode=True,\n            api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),\n        )\n\n        super().__init__(\n            config=config,\n            logger=logger,\n            bot=bot,\n            bot_account_id='',\n        )\n\n    def set_bot_uuid(self, bot_uuid: str):\n        \"\"\"设置 bot UUID（用于生成 webhook URL）\"\"\"\n        self.bot_uuid = bot_uuid\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)\n        content_list = await WecomMessageConverter.yiri2target(message, self.bot)\n        fixed_user_id = Wecom_event.user_id\n        # 删掉开头的u\n        fixed_user_id = fixed_user_id[1:]\n        for content in content_list:\n            if content['type'] == 'text':\n                await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])\n            elif content['type'] == 'image':\n                await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])\n            elif content['type'] == 'voice':\n                await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])\n            elif content['type'] == 'file':\n                await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        content_list = await WecomMessageConverter.yiri2target(message, self.bot)\n        parts = target_id.split('|')\n        user_id = parts[0]\n        agent_id = int(parts[1])\n        if target_type == 'person':\n            for content in content_list:\n                if content['type'] == 'text':\n                    await self.bot.send_private_msg(user_id, agent_id, content['content'])\n                if content['type'] == 'image':\n                    await self.bot.send_image(user_id, agent_id, content['media'])\n                if content['type'] == 'voice':\n                    await self.bot.send_voice(user_id, agent_id, content['media'])\n                if content['type'] == 'file':\n                    await self.bot.send_file(user_id, agent_id, content['media'])\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        async def on_message(event: WecomEvent):\n            self.bot_account_id = event.receiver_id\n            try:\n                return await callback(await self.event_converter.target2yiri(event), self)\n            except Exception:\n                await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')\n\n        if event_type == platform_events.FriendMessage:\n            self.bot.on_message('text')(on_message)\n            self.bot.on_message('image')(on_message)\n        elif event_type == platform_events.GroupMessage:\n            pass\n\n    async def handle_unified_webhook(self, bot_uuid: str, path: str, request):\n        \"\"\"处理统一 webhook 请求。\n\n        Args:\n            bot_uuid: Bot 的 UUID\n            path: 子路径（如果有的话）\n            request: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self.bot.handle_unified_webhook(request)\n\n    async def run_async(self):\n        async def keep_alive():\n            while True:\n                await asyncio.sleep(1)\n\n        await keep_alive()\n\n    async def kill(self) -> bool:\n        return False\n\n    async def unregister_listener(\n        self,\n        event_type: type,\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        return super().unregister_listener(event_type, callback)\n\n    async def is_muted(self, group_id: int) -> bool:\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/wecom.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: wecom\n  label:\n    en_US: WeCom\n    zh_Hans: 企业微信\n  description:\n    en_US: WeCom Adapter\n    zh_Hans: 企业微信适配器，请查看文档了解使用方式\n  icon: wecom.png\nspec:\n  config:\n    - name: corpid\n      label:\n        en_US: Corpid\n        zh_Hans: 企业ID\n      type: string\n      required: true\n      default: \"\"\n    - name: secret\n      label:\n        en_US: Secret\n        zh_Hans: 密钥 (Secret)\n      type: string\n      required: true\n      default: \"\"\n    - name: token\n      label:\n        en_US: Token\n        zh_Hans: 令牌 (Token)\n      type: string\n      required: true\n      default: \"\"\n    - name: EncodingAESKey\n      label:\n        en_US: EncodingAESKey\n        zh_Hans: 消息加解密密钥 (EncodingAESKey)\n      type: string\n      required: true\n      default: \"\"\n    - name: contacts_secret\n      label:\n        en_US: Contacts Secret\n        zh_Hans: 通讯录密钥\n      type: string\n      required: true\n      default: \"\"\n    - name: api_base_url\n      label:\n        en_US: API Base URL\n        zh_Hans: API 基础 URL\n      description:\n        en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.\n        zh_Hans: 可选，若您部署在内网环境并通过反向代理访问企业微信 API，可根据文档填写此项\n      type: string\n      required: false\n      default: \"https://qyapi.weixin.qq.com/cgi-bin\"\nexecution:\n  python:\n    path: ./wecom.py\n    attr: WecomAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/wecombot.py",
    "content": "from __future__ import annotations\nimport typing\nimport asyncio\nimport traceback\n\nimport datetime\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nfrom ..logger import EventLogger\nfrom langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent\nfrom langbot.libs.wecom_ai_bot_api.api import WecomBotClient\nfrom langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient\n\n\nclass WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain):\n        content = ''\n        for msg in message_chain:\n            if type(msg) is platform_message.Plain:\n                content += msg.text\n        return content\n\n    @staticmethod\n    async def target2yiri(event: WecomBotEvent):\n        yiri_msg_list = []\n        if event.type == 'group':\n            yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))\n        yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now()))\n\n        if event.content:\n            yiri_msg_list.append(platform_message.Plain(text=event.content))\n\n        images = []\n        if event.images:\n            images.extend([img for img in event.images if img])\n        if not images and event.picurl:\n            images.append(event.picurl)\n        for image_base64 in images:\n            if image_base64:\n                yiri_msg_list.append(platform_message.Image(base64=image_base64))\n\n        file_info = event.file or {}\n        if file_info:\n            file_url = (\n                file_info.get('download_url')\n                or file_info.get('url')\n                or file_info.get('fileurl')\n                or file_info.get('path')\n            )\n            file_base64 = file_info.get('base64')\n            file_name = file_info.get('filename') or file_info.get('name')\n            file_size = file_info.get('filesize') or file_info.get('size')\n            file_data = file_url or file_base64\n            if file_data or file_name:\n                file_kwargs = {}\n                if file_data:\n                    file_kwargs['url'] = file_data\n                if file_name:\n                    file_kwargs['name'] = file_name\n                if file_size is not None:\n                    file_kwargs['size'] = file_size\n                try:\n                    yiri_msg_list.append(platform_message.File(**file_kwargs))\n                except Exception:\n                    # 兜底\n                    yiri_msg_list.append(platform_message.Unknown(text='[file message unsupported]'))\n\n        voice_info = event.voice or {}\n        if voice_info:\n            voice_payload = voice_info.get('base64') or voice_info.get('url')\n            if voice_payload:\n                if voice_info.get('base64') and not voice_payload.startswith('data:'):\n                    voice_payload = f'data:audio/mpeg;base64,{voice_info.get(\"base64\")}'\n                try:\n                    yiri_msg_list.append(platform_message.Voice(base64=voice_payload))\n                except Exception:\n                    try:\n                        voice_kwargs = {'url': voice_payload}\n                        voice_name = voice_info.get('filename') or voice_info.get('name')\n                        voice_size = voice_info.get('filesize') or voice_info.get('size')\n                        if voice_name:\n                            voice_kwargs['name'] = voice_name\n                        if voice_size is not None:\n                            voice_kwargs['size'] = voice_size\n                        yiri_msg_list.append(platform_message.File(**voice_kwargs))\n                    except Exception:\n                        yiri_msg_list.append(platform_message.Unknown(text='[voice message unsupported]'))\n\n        video_info = event.video or {}\n        if video_info:\n            video_payload = (\n                video_info.get('base64')\n                or video_info.get('url')\n                or video_info.get('download_url')\n                or video_info.get('fileurl')\n            )\n            if video_payload:\n                video_kwargs = {'url': video_payload}\n                video_name = video_info.get('filename') or video_info.get('name')\n                video_size = video_info.get('filesize') or video_info.get('size')\n                if video_name:\n                    video_kwargs['name'] = video_name\n                if video_size is not None:\n                    video_kwargs['size'] = video_size\n                try:\n                    # 没有专门的视频类型，沿用 File 传递给上层\n                    yiri_msg_list.append(platform_message.File(**video_kwargs))\n                except Exception:\n                    yiri_msg_list.append(platform_message.Unknown(text='[video message unsupported]'))\n\n        if event.msgtype == 'link' and event.link:\n            link = event.link\n            summary = '\\n'.join(\n                filter(\n                    None,\n                    [link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')],\n                )\n            )\n            if summary:\n                yiri_msg_list.append(platform_message.Plain(text=summary))\n\n        has_content_element = any(\n            not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list\n        )\n        if not has_content_element:\n            fallback_type = event.msgtype or 'unknown'\n            yiri_msg_list.append(platform_message.Unknown(text=f'[unsupported wecom msgtype: {fallback_type}]'))\n        chain = platform_message.MessageChain(yiri_msg_list)\n\n        return chain\n\n\nclass WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def yiri2target(event: platform_events.MessageEvent):\n        return event.source_platform_object\n\n    @staticmethod\n    async def target2yiri(event: WecomBotEvent):\n        message_chain = await WecomBotMessageConverter.target2yiri(event)\n        if event.type == 'single':\n            return platform_events.FriendMessage(\n                sender=platform_entities.Friend(\n                    id=event.userid,\n                    nickname=event.username,\n                    remark='',\n                ),\n                message_chain=message_chain,\n                time=datetime.datetime.now().timestamp(),\n                source_platform_object=event,\n            )\n        elif event.type == 'group':\n            try:\n                sender = platform_entities.GroupMember(\n                    id=event.userid,\n                    permission='MEMBER',\n                    member_name=event.username,\n                    group=platform_entities.Group(\n                        id=str(event.chatid),\n                        name=event.chatname,\n                        permission=platform_entities.Permission.Member,\n                    ),\n                    special_title='',\n                )\n                time = datetime.datetime.now().timestamp()\n                return platform_events.GroupMessage(\n                    sender=sender,\n                    message_chain=message_chain,\n                    time=time,\n                    source_platform_object=event,\n                )\n            except Exception:\n                print(traceback.format_exc())\n\n\nclass WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: typing.Union[WecomBotClient, WecomBotWsClient]\n    bot_account_id: str\n    message_converter: WecomBotMessageConverter = WecomBotMessageConverter()\n    event_converter: WecomBotEventConverter = WecomBotEventConverter()\n    config: dict\n    bot_uuid: str = None\n    _ws_mode: bool = False\n\n    def __init__(self, config: dict, logger: EventLogger):\n        enable_webhook = config.get('enable-webhook', False)\n\n        if not enable_webhook:\n            bot = WecomBotWsClient(\n                bot_id=config['BotId'],\n                secret=config['Secret'],\n                logger=logger,\n                encoding_aes_key=config.get('EncodingAESKey', ''),\n            )\n            ws_mode = True\n        else:\n            # Webhook callback mode\n            required_keys = ['Token', 'EncodingAESKey', 'Corpid']\n            missing_keys = [key for key in required_keys if key not in config or not config[key]]\n            if missing_keys:\n                raise Exception(f'WecomBot webhook mode missing config: {missing_keys}')\n\n            bot = WecomBotClient(\n                Token=config['Token'],\n                EnCodingAESKey=config['EncodingAESKey'],\n                Corpid=config['Corpid'],\n                logger=logger,\n                unified_mode=True,\n            )\n            ws_mode = False\n\n        bot_account_id = config.get('BotId', '')\n\n        super().__init__(\n            config=config,\n            logger=logger,\n            bot=bot,\n            bot_account_id=bot_account_id,\n        )\n        self._ws_mode = ws_mode\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        content = await self.message_converter.yiri2target(message)\n        if self._ws_mode:\n            event = message_source.source_platform_object\n            req_id = event.get('req_id', '')\n            if req_id:\n                await self.bot.reply_text(req_id, content)\n            else:\n                await self.bot.set_message(event.message_id, content)\n        else:\n            await self.bot.set_message(message_source.source_platform_object.message_id, content)\n\n    async def reply_message_chunk(\n        self,\n        message_source: platform_events.MessageEvent,\n        bot_message,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n        is_final: bool = False,\n    ):\n        content = await self.message_converter.yiri2target(message)\n        msg_id = message_source.source_platform_object.message_id\n\n        if self._ws_mode:\n            success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)\n            if not success and is_final:\n                event = message_source.source_platform_object\n                req_id = event.get('req_id', '')\n                if req_id:\n                    await self.bot.reply_text(req_id, content)\n            return {'stream': success}\n        else:\n            success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)\n            if not success and is_final:\n                await self.bot.set_message(msg_id, content)\n            return {'stream': success}\n\n    async def is_stream_output_supported(self) -> bool:\n        \"\"\"智能机器人侧默认开启流式能力。\n\n        Returns:\n            bool: 恒定返回 True。\n\n        Example:\n            流水线执行阶段会调用此方法以确认是否启用流式。\"\"\"\n        return True\n\n    async def send_message(self, target_type, target_id, message):\n        if self._ws_mode:\n            content = await self.message_converter.yiri2target(message)\n            await self.bot.send_message(target_id, content)\n        else:\n            pass\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        async def on_message(event: WecomBotEvent):\n            try:\n                return await callback(await self.event_converter.target2yiri(event), self)\n            except Exception:\n                await self.logger.error(f'Error in wecombot callback: {traceback.format_exc()}')\n                print(traceback.format_exc())\n\n        try:\n            if event_type == platform_events.FriendMessage:\n                self.bot.on_message('single')(on_message)\n            elif event_type == platform_events.GroupMessage:\n                self.bot.on_message('group')(on_message)\n        except Exception:\n            print(traceback.format_exc())\n\n    def set_bot_uuid(self, bot_uuid: str):\n        \"\"\"设置 bot UUID（用于生成 webhook URL）\"\"\"\n        self.bot_uuid = bot_uuid\n\n    async def handle_unified_webhook(self, bot_uuid: str, path: str, request):\n        if self._ws_mode:\n            return None\n        return await self.bot.handle_unified_webhook(request)\n\n    async def run_async(self):\n        if self._ws_mode:\n            await self.bot.connect()\n        else:\n\n            async def keep_alive():\n                while True:\n                    await asyncio.sleep(1)\n\n            await keep_alive()\n\n    async def kill(self) -> bool:\n        if self._ws_mode:\n            await self.bot.disconnect()\n            return True\n        return False\n\n    async def unregister_listener(\n        self,\n        event_type: type,\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        return super().unregister_listener(event_type, callback)\n\n    async def is_muted(self, group_id: int) -> bool:\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/wecombot.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: wecombot\n  label:\n    en_US: WeComBot\n    zh_Hans: 企业微信智能机器人\n  description:\n    en_US: WeComBot Adapter\n    zh_Hans: 企业微信智能机器人适配器，请查看文档了解使用方式\n  icon: wecombot.png\nspec:\n  config:\n    - name: BotId\n      label:\n        en_US: BotId\n        zh_Hans: 机器人ID (BotId)\n      type: string\n      required: true\n      default: \"\"\n    - name: enable-webhook\n      label:\n        en_US: Enable Webhook Mode\n        zh_Hans: 启用Webhook模式\n      description:\n        en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode\n        zh_Hans: 如果启用，机器人将使用 Webhook 模式接收消息。否则，将使用 WS 长连接模式\n      type: boolean\n      required: true\n      default: false\n    - name: Secret\n      label:\n        en_US: Secret\n        zh_Hans: 机器人密钥 (Secret)\n      description:\n        en_US: Required for WebSocket long connection mode\n        zh_Hans: 使用 WS 长连接模式时必填\n      type: string\n      required: false\n      default: \"\"\n    - name: Corpid\n      label:\n        en_US: Corpid\n        zh_Hans: 企业ID\n      description:\n        en_US: Required for Webhook mode\n        zh_Hans: 使用 Webhook 模式时必填\n      type: string\n      required: false\n      default: \"\"\n    - name: Token\n      label:\n        en_US: Token\n        zh_Hans: 令牌 (Token)\n      description:\n        en_US: Required for Webhook mode\n        zh_Hans: 使用 Webhook 模式时必填\n      type: string\n      required: false\n      default: \"\"\n    - name: EncodingAESKey\n      label:\n        en_US: EncodingAESKey\n        zh_Hans: 消息加解密密钥 (EncodingAESKey)\n      description:\n        en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)\n        zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选（用于文件解密）\n      type: string\n      required: false\n      default: \"\"\nexecution:\n  python:\n    path: ./wecombot.py\n    attr: WecomBotAdapter\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/wecomcs.py",
    "content": "from __future__ import annotations\nimport typing\nimport asyncio\nimport traceback\n\nimport datetime\nimport pydantic\n\nfrom langbot.libs.wecom_customer_service_api.api import WecomCSClient\nimport langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter\nfrom langbot.libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nfrom langbot_plugin.api.entities.builtin.command import errors as command_errors\nimport langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger\n\n\nclass WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):\n    @staticmethod\n    async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomCSClient):\n        content_list = []\n\n        for msg in message_chain:\n            if type(msg) is platform_message.Plain:\n                content_list.append(\n                    {\n                        'type': 'text',\n                        'content': msg.text,\n                    }\n                )\n            elif type(msg) is platform_message.Image:\n                content_list.append(\n                    {\n                        'type': 'image',\n                        'media_id': await bot.get_media_id(msg),\n                    }\n                )\n            elif type(msg) is platform_message.Forward:\n                for node in msg.node_list:\n                    content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))\n            else:\n                content_list.append(\n                    {\n                        'type': 'text',\n                        'content': str(msg),\n                    }\n                )\n\n        return content_list\n\n    @staticmethod\n    async def target2yiri(message: str, message_id: int = -1):\n        yiri_msg_list = []\n        yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))\n\n        yiri_msg_list.append(platform_message.Plain(text=message))\n        chain = platform_message.MessageChain(yiri_msg_list)\n\n        return chain\n\n    @staticmethod\n    async def target2yiri_image(picurl: str, message_id: int = -1):\n        yiri_msg_list = []\n        yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))\n        yiri_msg_list.append(platform_message.Image(base64=picurl))\n        chain = platform_message.MessageChain(yiri_msg_list)\n\n        return chain\n\n\nclass WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):\n    @staticmethod\n    async def yiri2target(event: platform_events.Event, bot_account_id: int, bot: WecomCSClient) -> WecomCSEvent:\n        # only for extracting user information\n\n        if type(event) is platform_events.GroupMessage:\n            pass\n\n        if type(event) is platform_events.FriendMessage:\n            return event.source_platform_object\n\n    @staticmethod\n    async def target2yiri(event: WecomCSEvent, bot: WecomCSClient = None):\n        \"\"\"\n        将 WecomEvent 转换为平台的 FriendMessage 对象。\n\n        Args:\n            event (WecomEvent): 企业微信客服事件。\n            bot (WecomCSClient): 企业微信客服客户端，用于获取用户信息。\n\n        Returns:\n            platform_events.FriendMessage: 转换后的 FriendMessage 对象。\n        \"\"\"\n        # Try to get customer nickname from WeChat API\n        nickname = str(event.user_id)\n        if bot and event.user_id:\n            try:\n                customer_info = await bot.get_customer_info(event.user_id)\n                if customer_info and customer_info.get('nickname'):\n                    nickname = customer_info.get('nickname')\n            except Exception:\n                pass  # Fall back to user_id as nickname\n\n        # 转换消息链\n        if event.type == 'text':\n            yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)\n            friend = platform_entities.Friend(\n                id=f'u{event.user_id}',\n                nickname=nickname,\n                remark='',\n            )\n\n            return platform_events.FriendMessage(\n                sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event\n            )\n        elif event.type == 'image':\n            friend = platform_entities.Friend(\n                id=f'u{event.user_id}',\n                nickname=nickname,\n                remark='',\n            )\n\n            yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)\n\n            return platform_events.FriendMessage(\n                sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event\n            )\n\n\nclass WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):\n    bot: WecomCSClient = pydantic.Field(exclude=True)\n    message_converter: WecomMessageConverter = WecomMessageConverter()\n    event_converter: WecomEventConverter = WecomEventConverter()\n    bot_uuid: str = None\n\n    def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):\n        required_keys = [\n            'corpid',\n            'secret',\n            'token',\n            'EncodingAESKey',\n        ]\n        missing_keys = [key for key in required_keys if key not in config]\n        if missing_keys:\n            raise command_errors.ParamNotEnoughError('企业微信客服缺少相关配置项，请查看文档或联系管理员')\n\n        bot = WecomCSClient(\n            corpid=config['corpid'],\n            secret=config['secret'],\n            token=config['token'],\n            EncodingAESKey=config['EncodingAESKey'],\n            logger=logger,\n            unified_mode=True,\n            api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),\n        )\n\n        super().__init__(\n            config=config,\n            logger=logger,\n            bot_account_id='',\n            listeners={},\n            bot=bot,\n        )\n\n    async def reply_message(\n        self,\n        message_source: platform_events.MessageEvent,\n        message: platform_message.MessageChain,\n        quote_origin: bool = False,\n    ):\n        Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)\n        content_list = await WecomMessageConverter.yiri2target(message, self.bot)\n\n        for content in content_list:\n            if content['type'] == 'text':\n                await self.bot.send_text_msg(\n                    open_kfid=Wecom_event.receiver_id,\n                    external_userid=Wecom_event.user_id,\n                    msgid=Wecom_event.message_id,\n                    content=content['content'],\n                )\n\n    async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):\n        pass\n\n    def set_bot_uuid(self, bot_uuid: str):\n        \"\"\"设置 bot UUID（用于生成 webhook URL）\"\"\"\n        self.bot_uuid = bot_uuid\n\n    def register_listener(\n        self,\n        event_type: typing.Type[platform_events.Event],\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        async def on_message(event: WecomCSEvent):\n            self.bot_account_id = event.receiver_id\n            try:\n                return await callback(await self.event_converter.target2yiri(event, self.bot), self)\n            except Exception:\n                await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')\n\n        if event_type == platform_events.FriendMessage:\n            self.bot.on_message('text')(on_message)\n            self.bot.on_message('image')(on_message)\n        elif event_type == platform_events.GroupMessage:\n            pass\n\n    async def handle_unified_webhook(self, bot_uuid: str, path: str, request):\n        \"\"\"处理统一 webhook 请求。\n\n        Args:\n            bot_uuid: Bot 的 UUID\n            path: 子路径（如果有的话）\n            request: Quart Request 对象\n\n        Returns:\n            响应数据\n        \"\"\"\n        return await self.bot.handle_unified_webhook(request)\n\n    async def run_async(self):\n        # 统一 webhook 模式下，不启动独立的 Quart 应用\n        # 保持运行但不启动独立端口\n\n        async def keep_alive():\n            while True:\n                await asyncio.sleep(1)\n\n        await keep_alive()\n\n    async def kill(self) -> bool:\n        return False\n\n    async def is_muted(self, group_id: int) -> bool:\n        return False\n\n    async def unregister_listener(\n        self,\n        event_type: type,\n        callback: typing.Callable[\n            [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None\n        ],\n    ):\n        return super().unregister_listener(event_type, callback)\n"
  },
  {
    "path": "src/langbot/pkg/platform/sources/wecomcs.yaml",
    "content": "apiVersion: v1\nkind: MessagePlatformAdapter\nmetadata:\n  name: wecomcs\n  label:\n    en_US: WeComCustomerService\n    zh_Hans: 企业微信客服\n  description:\n    en_US: WeComCSAdapter\n    zh_Hans: 企业微信客服适配器\n  icon: wecom.png\nspec:\n  config:\n    - name: corpid\n      label:\n        en_US: Corpid\n        zh_Hans: 企业ID\n      type: string\n      required: true\n      default: \"\"\n    - name: secret\n      label:\n        en_US: Secret\n        zh_Hans: 密钥\n      type: string\n      required: true\n      default: \"\"\n    - name: token\n      label:\n        en_US: Token\n        zh_Hans: 令牌\n      type: string\n      required: true\n      default: \"\"\n    - name: EncodingAESKey\n      label:\n        en_US: EncodingAESKey\n        zh_Hans: 消息加解密密钥\n      type: string\n      required: true\n      default: \"\"\n    - name: api_base_url\n      label:\n        en_US: API Base URL\n        zh_Hans: API 基础 URL\n      description:\n        en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.\n        zh_Hans: 可选，若您部署在内网环境并通过反向代理访问企业微信 API，可根据文档修改此项\n      type: string\n      required: false\n      default: \"https://qyapi.weixin.qq.com/cgi-bin\"\nexecution:\n  python:\n    path: ./wecomcs.py\n    attr: WecomCSAdapter"
  },
  {
    "path": "src/langbot/pkg/platform/webhook_pusher.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport logging\nimport aiohttp\n\nfrom langbot.pkg.utils import httpclient\nimport uuid\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from ..core import app\n\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\n\n\nclass WebhookPusher:\n    \"\"\"Push bot events to configured webhooks\"\"\"\n\n    ap: app.Application\n    logger: logging.Logger\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        self.logger = self.ap.logger\n\n    async def push_person_message(self, event: platform_events.FriendMessage, bot_uuid: str, adapter_name: str) -> bool:\n        \"\"\"Push person message event to webhooks\n\n        Returns:\n            bool: True if any webhook responded with skip_pipeline=true, False otherwise\n        \"\"\"\n        try:\n            webhooks = await self.ap.webhook_service.get_enabled_webhooks()\n            if not webhooks:\n                return False\n\n            # Build payload\n            payload = {\n                'uuid': str(uuid.uuid4()),  # unique id for the event\n                'event_type': 'bot.person_message',\n                'data': {\n                    'bot_uuid': bot_uuid,\n                    'adapter_name': adapter_name,\n                    'sender': {\n                        'id': str(event.sender.id),\n                        'name': getattr(event.sender, 'name', ''),\n                    },\n                    'message': event.message_chain.model_dump(),\n                    'timestamp': event.time if hasattr(event, 'time') else None,\n                },\n            }\n\n            # Push to all webhooks asynchronously\n            tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks]\n            results = await asyncio.gather(*tasks, return_exceptions=True)\n\n            # Check if any webhook responded with skip_pipeline=true\n            for result in results:\n                if isinstance(result, dict) and result.get('skip_pipeline') is True:\n                    self.logger.info('Webhook responded with skip_pipeline=true, skipping pipeline for person message')\n                    return True\n\n            return False\n\n        except Exception as e:\n            self.logger.error(f'Failed to push person message to webhooks: {e}')\n            return False\n\n    async def push_group_message(self, event: platform_events.GroupMessage, bot_uuid: str, adapter_name: str) -> bool:\n        \"\"\"Push group message event to webhooks\n\n        Returns:\n            bool: True if any webhook responded with skip_pipeline=true, False otherwise\n        \"\"\"\n        try:\n            webhooks = await self.ap.webhook_service.get_enabled_webhooks()\n            if not webhooks:\n                return False\n\n            # Build payload\n            payload = {\n                'uuid': str(uuid.uuid4()),  # unique id for the event\n                'event_type': 'bot.group_message',\n                'data': {\n                    'bot_uuid': bot_uuid,\n                    'adapter_name': adapter_name,\n                    'group': {\n                        'id': str(event.group.id),\n                        'name': getattr(event.group, 'name', ''),\n                    },\n                    'sender': {\n                        'id': str(event.sender.id),\n                        'name': getattr(event.sender, 'name', ''),\n                    },\n                    'message': event.message_chain.model_dump(),\n                    'timestamp': event.time if hasattr(event, 'time') else None,\n                },\n            }\n\n            # Push to all webhooks asynchronously\n            tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks]\n            results = await asyncio.gather(*tasks, return_exceptions=True)\n\n            # Check if any webhook responded with skip_pipeline=true\n            for result in results:\n                if isinstance(result, dict) and result.get('skip_pipeline') is True:\n                    self.logger.info('Webhook responded with skip_pipeline=true, skipping pipeline for group message')\n                    return True\n\n            return False\n\n        except Exception as e:\n            self.logger.error(f'Failed to push group message to webhooks: {e}')\n            return False\n\n    async def _push_to_webhook(self, url: str, payload: dict) -> dict | None:\n        \"\"\"Push payload to a single webhook URL\n\n        Returns:\n            dict | None: The response JSON if successful, None otherwise\n        \"\"\"\n        try:\n            session = httpclient.get_session()\n            async with session.post(\n                url,\n                json=payload,\n                headers={'Content-Type': 'application/json'},\n                timeout=aiohttp.ClientTimeout(total=15),\n            ) as response:\n                if response.status >= 400:\n                    self.logger.warning(f'Webhook {url} returned status {response.status}')\n                    return None\n                else:\n                    self.logger.debug(f'Successfully pushed to webhook {url}')\n                    try:\n                        return await response.json()\n                    except Exception as json_error:\n                        self.logger.debug(f'Failed to parse JSON response from webhook {url}: {json_error}')\n                        return None\n        except asyncio.TimeoutError:\n            self.logger.warning(f'Timeout pushing to webhook {url}')\n            return None\n        except Exception as e:\n            self.logger.warning(f'Error pushing to webhook {url}: {e}')\n            return None\n"
  },
  {
    "path": "src/langbot/pkg/plugin/__init__.py",
    "content": "\"\"\"插件支持包\n\n包含插件基类、插件宿主以及部分API接口\n\"\"\"\n"
  },
  {
    "path": "src/langbot/pkg/plugin/connector.py",
    "content": "# For connect to plugin runtime.\nfrom __future__ import annotations\n\nimport asyncio\nfrom typing import Any\nimport typing\nimport os\nimport sys\nimport httpx\nimport sqlalchemy\nfrom async_lru import alru_cache\nfrom langbot_plugin.api.entities.builtin.pipeline.query import provider_session\n\nfrom ..core import app\nfrom . import handler\nfrom ..utils import platform\nfrom langbot_plugin.runtime.io.controllers.stdio import (\n    client as stdio_client_controller,\n)\nfrom langbot_plugin.runtime.io.controllers.ws import client as ws_client_controller\nfrom langbot_plugin.api.entities import events\nfrom langbot_plugin.api.entities import context\nimport langbot_plugin.runtime.io.connection as base_connection\nfrom langbot_plugin.api.definition.components.manifest import ComponentManifest\nfrom langbot_plugin.api.entities.builtin.command import (\n    context as command_context,\n    errors as command_errors,\n)\nfrom langbot_plugin.runtime.plugin.mgr import PluginInstallSource\nfrom ..core import taskmgr\nfrom ..entity.persistence import plugin as persistence_plugin\n\n\nclass PluginRuntimeConnector:\n    \"\"\"Plugin runtime connector\"\"\"\n\n    ap: app.Application\n\n    handler: handler.RuntimeConnectionHandler\n\n    handler_task: asyncio.Task\n\n    heartbeat_task: asyncio.Task | None = None\n\n    stdio_client_controller: stdio_client_controller.StdioClientController\n\n    ctrl: stdio_client_controller.StdioClientController | ws_client_controller.WebSocketClientController\n\n    runtime_subprocess_on_windows: asyncio.subprocess.Process | None = None\n\n    runtime_subprocess_on_windows_task: asyncio.Task | None = None\n\n    runtime_disconnect_callback: typing.Callable[\n        [PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]\n    ]\n\n    is_enable_plugin: bool = True\n    \"\"\"Mark if the plugin system is enabled\"\"\"\n\n    def __init__(\n        self,\n        ap: app.Application,\n        runtime_disconnect_callback: typing.Callable[\n            [PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]\n        ],\n    ):\n        self.ap = ap\n        self.runtime_disconnect_callback = runtime_disconnect_callback\n        self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)\n\n    async def heartbeat_loop(self):\n        while True:\n            await asyncio.sleep(20)\n            try:\n                await self.ping_plugin_runtime()\n                self.ap.logger.debug('Heartbeat to plugin runtime success.')\n            except Exception as e:\n                self.ap.logger.debug(f'Failed to heartbeat to plugin runtime: {e}')\n\n    async def initialize(self):\n        if not self.is_enable_plugin:\n            self.ap.logger.info('Plugin system is disabled.')\n            return\n\n        async def new_connection_callback(connection: base_connection.Connection):\n            async def disconnect_callback(\n                rchandler: handler.RuntimeConnectionHandler,\n            ) -> bool:\n                if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():\n                    self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...')\n                    await self.runtime_disconnect_callback(self)\n                    return False\n                else:\n                    self.ap.logger.error(\n                        'Disconnected from plugin runtime, cannot automatically reconnect while LangBot connects to plugin runtime via stdio, please restart LangBot.'\n                    )\n                    return False\n\n            self.handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)\n\n            self.handler_task = asyncio.create_task(self.handler.run())\n            _ = await self.handler.ping()\n            self.ap.logger.info('Connected to plugin runtime.')\n            await self.handler_task\n\n        task: asyncio.Task | None = None\n\n        if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():  # use websocket\n            self.ap.logger.info('use websocket to connect to plugin runtime')\n            ws_url = self.ap.instance_config.data.get('plugin', {}).get(\n                'runtime_ws_url', 'ws://langbot_plugin_runtime:5400/control/ws'\n            )\n\n            async def make_connection_failed_callback(\n                ctrl: ws_client_controller.WebSocketClientController,\n                exc: Exception = None,\n            ) -> None:\n                if exc is not None:\n                    self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')\n                else:\n                    self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}), trying to reconnect...')\n                await self.runtime_disconnect_callback(self)\n\n            self.ctrl = ws_client_controller.WebSocketClientController(\n                ws_url=ws_url,\n                make_connection_failed_callback=make_connection_failed_callback,\n            )\n            task = self.ctrl.run(new_connection_callback)\n        elif platform.get_platform() == 'win32':\n            # Due to Windows's lack of supports for both stdio and subprocess:\n            # See also: https://docs.python.org/zh-cn/3.13/library/asyncio-platforms.html\n            # We have to launch runtime via cmd but communicate via ws.\n            self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')\n\n            if self.runtime_subprocess_on_windows is None:  # only launch once\n                python_path = sys.executable\n                env = os.environ.copy()\n                self.runtime_subprocess_on_windows = await asyncio.create_subprocess_exec(\n                    python_path,\n                    '-m',\n                    'langbot_plugin.cli.__init__',\n                    'rt',\n                    env=env,\n                )\n\n                # hold the process\n                self.runtime_subprocess_on_windows_task = asyncio.create_task(self.runtime_subprocess_on_windows.wait())\n\n            ws_url = 'ws://localhost:5400/control/ws'\n\n            async def make_connection_failed_callback(\n                ctrl: ws_client_controller.WebSocketClientController,\n                exc: Exception = None,\n            ) -> None:\n                if exc is not None:\n                    self.ap.logger.error(f'(windows) Failed to connect to plugin runtime({ws_url}): {exc}')\n                else:\n                    self.ap.logger.error(\n                        f'(windows) Failed to connect to plugin runtime({ws_url}), trying to reconnect...'\n                    )\n                await self.runtime_disconnect_callback(self)\n\n            self.ctrl = ws_client_controller.WebSocketClientController(\n                ws_url=ws_url,\n                make_connection_failed_callback=make_connection_failed_callback,\n            )\n            task = self.ctrl.run(new_connection_callback)\n\n        else:  # stdio\n            self.ap.logger.info('use stdio to connect to plugin runtime')\n            # cmd: lbp rt -s\n            python_path = sys.executable\n            env = os.environ.copy()\n            self.ctrl = stdio_client_controller.StdioClientController(\n                command=python_path,\n                args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],\n                env=env,\n            )\n            task = self.ctrl.run(new_connection_callback)\n\n        if self.heartbeat_task is None:\n            self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())\n\n        asyncio.create_task(task)\n\n    async def initialize_plugins(self):\n        pass\n\n    async def ping_plugin_runtime(self):\n        if not hasattr(self, 'handler'):\n            raise Exception('Plugin runtime is not connected')\n\n        return await self.handler.ping()\n\n    async def install_plugin(\n        self,\n        install_source: PluginInstallSource,\n        install_info: dict[str, Any],\n        task_context: taskmgr.TaskContext | None = None,\n    ):\n        if install_source == PluginInstallSource.LOCAL:\n            # transfer file before install\n            file_bytes = install_info['plugin_file']\n            file_key = await self.handler.send_file(file_bytes, 'lbpkg')\n            install_info['plugin_file_key'] = file_key\n            del install_info['plugin_file']\n            self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')\n        elif install_source == PluginInstallSource.GITHUB:\n            # download and transfer file\n            try:\n                async with httpx.AsyncClient(\n                    trust_env=True,\n                    follow_redirects=True,\n                    timeout=20,\n                ) as client:\n                    response = await client.get(\n                        install_info['asset_url'],\n                    )\n                    response.raise_for_status()\n                    file_bytes = response.content\n                    file_key = await self.handler.send_file(file_bytes, 'lbpkg')\n                    install_info['plugin_file_key'] = file_key\n                    self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')\n            except Exception as e:\n                self.ap.logger.error(f'Failed to download file from GitHub: {e}')\n                raise Exception(f'Failed to download file from GitHub: {e}')\n\n        async for ret in self.handler.install_plugin(install_source.value, install_info):\n            current_action = ret.get('current_action', None)\n            if current_action is not None:\n                if task_context is not None:\n                    task_context.set_current_action(current_action)\n\n            trace = ret.get('trace', None)\n            if trace is not None:\n                if task_context is not None:\n                    task_context.trace(trace)\n\n    async def upgrade_plugin(\n        self,\n        plugin_author: str,\n        plugin_name: str,\n        task_context: taskmgr.TaskContext | None = None,\n    ) -> dict[str, Any]:\n        async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name):\n            current_action = ret.get('current_action', None)\n            if current_action is not None:\n                if task_context is not None:\n                    task_context.set_current_action(current_action)\n\n            trace = ret.get('trace', None)\n            if trace is not None:\n                if task_context is not None:\n                    task_context.trace(trace)\n\n    async def delete_plugin(\n        self,\n        plugin_author: str,\n        plugin_name: str,\n        delete_data: bool = False,\n        task_context: taskmgr.TaskContext | None = None,\n    ) -> dict[str, Any]:\n        async for ret in self.handler.delete_plugin(plugin_author, plugin_name):\n            current_action = ret.get('current_action', None)\n            if current_action is not None:\n                if task_context is not None:\n                    task_context.set_current_action(current_action)\n\n            trace = ret.get('trace', None)\n            if trace is not None:\n                if task_context is not None:\n                    task_context.trace(trace)\n\n        # Clean up plugin settings and binary storage if requested\n        if delete_data:\n            if task_context is not None:\n                task_context.trace('Cleaning up plugin configuration and storage...')\n            await self.handler.cleanup_plugin_data(plugin_author, plugin_name)\n\n    async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:\n        \"\"\"List plugins, optionally filtered by component kinds.\n\n        Args:\n            component_kinds: Optional list of component kinds to filter by.\n                           If provided, only plugins that contain at least one\n                           component of the specified kinds will be returned.\n                           E.g., ['Command', 'EventListener', 'Tool'] for pipeline-related plugins.\n        \"\"\"\n        if not self.is_enable_plugin:\n            return []\n\n        plugins = await self.handler.list_plugins()\n\n        # Filter plugins by component kinds if specified\n        if component_kinds is not None:\n            filtered_plugins = []\n            for plugin in plugins:\n                components = plugin.get('components', [])\n                has_matching_component = False\n                for component in components:\n                    component_kind = component.get('manifest', {}).get('manifest', {}).get('kind', '')\n                    if component_kind in component_kinds:\n                        has_matching_component = True\n                        break\n                if has_matching_component:\n                    filtered_plugins.append(plugin)\n            plugins = filtered_plugins\n\n        # Sort plugins: debug plugins first, then by installation time (newest first)\n        # Get installation timestamps from database in a single query\n        plugin_timestamps = {}\n\n        if plugins:\n            # Build list of (author, name) tuples for all plugins\n            plugin_ids = []\n            for plugin in plugins:\n                author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')\n                name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')\n                if author and name:\n                    plugin_ids.append((author, name))\n\n            # Fetch all timestamps in a single query using OR conditions\n            if plugin_ids:\n                conditions = [\n                    sqlalchemy.and_(\n                        persistence_plugin.PluginSetting.plugin_author == author,\n                        persistence_plugin.PluginSetting.plugin_name == name,\n                    )\n                    for author, name in plugin_ids\n                ]\n\n                result = await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.select(\n                        persistence_plugin.PluginSetting.plugin_author,\n                        persistence_plugin.PluginSetting.plugin_name,\n                        persistence_plugin.PluginSetting.created_at,\n                    ).where(sqlalchemy.or_(*conditions))\n                )\n\n                for row in result:\n                    plugin_id = f'{row.plugin_author}/{row.plugin_name}'\n                    plugin_timestamps[plugin_id] = row.created_at\n\n        # Sort: debug plugins first (descending), then by created_at (descending)\n        def sort_key(plugin):\n            author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')\n            name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')\n            plugin_id = f'{author}/{name}'\n\n            is_debug = plugin.get('debug', False)\n            created_at = plugin_timestamps.get(plugin_id)\n\n            # Return tuple: (not is_debug, -timestamp)\n            # not is_debug: False (0) for debug plugins, True (1) for non-debug\n            # -timestamp: to sort newest first (will be None for plugins without timestamp)\n            timestamp_value = -created_at.timestamp() if created_at else 0\n            return (not is_debug, timestamp_value)\n\n        plugins.sort(key=sort_key)\n\n        return plugins\n\n    async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:\n        return await self.handler.get_plugin_info(author, plugin_name)\n\n    async def set_plugin_config(self, plugin_author: str, plugin_name: str, config: dict[str, Any]) -> dict[str, Any]:\n        return await self.handler.set_plugin_config(plugin_author, plugin_name, config)\n\n    @alru_cache(ttl=5 * 60)  # 5 minutes\n    async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:\n        return await self.handler.get_plugin_icon(plugin_author, plugin_name)\n\n    @alru_cache(ttl=5 * 60)  # 5 minutes\n    async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:\n        return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)\n\n    @alru_cache(ttl=5 * 60)\n    async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:\n        return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)\n\n    async def get_debug_info(self) -> dict[str, Any]:\n        \"\"\"Get debug information including debug key and WS URL\"\"\"\n        if not self.is_enable_plugin:\n            return {}\n        return await self.handler.get_debug_info()\n\n    async def emit_event(\n        self,\n        event: events.BaseEventModel,\n        bound_plugins: list[str] | None = None,\n    ) -> context.EventContext:\n        event_ctx = context.EventContext.from_event(event)\n\n        if not self.is_enable_plugin:\n            return event_ctx\n\n        # Pass include_plugins to runtime for filtering\n        event_ctx_result = await self.handler.emit_event(\n            event_ctx.model_dump(serialize_as_any=False), include_plugins=bound_plugins\n        )\n\n        event_ctx = context.EventContext.model_validate(event_ctx_result['event_context'])\n\n        return event_ctx\n\n    async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:\n        if not self.is_enable_plugin:\n            return []\n\n        # Pass include_plugins to runtime for filtering\n        list_tools_data = await self.handler.list_tools(include_plugins=bound_plugins)\n\n        tools = [ComponentManifest.model_validate(tool) for tool in list_tools_data]\n\n        return tools\n\n    async def call_tool(\n        self,\n        tool_name: str,\n        parameters: dict[str, Any],\n        session: provider_session.Session,\n        query_id: int,\n        bound_plugins: list[str] | None = None,\n    ) -> dict[str, Any]:\n        if not self.is_enable_plugin:\n            return {'error': 'Tool not found: plugin system is disabled'}\n\n        # Pass include_plugins to runtime for validation\n        return await self.handler.call_tool(\n            tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins\n        )\n\n    async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:\n        if not self.is_enable_plugin:\n            return []\n\n        # Pass include_plugins to runtime for filtering\n        list_commands_data = await self.handler.list_commands(include_plugins=bound_plugins)\n\n        commands = [ComponentManifest.model_validate(command) for command in list_commands_data]\n\n        return commands\n\n    async def execute_command(\n        self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None\n    ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:\n        if not self.is_enable_plugin:\n            yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))\n            return\n\n        # Pass include_plugins to runtime for validation\n        gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True), include_plugins=bound_plugins)\n\n        async for ret in gen:\n            cmd_ret = command_context.CommandReturn.model_validate(ret)\n\n            yield cmd_ret\n\n    async def retrieve_knowledge(\n        self,\n        plugin_author: str,\n        plugin_name: str,\n        retriever_name: str,\n        retrieval_context: dict[str, Any],\n    ) -> dict[str, Any]:\n        \"\"\"Retrieve knowledge using a KnowledgeEngine instance.\"\"\"\n        if not self.is_enable_plugin:\n            return {'results': []}\n\n        return await self.handler.retrieve_knowledge(plugin_author, plugin_name, retriever_name, retrieval_context)\n\n    def dispose(self):\n        # No need to consider the shutdown on Windows\n        # for Windows can kill processes and subprocesses chainly\n\n        if self.is_enable_plugin and isinstance(self.ctrl, stdio_client_controller.StdioClientController):\n            self.ap.logger.info('Terminating plugin runtime process...')\n            self.ctrl.process.terminate()\n\n        if self.heartbeat_task is not None:\n            self.heartbeat_task.cancel()\n            self.heartbeat_task = None\n\n    @staticmethod\n    def _parse_plugin_id(plugin_id: str) -> tuple[str, str]:\n        \"\"\"Parse a plugin ID string into (author, name).\n\n        Args:\n            plugin_id: Plugin ID in 'author/name' format.\n\n        Returns:\n            Tuple of (plugin_author, plugin_name).\n\n        Raises:\n            ValueError: If plugin_id is not in the expected 'author/name' format.\n        \"\"\"\n        if '/' not in plugin_id:\n            raise ValueError(\n                f\"Invalid plugin_id format: '{plugin_id}'. Expected 'author/name' format (e.g. 'langbot/rag-engine').\"\n            )\n        return plugin_id.split('/', 1)\n\n    async def call_rag_ingest(self, plugin_id: str, context_data: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"Call plugin to ingest document.\n\n        Args:\n            plugin_id: Target plugin ID (author/name).\n            context_data: IngestionContext data.\n        \"\"\"\n        plugin_author, plugin_name = self._parse_plugin_id(plugin_id)\n        return await self.handler.rag_ingest_document(plugin_author, plugin_name, context_data)\n\n    async def call_rag_delete_document(self, plugin_id: str, document_id: str, kb_id: str) -> bool:\n        plugin_author, plugin_name = self._parse_plugin_id(plugin_id)\n        return await self.handler.rag_delete_document(plugin_author, plugin_name, document_id, kb_id)\n\n    async def get_rag_creation_schema(self, plugin_id: str) -> dict[str, Any]:\n        plugin_author, plugin_name = self._parse_plugin_id(plugin_id)\n        return await self.handler.get_rag_creation_schema(plugin_author, plugin_name)\n\n    async def get_rag_retrieval_schema(self, plugin_id: str) -> dict[str, Any]:\n        plugin_author, plugin_name = self._parse_plugin_id(plugin_id)\n        return await self.handler.get_rag_retrieval_schema(plugin_author, plugin_name)\n\n    async def rag_on_kb_create(self, plugin_id: str, kb_id: str, config: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"Notify plugin about KB creation.\"\"\"\n        plugin_author, plugin_name = self._parse_plugin_id(plugin_id)\n        return await self.handler.rag_on_kb_create(plugin_author, plugin_name, kb_id, config)\n\n    async def rag_on_kb_delete(self, plugin_id: str, kb_id: str) -> dict[str, Any]:\n        \"\"\"Notify plugin about KB deletion.\"\"\"\n        plugin_author, plugin_name = self._parse_plugin_id(plugin_id)\n        return await self.handler.rag_on_kb_delete(plugin_author, plugin_name, kb_id)\n\n    async def call_rag_retrieve(self, plugin_id: str, retrieval_context: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"Call plugin to retrieve knowledge.\n\n        Args:\n            plugin_id: Target plugin ID (author/name).\n            retrieval_context: RetrievalContext data.\n        \"\"\"\n        plugin_author, plugin_name = self._parse_plugin_id(plugin_id)\n        return await self.handler.retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context)\n\n    async def list_knowledge_engines(self) -> list[dict[str, Any]]:\n        \"\"\"List all available Knowledge Engines from plugins.\n\n        Returns a list of Knowledge Engines with their capabilities and configuration schemas.\n        \"\"\"\n        if not self.is_enable_plugin:\n            return []\n\n        return await self.handler.list_knowledge_engines()\n\n    async def list_parsers(self) -> list[dict[str, Any]]:\n        \"\"\"List all available parsers from plugins.\"\"\"\n        if not self.is_enable_plugin:\n            return []\n        return await self.handler.list_parsers()\n\n    async def call_parser(self, plugin_id: str, context_data: dict[str, Any], file_bytes: bytes) -> dict[str, Any]:\n        \"\"\"Call plugin to parse a document.\"\"\"\n        plugin_author, plugin_name = self._parse_plugin_id(plugin_id)\n        return await self.handler.parse_document(plugin_author, plugin_name, context_data, file_bytes)\n"
  },
  {
    "path": "src/langbot/pkg/plugin/handler.py",
    "content": "from __future__ import annotations\n\nimport typing\nfrom typing import Any\nimport base64\nimport traceback\n\nimport sqlalchemy\n\nfrom langbot_plugin.runtime.io import handler\nfrom langbot_plugin.runtime.io.connection import Connection\nfrom langbot_plugin.entities.io.actions.enums import (\n    CommonAction,\n    RuntimeToLangBotAction,\n    LangBotToRuntimeAction,\n    PluginToRuntimeAction,\n)\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\n\nfrom ..entity.persistence import plugin as persistence_plugin\nfrom ..entity.persistence import bstorage as persistence_bstorage\n\nfrom ..core import app\nfrom ..utils import constants\n\n\ndef _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse:\n    \"\"\"Create a clean error response for RAG operations.\n\n    Args:\n        error: The caught exception.\n        error_type: A category string like 'EmbeddingError', 'VectorStoreError'.\n        **extra_context: Additional context fields for the error message.\n    \"\"\"\n    context_parts = [f'{k}={v}' for k, v in extra_context.items()]\n    context_str = f' [{\", \".join(context_parts)}]' if context_parts else ''\n    message = f'[{error_type}/{type(error).__name__}]{context_str} {str(error)}'\n    return handler.ActionResponse.error(message=message)\n\n\nclass RuntimeConnectionHandler(handler.Handler):\n    \"\"\"Runtime connection handler\"\"\"\n\n    ap: app.Application\n\n    def __init__(\n        self,\n        connection: Connection,\n        disconnect_callback: typing.Callable[[], typing.Coroutine[typing.Any, typing.Any, bool]],\n        ap: app.Application,\n    ):\n        super().__init__(connection, disconnect_callback)\n        self.ap = ap\n\n        @self.action(RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS)\n        async def initialize_plugin_settings(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Initialize plugin settings\"\"\"\n            # check if exists plugin setting\n            plugin_author = data['plugin_author']\n            plugin_name = data['plugin_name']\n            install_source = data['install_source']\n            install_info = data['install_info']\n\n            try:\n                result = await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.select(persistence_plugin.PluginSetting)\n                    .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)\n                    .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)\n                )\n\n                setting = result.first()\n\n                if setting is not None:\n                    # delete plugin setting\n                    await self.ap.persistence_mgr.execute_async(\n                        sqlalchemy.delete(persistence_plugin.PluginSetting)\n                        .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)\n                        .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)\n                    )\n\n                # create plugin setting\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.insert(persistence_plugin.PluginSetting).values(\n                        plugin_author=plugin_author,\n                        plugin_name=plugin_name,\n                        install_source=install_source,\n                        install_info=install_info,\n                        # inherit from existing setting\n                        enabled=setting.enabled if setting is not None else True,\n                        priority=setting.priority if setting is not None else 0,\n                        config=setting.config if setting is not None else {},  # noqa: F821\n                    )\n                )\n\n                return handler.ActionResponse.success(\n                    data={},\n                )\n            except Exception as e:\n                traceback.print_exc()\n                return handler.ActionResponse.error(\n                    message=f'Failed to initialize plugin settings: {e}',\n                )\n\n        @self.action(RuntimeToLangBotAction.GET_PLUGIN_SETTINGS)\n        async def get_plugin_settings(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get plugin settings\"\"\"\n\n            plugin_author = data['plugin_author']\n            plugin_name = data['plugin_name']\n\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.select(persistence_plugin.PluginSetting)\n                .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)\n                .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)\n            )\n\n            data = {\n                'enabled': True,\n                'priority': 0,\n                'plugin_config': {},\n                'install_source': 'local',\n                'install_info': {},\n            }\n\n            setting = result.first()\n\n            if setting is not None:\n                data['enabled'] = setting.enabled\n                data['priority'] = setting.priority\n                data['plugin_config'] = setting.config\n                data['install_source'] = setting.install_source\n                data['install_info'] = setting.install_info\n\n            return handler.ActionResponse.success(\n                data=data,\n            )\n\n        @self.action(PluginToRuntimeAction.REPLY_MESSAGE)\n        async def reply_message(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Reply message\"\"\"\n            query_id = data['query_id']\n            message_chain = data['message_chain']\n            quote_origin = data['quote_origin']\n\n            if query_id not in self.ap.query_pool.cached_queries:\n                return handler.ActionResponse.error(\n                    message=f'Query with query_id {query_id} not found',\n                )\n\n            query = self.ap.query_pool.cached_queries[query_id]\n\n            message_chain_obj = platform_message.MessageChain.model_validate(message_chain)\n\n            self.ap.logger.debug(f'Reply message: {message_chain_obj.model_dump(serialize_as_any=False)}')\n\n            await query.adapter.reply_message(\n                query.message_event,\n                message_chain_obj,\n                quote_origin,\n            )\n\n            return handler.ActionResponse.success(\n                data={},\n            )\n\n        @self.action(PluginToRuntimeAction.GET_BOT_UUID)\n        async def get_bot_uuid(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get bot uuid\"\"\"\n            query_id = data['query_id']\n            if query_id not in self.ap.query_pool.cached_queries:\n                return handler.ActionResponse.error(\n                    message=f'Query with query_id {query_id} not found',\n                )\n\n            query = self.ap.query_pool.cached_queries[query_id]\n\n            return handler.ActionResponse.success(\n                data={\n                    'bot_uuid': query.bot_uuid,\n                },\n            )\n\n        @self.action(PluginToRuntimeAction.SET_QUERY_VAR)\n        async def set_query_var(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Set query var\"\"\"\n            query_id = data['query_id']\n            key = data['key']\n            value = data['value']\n\n            if query_id not in self.ap.query_pool.cached_queries:\n                return handler.ActionResponse.error(\n                    message=f'Query with query_id {query_id} not found',\n                )\n\n            query = self.ap.query_pool.cached_queries[query_id]\n\n            query.variables[key] = value\n\n            return handler.ActionResponse.success(\n                data={},\n            )\n\n        @self.action(PluginToRuntimeAction.GET_QUERY_VAR)\n        async def get_query_var(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get query var\"\"\"\n            query_id = data['query_id']\n            key = data['key']\n\n            if query_id not in self.ap.query_pool.cached_queries:\n                return handler.ActionResponse.error(\n                    message=f'Query with query_id {query_id} not found',\n                )\n\n            query = self.ap.query_pool.cached_queries[query_id]\n\n            return handler.ActionResponse.success(\n                data={\n                    'value': query.variables[key],\n                },\n            )\n\n        @self.action(PluginToRuntimeAction.GET_QUERY_VARS)\n        async def get_query_vars(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get query vars\"\"\"\n            query_id = data['query_id']\n            if query_id not in self.ap.query_pool.cached_queries:\n                return handler.ActionResponse.error(\n                    message=f'Query with query_id {query_id} not found',\n                )\n\n            query = self.ap.query_pool.cached_queries[query_id]\n\n            return handler.ActionResponse.success(\n                data={\n                    'vars': query.variables,\n                },\n            )\n\n        @self.action(PluginToRuntimeAction.CREATE_NEW_CONVERSATION)\n        async def create_new_conversation(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Create new conversation\"\"\"\n            query_id = data['query_id']\n            if query_id not in self.ap.query_pool.cached_queries:\n                return handler.ActionResponse.error(\n                    message=f'Query with query_id {query_id} not found',\n                )\n\n            query = self.ap.query_pool.cached_queries[query_id]\n\n            query.session.using_conversation = None\n\n            return handler.ActionResponse.success(\n                data={},\n            )\n\n        @self.action(PluginToRuntimeAction.GET_LANGBOT_VERSION)\n        async def get_langbot_version(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get langbot version\"\"\"\n            return handler.ActionResponse.success(\n                data={\n                    'version': constants.semantic_version,\n                },\n            )\n\n        @self.action(PluginToRuntimeAction.GET_BOTS)\n        async def get_bots(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get bots\"\"\"\n            bots = await self.ap.bot_service.get_bots(include_secret=False)\n            return handler.ActionResponse.success(\n                data={\n                    'bots': bots,\n                },\n            )\n\n        @self.action(PluginToRuntimeAction.GET_BOT_INFO)\n        async def get_bot_info(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get bot info\"\"\"\n            bot_uuid = data['bot_uuid']\n            bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid, include_secret=False)\n            return handler.ActionResponse.success(\n                data={\n                    'bot': bot,\n                },\n            )\n\n        @self.action(PluginToRuntimeAction.SEND_MESSAGE)\n        async def send_message(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Send message\"\"\"\n            bot_uuid = data['bot_uuid']\n            target_type = data['target_type']\n            target_id = data['target_id']\n            message_chain = data['message_chain']\n\n            # Use custom deserializer that properly handles Forward messages\n            message_chain_obj = platform_message.MessageChain.model_validate(message_chain)\n\n            bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)\n            if bot is None:\n                return handler.ActionResponse.error(\n                    message=f'Bot with bot_uuid {bot_uuid} not found',\n                )\n\n            await bot.adapter.send_message(\n                target_type,\n                target_id,\n                message_chain_obj,\n            )\n\n            return handler.ActionResponse.success(\n                data={},\n            )\n\n        @self.action(PluginToRuntimeAction.GET_LLM_MODELS)\n        async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get llm models\"\"\"\n            llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)\n            return handler.ActionResponse.success(\n                data={\n                    'llm_models': llm_models,\n                },\n            )\n\n        @self.action(PluginToRuntimeAction.INVOKE_LLM)\n        async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Invoke llm\"\"\"\n            llm_model_uuid = data['llm_model_uuid']\n            messages = data['messages']\n            funcs = data.get('funcs', [])\n            extra_args = data.get('extra_args', {})\n\n            llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid)\n            if llm_model is None:\n                return handler.ActionResponse.error(\n                    message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',\n                )\n\n            messages_obj = [provider_message.Message.model_validate(message) for message in messages]\n\n            # The func field is excluded during model_dump() in plugin side (marked as exclude=True),\n            # but it's a required field for LLMTool validation. We need to provide a placeholder\n            # function when reconstructing the LLMTool objects from serialized data.\n            async def _placeholder_func(**kwargs):\n                pass\n\n            funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs]\n\n            result = await llm_model.provider.invoke_llm(\n                query=None,\n                model=llm_model,\n                messages=messages_obj,\n                funcs=funcs_obj,\n                extra_args=extra_args,\n            )\n\n            return handler.ActionResponse.success(\n                data={\n                    'message': result.model_dump(),\n                },\n            )\n\n        @self.action(RuntimeToLangBotAction.SET_BINARY_STORAGE)\n        async def set_binary_storage(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Set binary storage\"\"\"\n            key = data['key']\n            owner_type = data['owner_type']\n            owner = data['owner']\n            value = base64.b64decode(data['value_base64'])\n\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.select(persistence_bstorage.BinaryStorage)\n                .where(persistence_bstorage.BinaryStorage.key == key)\n                .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)\n                .where(persistence_bstorage.BinaryStorage.owner == owner)\n            )\n\n            if result.first() is not None:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.update(persistence_bstorage.BinaryStorage)\n                    .where(persistence_bstorage.BinaryStorage.key == key)\n                    .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)\n                    .where(persistence_bstorage.BinaryStorage.owner == owner)\n                    .values(value=value)\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(\n                        unique_key=f'{owner_type}:{owner}:{key}',\n                        key=key,\n                        owner_type=owner_type,\n                        owner=owner,\n                        value=value,\n                    )\n                )\n\n            return handler.ActionResponse.success(\n                data={},\n            )\n\n        @self.action(RuntimeToLangBotAction.GET_BINARY_STORAGE)\n        async def get_binary_storage(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get binary storage\"\"\"\n            key = data['key']\n            owner_type = data['owner_type']\n            owner = data['owner']\n\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.select(persistence_bstorage.BinaryStorage)\n                .where(persistence_bstorage.BinaryStorage.key == key)\n                .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)\n                .where(persistence_bstorage.BinaryStorage.owner == owner)\n            )\n\n            storage = result.first()\n            if storage is None:\n                return handler.ActionResponse.error(\n                    message=f'Storage with key {key} not found',\n                )\n\n            return handler.ActionResponse.success(\n                data={\n                    'value_base64': base64.b64encode(storage.value).decode('utf-8'),\n                },\n            )\n\n        @self.action(RuntimeToLangBotAction.DELETE_BINARY_STORAGE)\n        async def delete_binary_storage(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Delete binary storage\"\"\"\n            key = data['key']\n            owner_type = data['owner_type']\n            owner = data['owner']\n\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.delete(persistence_bstorage.BinaryStorage)\n                .where(persistence_bstorage.BinaryStorage.key == key)\n                .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)\n                .where(persistence_bstorage.BinaryStorage.owner == owner)\n            )\n\n            return handler.ActionResponse.success(\n                data={},\n            )\n\n        @self.action(RuntimeToLangBotAction.GET_BINARY_STORAGE_KEYS)\n        async def get_binary_storage_keys(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get binary storage keys\"\"\"\n            owner_type = data['owner_type']\n            owner = data['owner']\n\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.select(persistence_bstorage.BinaryStorage.key)\n                .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)\n                .where(persistence_bstorage.BinaryStorage.owner == owner)\n            )\n\n            return handler.ActionResponse.success(\n                data={\n                    'keys': result.scalars().all(),\n                },\n            )\n\n        @self.action(PluginToRuntimeAction.GET_CONFIG_FILE)\n        async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Get a config file by file key\"\"\"\n            file_key = data['file_key']\n\n            try:\n                # Load file from storage\n                file_bytes = await self.ap.storage_mgr.storage_provider.load(file_key)\n\n                return handler.ActionResponse.success(\n                    data={\n                        'file_base64': base64.b64encode(file_bytes).decode('utf-8'),\n                    },\n                )\n            except Exception as e:\n                return handler.ActionResponse.error(\n                    message=f'Failed to load config file {file_key}: {e}',\n                )\n\n        # ================= RAG Capability Handlers =================\n\n        @self.action(PluginToRuntimeAction.INVOKE_EMBEDDING)\n        async def invoke_embedding(data: dict[str, Any]) -> handler.ActionResponse:\n            embedding_model_uuid = data['embedding_model_uuid']\n            texts = data['texts']\n\n            embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid(embedding_model_uuid)\n            if embedding_model is None:\n                return handler.ActionResponse.error(\n                    message=f'Embedding model with embedding_model_uuid {embedding_model_uuid} not found',\n                )\n\n            try:\n                vectors = await embedding_model.provider.invoke_embedding(embedding_model, texts)\n                return handler.ActionResponse.success(data={'vectors': vectors})\n            except Exception as e:\n                return _make_rag_error_response(e, 'EmbeddingError', embedding_model_uuid=embedding_model_uuid)\n\n        @self.action(PluginToRuntimeAction.VECTOR_UPSERT)\n        async def vector_upsert(data: dict[str, Any]) -> handler.ActionResponse:\n            collection_id = data['collection_id']\n            vectors = data['vectors']\n            ids = data['ids']\n            metadata = data.get('metadata')\n            documents = data.get('documents')\n            if len(vectors) != len(ids):\n                return handler.ActionResponse.error(message='vectors and ids must have same length')\n            if metadata and len(metadata) != len(vectors):\n                return handler.ActionResponse.error(message='metadata must match vectors length')\n            if documents and len(documents) != len(vectors):\n                return handler.ActionResponse.error(message='documents must match vectors length')\n            try:\n                await self.ap.rag_runtime_service.vector_upsert(\n                    collection_id,\n                    vectors,\n                    ids,\n                    metadata,\n                    documents,\n                )\n                return handler.ActionResponse.success(data={})\n            except Exception as e:\n                return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)\n\n        @self.action(PluginToRuntimeAction.VECTOR_SEARCH)\n        async def vector_search(data: dict[str, Any]) -> handler.ActionResponse:\n            collection_id = data['collection_id']\n            query_vector = data['query_vector']\n            top_k = data['top_k']\n            filters = data.get('filters')\n            search_type = data.get('search_type', 'vector')\n            query_text = data.get('query_text', '')\n            try:\n                results = await self.ap.rag_runtime_service.vector_search(\n                    collection_id,\n                    query_vector,\n                    top_k,\n                    filters,\n                    search_type,\n                    query_text,\n                )\n                return handler.ActionResponse.success(data={'results': results})\n            except Exception as e:\n                return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)\n\n        @self.action(PluginToRuntimeAction.VECTOR_DELETE)\n        async def vector_delete(data: dict[str, Any]) -> handler.ActionResponse:\n            collection_id = data['collection_id']\n            file_ids = data.get('file_ids')\n            filters = data.get('filters')\n            try:\n                count = await self.ap.rag_runtime_service.vector_delete(collection_id, file_ids, filters)\n                return handler.ActionResponse.success(data={'count': count})\n            except Exception as e:\n                return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)\n\n        @self.action(PluginToRuntimeAction.VECTOR_LIST)\n        async def vector_list(data: dict[str, Any]) -> handler.ActionResponse:\n            collection_id = data['collection_id']\n            filters = data.get('filters')\n            limit = data.get('limit', 20)\n            offset = data.get('offset', 0)\n            try:\n                items, total = await self.ap.rag_runtime_service.vector_list(collection_id, filters, limit, offset)\n                return handler.ActionResponse.success(data={'items': items, 'total': total})\n            except Exception as e:\n                return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)\n\n        @self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM)\n        async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse:\n            storage_path = data['storage_path']\n            try:\n                content_bytes = await self.ap.rag_runtime_service.get_file_stream(storage_path)\n                file_key = await self.send_file(content_bytes, '')\n                return handler.ActionResponse.success(data={'file_key': file_key})\n            except Exception as e:\n                return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path)\n\n        @self.action(PluginToRuntimeAction.LIST_PARSERS)\n        async def list_parsers(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Plugin requests host to list available parser plugins.\"\"\"\n            mime_type = data.get('mime_type')\n            try:\n                parsers = await self.ap.knowledge_service.list_parsers(mime_type)\n                return handler.ActionResponse.success(data={'parsers': parsers})\n            except Exception as e:\n                return _make_rag_error_response(e, 'ParserDiscoveryError', mime_type=mime_type)\n\n        @self.action(PluginToRuntimeAction.INVOKE_PARSER)\n        async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Plugin requests host to invoke a parser plugin.\"\"\"\n            plugin_author = data['plugin_author']\n            plugin_name = data['plugin_name']\n            storage_path = data['storage_path']\n            mime_type = data.get('mime_type', 'application/octet-stream')\n            filename = data.get('filename', '')\n            metadata = data.get('metadata', {})\n            try:\n                # Read file from storage\n                file_bytes = await self.ap.rag_runtime_service.get_file_stream(storage_path)\n                context_data = {\n                    'mime_type': mime_type,\n                    'filename': filename,\n                    'metadata': metadata,\n                }\n                result = await self.ap.plugin_connector.call_parser(\n                    f'{plugin_author}/{plugin_name}', context_data, file_bytes\n                )\n                return handler.ActionResponse.success(data=result)\n            except Exception as e:\n                return _make_rag_error_response(e, 'ParserError')\n\n        # ================= Knowledge Base Query APIs =================\n\n        @self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)\n        async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"List knowledge bases configured for the current query's pipeline.\"\"\"\n            query_id = data['query_id']\n\n            if query_id not in self.ap.query_pool.cached_queries:\n                return handler.ActionResponse.error(\n                    message=f'Query with query_id {query_id} not found',\n                )\n\n            query = self.ap.query_pool.cached_queries[query_id]\n\n            kb_uuids = []\n            if query.pipeline_config:\n                local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})\n                kb_uuids = local_agent_config.get('knowledge-bases', [])\n                # Backward compatibility\n                if not kb_uuids:\n                    old_kb_uuid = local_agent_config.get('knowledge-base', '')\n                    if old_kb_uuid and old_kb_uuid != '__none__':\n                        kb_uuids = [old_kb_uuid]\n\n            knowledge_bases = []\n            for kb_uuid in kb_uuids:\n                kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)\n                if kb:\n                    knowledge_bases.append(\n                        {\n                            'uuid': kb.get_uuid(),\n                            'name': kb.get_name(),\n                            'description': kb.knowledge_base_entity.description or '',\n                        }\n                    )\n\n            return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})\n\n        @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE)\n        async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Retrieve documents from a knowledge base within the pipeline's scope.\"\"\"\n            query_id = data['query_id']\n            kb_id = data['kb_id']\n            query_text = data['query_text']\n            top_k = data.get('top_k', 5)\n            filters = data.get('filters', {})\n\n            if query_id not in self.ap.query_pool.cached_queries:\n                return handler.ActionResponse.error(\n                    message=f'Query with query_id {query_id} not found',\n                )\n\n            query = self.ap.query_pool.cached_queries[query_id]\n\n            # Validate kb_id is in pipeline's allowed list\n            allowed_kb_uuids = []\n            if query.pipeline_config:\n                local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})\n                allowed_kb_uuids = local_agent_config.get('knowledge-bases', [])\n                if not allowed_kb_uuids:\n                    old_kb_uuid = local_agent_config.get('knowledge-base', '')\n                    if old_kb_uuid and old_kb_uuid != '__none__':\n                        allowed_kb_uuids = [old_kb_uuid]\n\n            if kb_id not in allowed_kb_uuids:\n                return handler.ActionResponse.error(\n                    message=f'Knowledge base {kb_id} is not configured for this pipeline',\n                )\n\n            kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)\n            if not kb:\n                return handler.ActionResponse.error(\n                    message=f'Knowledge base {kb_id} not found',\n                )\n\n            try:\n                session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}'\n                entries = await kb.retrieve(\n                    query_text,\n                    settings={\n                        'top_k': top_k,\n                        'filters': filters,\n                        'session_name': session_name,\n                        'bot_uuid': query.bot_uuid or '',\n                        'sender_id': str(query.sender_id),\n                    },\n                )\n                results = [entry.model_dump(mode='json') for entry in entries]\n                return handler.ActionResponse.success(data={'results': results})\n            except Exception as e:\n                return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)\n\n        @self.action(CommonAction.PING)\n        async def ping(data: dict[str, Any]) -> handler.ActionResponse:\n            \"\"\"Ping\"\"\"\n            return handler.ActionResponse.success(\n                data={\n                    'pong': 'pong',\n                },\n            )\n\n    async def ping(self) -> dict[str, Any]:\n        \"\"\"Ping the runtime\"\"\"\n        return await self.call_action(\n            CommonAction.PING,\n            {},\n            timeout=10,\n        )\n\n    async def install_plugin(\n        self, install_source: str, install_info: dict[str, Any]\n    ) -> typing.AsyncGenerator[dict[str, Any], None]:\n        \"\"\"Install plugin\"\"\"\n        gen = self.call_action_generator(\n            LangBotToRuntimeAction.INSTALL_PLUGIN,\n            {\n                'install_source': install_source,\n                'install_info': install_info,\n            },\n            timeout=120,\n        )\n\n        async for ret in gen:\n            yield ret\n\n    async def upgrade_plugin(self, plugin_author: str, plugin_name: str) -> typing.AsyncGenerator[dict[str, Any], None]:\n        \"\"\"Upgrade plugin\"\"\"\n        gen = self.call_action_generator(\n            LangBotToRuntimeAction.UPGRADE_PLUGIN,\n            {\n                'plugin_author': plugin_author,\n                'plugin_name': plugin_name,\n            },\n            timeout=120,\n        )\n\n        async for ret in gen:\n            yield ret\n\n    async def delete_plugin(self, plugin_author: str, plugin_name: str) -> typing.AsyncGenerator[dict[str, Any], None]:\n        \"\"\"Delete plugin\"\"\"\n        gen = self.call_action_generator(\n            LangBotToRuntimeAction.DELETE_PLUGIN,\n            {\n                'plugin_author': plugin_author,\n                'plugin_name': plugin_name,\n            },\n        )\n\n        async for ret in gen:\n            yield ret\n\n    async def list_plugins(self) -> list[dict[str, Any]]:\n        \"\"\"List plugins\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.LIST_PLUGINS,\n            {},\n            timeout=10,\n        )\n\n        return result['plugins']\n\n    async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:\n        \"\"\"Get plugin\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.GET_PLUGIN_INFO,\n            {\n                'author': author,\n                'plugin_name': plugin_name,\n            },\n            timeout=10,\n        )\n        return result['plugin']\n\n    async def set_plugin_config(self, plugin_author: str, plugin_name: str, config: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"Set plugin config\"\"\"\n        # update plugin setting\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.update(persistence_plugin.PluginSetting)\n            .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)\n            .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)\n            .values(config=config)\n        )\n\n        # restart plugin\n        gen = self.call_action_generator(\n            LangBotToRuntimeAction.RESTART_PLUGIN,\n            {\n                'plugin_author': plugin_author,\n                'plugin_name': plugin_name,\n            },\n        )\n        async for ret in gen:\n            pass\n\n        return {}\n\n    async def emit_event(\n        self,\n        event_context: dict[str, Any],\n        include_plugins: list[str] | None = None,\n    ) -> dict[str, Any]:\n        \"\"\"Emit event\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.EMIT_EVENT,\n            {\n                'event_context': event_context,\n                'include_plugins': include_plugins,\n            },\n            timeout=180,\n        )\n\n        return result\n\n    async def list_tools(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]:\n        \"\"\"List tools\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.LIST_TOOLS,\n            {\n                'include_plugins': include_plugins,\n            },\n            timeout=20,\n        )\n\n        return result['tools']\n\n    async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:\n        \"\"\"Get plugin icon\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.GET_PLUGIN_ICON,\n            {\n                'plugin_author': plugin_author,\n                'plugin_name': plugin_name,\n            },\n        )\n\n        plugin_icon_file_key = result['plugin_icon_file_key']\n        mime_type = result['mime_type']\n\n        plugin_icon_bytes = await self.read_local_file(plugin_icon_file_key)\n\n        await self.delete_local_file(plugin_icon_file_key)\n\n        return {\n            'plugin_icon_base64': base64.b64encode(plugin_icon_bytes).decode('utf-8'),\n            'mime_type': mime_type,\n        }\n\n    async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:\n        \"\"\"Get plugin readme\"\"\"\n        try:\n            result = await self.call_action(\n                LangBotToRuntimeAction.GET_PLUGIN_README,\n                {\n                    'plugin_author': plugin_author,\n                    'plugin_name': plugin_name,\n                    'language': language,\n                },\n                timeout=20,\n            )\n        except Exception:\n            traceback.print_exc()\n            return ''\n\n        readme_file_key = result.get('readme_file_key')\n        if not readme_file_key:\n            return ''\n\n        readme_bytes = await self.read_local_file(readme_file_key)\n        await self.delete_local_file(readme_file_key)\n\n        return readme_bytes.decode('utf-8')\n\n    async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:\n        \"\"\"Get plugin assets\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.GET_PLUGIN_ASSETS_FILE,\n            {\n                'plugin_author': plugin_author,\n                'plugin_name': plugin_name,\n                'file_path': filepath,\n            },\n            timeout=20,\n        )\n        asset_file_key = result['file_file_key']\n        mime_type = result['mime_type']\n        asset_bytes = await self.read_local_file(asset_file_key)\n        await self.delete_local_file(asset_file_key)\n        return {\n            'asset_base64': base64.b64encode(asset_bytes).decode('utf-8'),\n            'mime_type': mime_type,\n        }\n\n    async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:\n        \"\"\"Cleanup plugin settings and binary storage\"\"\"\n        # Delete plugin settings\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(persistence_plugin.PluginSetting)\n            .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)\n            .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)\n        )\n\n        # Delete all binary storage for this plugin\n        owner = f'{plugin_author}/{plugin_name}'\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(persistence_bstorage.BinaryStorage)\n            .where(persistence_bstorage.BinaryStorage.owner_type == 'plugin')\n            .where(persistence_bstorage.BinaryStorage.owner == owner)\n        )\n\n    async def call_tool(\n        self,\n        tool_name: str,\n        parameters: dict[str, Any],\n        session: dict[str, Any],\n        query_id: int,\n        include_plugins: list[str] | None = None,\n    ) -> dict[str, Any]:\n        \"\"\"Call tool\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.CALL_TOOL,\n            {\n                'tool_name': tool_name,\n                'tool_parameters': parameters,\n                'session': session,\n                'query_id': query_id,\n                'include_plugins': include_plugins,\n            },\n            timeout=180,\n        )\n\n        return result['tool_response']\n\n    async def list_commands(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]:\n        \"\"\"List commands\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.LIST_COMMANDS,\n            {\n                'include_plugins': include_plugins,\n            },\n            timeout=10,\n        )\n        return result['commands']\n\n    async def execute_command(\n        self, command_context: dict[str, Any], include_plugins: list[str] | None = None\n    ) -> typing.AsyncGenerator[dict[str, Any], None]:\n        \"\"\"Execute command\"\"\"\n        gen = self.call_action_generator(\n            LangBotToRuntimeAction.EXECUTE_COMMAND,\n            {\n                'command_context': command_context,\n                'include_plugins': include_plugins,\n            },\n            timeout=180,\n        )\n\n        async for ret in gen:\n            yield ret\n\n    async def retrieve_knowledge(\n        self,\n        plugin_author: str,\n        plugin_name: str,\n        retriever_name: str,\n        retrieval_context: dict[str, Any],\n    ) -> dict[str, Any]:\n        \"\"\"Retrieve knowledge\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.RETRIEVE_KNOWLEDGE,\n            {\n                'plugin_author': plugin_author,\n                'plugin_name': plugin_name,\n                'retriever_name': retriever_name,\n                'retrieval_context': retrieval_context,\n            },\n            timeout=30,\n        )\n        return result\n\n    async def get_debug_info(self) -> dict[str, Any]:\n        \"\"\"Get debug information including debug key and WS URL\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.GET_DEBUG_INFO,\n            {},\n            timeout=10,\n        )\n        return result\n\n    # ================= RAG Capability Callers (LangBot -> Runtime) =================\n\n    async def rag_ingest_document(\n        self, plugin_author: str, plugin_name: str, context_data: dict[str, Any]\n    ) -> dict[str, Any]:\n        \"\"\"Send INGEST_DOCUMENT action to runtime.\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.RAG_INGEST_DOCUMENT,\n            {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},\n            timeout=1200,  # Ingestion can be slow for large documents\n        )\n        return result\n\n    async def rag_delete_document(self, plugin_author: str, plugin_name: str, document_id: str, kb_id: str) -> bool:\n        result = await self.call_action(\n            LangBotToRuntimeAction.RAG_DELETE_DOCUMENT,\n            {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'document_id': document_id, 'kb_id': kb_id},\n            timeout=30,\n        )\n        return result.get('success', False)\n\n    async def rag_on_kb_create(\n        self, plugin_author: str, plugin_name: str, kb_id: str, config: dict[str, Any]\n    ) -> dict[str, Any]:\n        \"\"\"Notify plugin about KB creation.\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.RAG_ON_KB_CREATE,\n            {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'kb_id': kb_id, 'config': config},\n            timeout=30,\n        )\n        return result\n\n    async def rag_on_kb_delete(self, plugin_author: str, plugin_name: str, kb_id: str) -> dict[str, Any]:\n        \"\"\"Notify plugin about KB deletion.\"\"\"\n        result = await self.call_action(\n            LangBotToRuntimeAction.RAG_ON_KB_DELETE,\n            {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'kb_id': kb_id},\n            timeout=30,\n        )\n        return result\n\n    async def get_rag_creation_schema(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:\n        return await self.call_action(\n            LangBotToRuntimeAction.GET_RAG_CREATION_SETTINGS_SCHEMA,\n            {'plugin_author': plugin_author, 'plugin_name': plugin_name},\n            timeout=10,\n        )\n\n    async def get_rag_retrieval_schema(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:\n        return await self.call_action(\n            LangBotToRuntimeAction.GET_RAG_RETRIEVAL_SETTINGS_SCHEMA,\n            {'plugin_author': plugin_author, 'plugin_name': plugin_name},\n            timeout=10,\n        )\n\n    async def list_knowledge_engines(self) -> list[dict[str, Any]]:\n        \"\"\"List all available Knowledge Engines from plugins.\"\"\"\n        result = await self.call_action(LangBotToRuntimeAction.LIST_KNOWLEDGE_ENGINES, {}, timeout=60)\n        return result.get('engines', [])\n\n    # ================= Parser Capability Callers (LangBot -> Runtime) =================\n\n    async def list_parsers(self) -> list[dict[str, Any]]:\n        \"\"\"List all available parsers from plugins.\"\"\"\n        result = await self.call_action(LangBotToRuntimeAction.LIST_PARSERS, {}, timeout=60)\n        return result.get('parsers', [])\n\n    async def parse_document(\n        self, plugin_author: str, plugin_name: str, context_data: dict[str, Any], file_bytes: bytes\n    ) -> dict[str, Any]:\n        \"\"\"Send PARSE_DOCUMENT action to runtime.\n\n        Sends file content via chunked FILE_CHUNK transfer, then invokes\n        the PARSE_DOCUMENT action with a file_key reference.\n        \"\"\"\n        # Send file to runtime via chunked transfer\n        file_key = await self.send_file(file_bytes, '')\n\n        # Include file_key in context_data for the runtime to read\n        context_data['file_key'] = file_key\n\n        result = await self.call_action(\n            LangBotToRuntimeAction.PARSE_DOCUMENT,\n            {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},\n            timeout=300,\n        )\n        return result\n"
  },
  {
    "path": "src/langbot/pkg/provider/__init__.py",
    "content": "\"\"\"OpenAI 接口处理及会话管理相关\"\"\"\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/entities.py",
    "content": "from __future__ import annotations\n\nimport typing\n\nimport pydantic\n\nfrom . import requester\nfrom . import token\n\n\nclass LLMModelInfo(pydantic.BaseModel):\n    \"\"\"模型\"\"\"\n\n    name: str\n\n    model_name: typing.Optional[str] = None\n\n    token_mgr: token.TokenManager\n\n    requester: requester.ProviderAPIRequester\n\n    tool_call_supported: typing.Optional[bool] = False\n\n    vision_supported: typing.Optional[bool] = False\n\n    class Config:\n        arbitrary_types_allowed = True\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/errors.py",
    "content": "class RequesterError(Exception):\n    \"\"\"Base class for all Requester errors.\"\"\"\n\n    def __init__(self, message: str):\n        super().__init__('模型请求失败: ' + message)\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/modelmgr.py",
    "content": "from __future__ import annotations\n\nimport sqlalchemy\nimport traceback\n\nfrom . import requester\nfrom ...core import app\nfrom ...discover import engine\nfrom . import token\nfrom ...entity.persistence import model as persistence_model\nfrom ...entity.errors import provider as provider_errors\nfrom async_lru import alru_cache\n\n\nclass ModelManager:\n    \"\"\"Model manager\"\"\"\n\n    ap: app.Application\n\n    provider_dict: dict[str, requester.RuntimeProvider]\n    \"\"\"运行时模型提供商字典, uuid -> RuntimeProvider\"\"\"\n\n    llm_models: list[requester.RuntimeLLMModel]\n\n    embedding_models: list[requester.RuntimeEmbeddingModel]\n\n    requester_components: list[engine.Component]\n\n    requester_dict: dict[str, type[requester.ProviderAPIRequester]]\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        self.llm_models = []\n        self.embedding_models = []\n        self.requester_components = []\n        self.requester_dict = {}\n\n    async def initialize(self):\n        self.requester_components = self.ap.discover.get_components_by_kind('LLMAPIRequester')\n\n        requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}\n        for component in self.requester_components:\n            requester_dict[component.metadata.name] = component.get_python_component_class()\n\n        self.requester_dict = requester_dict\n\n        await self.load_models_from_db()\n\n        # Check if space models service is disabled\n        space_config = self.ap.instance_config.data.get('space', {})\n        if space_config.get('disable_models_service', False):\n            self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')\n            return\n\n        try:\n            await self.sync_new_models_from_space()\n        except Exception as e:\n            self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')\n            self.ap.logger.warning(f'  - Error: {e}')\n\n    async def load_models_from_db(self):\n        \"\"\"Load models from database\"\"\"\n        self.ap.logger.info('Loading models from db...')\n\n        self.llm_models = []\n        self.embedding_models = []\n\n        # Load all providers first\n        self.provider_dict = {}\n        providers_result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.ModelProvider)\n        )\n        for provider in providers_result.all():\n            try:\n                runtime_provider = await self.load_provider(provider)\n                self.provider_dict[provider.uuid] = runtime_provider\n            except provider_errors.RequesterNotFoundError as e:\n                self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping provider {provider.uuid}')\n                continue\n            except Exception as e:\n                self.ap.logger.error(f'Failed to load provider {provider.uuid}: {e}\\n{traceback.format_exc()}')\n\n        # Load LLM models\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))\n        llm_models = result.all()\n        for llm_model in llm_models:\n            try:\n                provider = self.provider_dict.get(llm_model.provider_uuid)\n                if provider is None:\n                    self.ap.logger.warning(f'Provider {llm_model.provider_uuid} not found for model {llm_model.uuid}')\n                    continue\n                runtime_llm_model = await self.load_llm_model_with_provider(llm_model, provider)\n                self.llm_models.append(runtime_llm_model)\n            except Exception as e:\n                self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\\n{traceback.format_exc()}')\n\n        # Load embedding models\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))\n        embedding_models = result.all()\n        for embedding_model in embedding_models:\n            try:\n                provider = self.provider_dict.get(embedding_model.provider_uuid)\n                if provider is None:\n                    self.ap.logger.warning(\n                        f'Provider {embedding_model.provider_uuid} not found for model {embedding_model.uuid}'\n                    )\n                    continue\n                runtime_embedding_model = await self.load_embedding_model_with_provider(embedding_model, provider)\n                self.embedding_models.append(runtime_embedding_model)\n            except Exception as e:\n                self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\\n{traceback.format_exc()}')\n\n    async def sync_new_models_from_space(self):\n        \"\"\"Sync models from Space\"\"\"\n        space_model_provider = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.ModelProvider).where(\n                persistence_model.ModelProvider.requester == 'space-chat-completions'\n            )\n        )\n        result = space_model_provider.first()\n        if result is None:\n            raise provider_errors.ProviderNotFoundError('LangBot Models')\n\n        space_model_provider = result\n\n        # get the latest models from space\n        space_models = await self.ap.space_service.get_models()\n\n        exists_llm_models_uuids = [m['uuid'] for m in await self.ap.llm_model_service.get_llm_models()]\n        exists_embedding_models_uuids = [\n            m['uuid'] for m in await self.ap.embedding_models_service.get_embedding_models()\n        ]\n\n        for space_model in space_models:\n            if space_model.category == 'chat':\n                uuid = space_model.uuid\n\n                if uuid in exists_llm_models_uuids:\n                    continue\n\n                # model will be automatically loaded\n                await self.ap.llm_model_service.create_llm_model(\n                    {\n                        'uuid': space_model.uuid,\n                        'name': space_model.model_id,\n                        'provider_uuid': space_model_provider.uuid,\n                        'abilities': space_model.llm_abilities or [],\n                        'extra_args': {},\n                        'prefered_ranking': space_model.featured_order,\n                    },\n                    preserve_uuid=True,\n                    auto_set_to_default_pipeline=False,\n                )\n\n            elif space_model.category == 'embedding':\n                uuid = space_model.uuid\n\n                if uuid in exists_embedding_models_uuids:\n                    continue\n\n                # model will be automatically loaded\n                await self.ap.embedding_models_service.create_embedding_model(\n                    {\n                        'uuid': space_model.uuid,\n                        'name': space_model.model_id,\n                        'provider_uuid': space_model_provider.uuid,\n                        'extra_args': {},\n                        'prefered_ranking': space_model.featured_order,\n                    },\n                    preserve_uuid=True,\n                )\n\n    async def init_temporary_runtime_llm_model(\n        self,\n        model_info: dict,\n    ) -> requester.RuntimeLLMModel:\n        \"\"\"Initialize runtime LLM model from dict (for testing)\"\"\"\n        provider_info = model_info.get('provider', {})\n\n        runtime_provider = await self.load_provider(provider_info)\n\n        runtime_llm_model = requester.RuntimeLLMModel(\n            model_entity=persistence_model.LLMModel(\n                uuid=model_info.get('uuid', ''),\n                name=model_info.get('name', ''),\n                provider_uuid='',\n                abilities=model_info.get('abilities', []),\n                extra_args=model_info.get('extra_args', {}),\n            ),\n            provider=runtime_provider,\n        )\n\n        return runtime_llm_model\n\n    async def init_temporary_runtime_embedding_model(\n        self,\n        model_info: dict,\n    ) -> requester.RuntimeEmbeddingModel:\n        \"\"\"Initialize runtime embedding model from dict (for testing)\"\"\"\n        provider_info = model_info.get('provider', {})\n        runtime_provider = await self.load_provider(provider_info)\n\n        runtime_embedding_model = requester.RuntimeEmbeddingModel(\n            model_entity=persistence_model.EmbeddingModel(\n                uuid=model_info.get('uuid', ''),\n                name=model_info.get('name', ''),\n                provider_uuid='',\n                extra_args=model_info.get('extra_args', {}),\n            ),\n            provider=runtime_provider,\n        )\n\n        return runtime_embedding_model\n\n    async def load_provider(\n        self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict\n    ) -> requester.RuntimeProvider:\n        \"\"\"Load provider from dict\"\"\"\n        if isinstance(provider_info, sqlalchemy.Row):\n            provider_entity = persistence_model.ModelProvider(**provider_info._mapping)\n        elif isinstance(provider_info, dict):\n            provider_entity = persistence_model.ModelProvider(**provider_info)\n        else:\n            provider_entity = provider_info\n\n        if provider_entity.requester not in self.requester_dict:\n            raise provider_errors.RequesterNotFoundError(provider_entity.requester)\n\n        requester_inst = self.requester_dict[provider_entity.requester](\n            ap=self.ap, config={'base_url': provider_entity.base_url}\n        )\n        await requester_inst.initialize()\n\n        token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])\n\n        provider = requester.RuntimeProvider(\n            provider_entity=provider_entity,\n            token_mgr=token_mgr,\n            requester=requester_inst,\n        )\n        return provider\n\n    async def remove_provider(self, provider_uuid: str):\n        \"\"\"Remove provider\n\n        This method will not consider the models using this provider,\n        because the models should be removed by the caller.\n        \"\"\"\n        del self.provider_dict[provider_uuid]\n\n    async def reload_provider(self, provider_uuid: str):\n        \"\"\"Reload provider\"\"\"\n        provider_entity = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_model.ModelProvider).where(\n                persistence_model.ModelProvider.uuid == provider_uuid\n            )\n        )\n        provider_entity = provider_entity.first()\n        if provider_entity is None:\n            raise provider_errors.ProviderNotFoundError(provider_uuid)\n\n        new_runtime_provider = await self.load_provider(provider_entity)\n\n        # update refs in runtime models\n        for model in self.llm_models:\n            if model.provider.provider_entity.uuid == provider_uuid:\n                model.provider = new_runtime_provider\n        for model in self.embedding_models:\n            if model.provider.provider_entity.uuid == provider_uuid:\n                model.provider = new_runtime_provider\n\n        # update ref in provider dict\n        self.provider_dict[provider_uuid] = new_runtime_provider\n\n    async def load_llm_model_with_provider(\n        self,\n        model_info: persistence_model.LLMModel | sqlalchemy.Row,\n        provider: requester.RuntimeProvider,\n    ) -> requester.RuntimeLLMModel:\n        \"\"\"Load LLM model with provider info\"\"\"\n        if isinstance(model_info, sqlalchemy.Row):\n            model_info = persistence_model.LLMModel(**model_info._mapping)\n\n        runtime_llm_model = requester.RuntimeLLMModel(\n            model_entity=model_info,\n            provider=provider,\n        )\n\n        return runtime_llm_model\n\n    async def load_embedding_model_with_provider(\n        self,\n        model_info: persistence_model.EmbeddingModel | sqlalchemy.Row,\n        provider: requester.RuntimeProvider,\n    ) -> requester.RuntimeEmbeddingModel:\n        \"\"\"Load embedding model with provider info\"\"\"\n        if isinstance(model_info, sqlalchemy.Row):\n            model_info = persistence_model.EmbeddingModel(**model_info._mapping)\n\n        runtime_embedding_model = requester.RuntimeEmbeddingModel(\n            model_entity=model_info,\n            provider=provider,\n        )\n\n        return runtime_embedding_model\n\n    async def load_llm_model(self, model_info: dict):\n        \"\"\"Load LLM model from dict (with provider info)\"\"\"\n        provider_info = model_info.get('provider', {})\n        if not provider_info:\n            raise ValueError('Provider info is required')\n\n        model_entity = persistence_model.LLMModel(\n            uuid=model_info.get('uuid', ''),\n            name=model_info.get('name', ''),\n            provider_uuid=model_info.get('provider_uuid', ''),\n            abilities=model_info.get('abilities', []),\n            extra_args=model_info.get('extra_args', {}),\n        )\n\n        provider_entity = persistence_model.ModelProvider(\n            uuid=provider_info.get('uuid', ''),\n            name=provider_info.get('name', ''),\n            requester=provider_info.get('requester', ''),\n            base_url=provider_info.get('base_url', ''),\n            api_keys=provider_info.get('api_keys', []),\n        )\n\n        await self.load_llm_model_with_provider(model_entity, provider_entity)\n\n    async def load_embedding_model(self, model_info: dict):\n        \"\"\"Load embedding model from dict (with provider info)\"\"\"\n        provider_info = model_info.get('provider', {})\n        if not provider_info:\n            raise ValueError('Provider info is required')\n\n        model_entity = persistence_model.EmbeddingModel(\n            uuid=model_info.get('uuid', ''),\n            name=model_info.get('name', ''),\n            provider_uuid=model_info.get('provider_uuid', ''),\n            extra_args=model_info.get('extra_args', {}),\n        )\n\n        provider_entity = persistence_model.ModelProvider(\n            uuid=provider_info.get('uuid', ''),\n            name=provider_info.get('name', ''),\n            requester=provider_info.get('requester', ''),\n            base_url=provider_info.get('base_url', ''),\n            api_keys=provider_info.get('api_keys', []),\n        )\n\n        await self.load_embedding_model_with_provider(model_entity, provider_entity)\n\n    @alru_cache(ttl=60 * 5)\n    async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:\n        \"\"\"Get LLM model by uuid\"\"\"\n        for model in self.llm_models:\n            if model.model_entity.uuid == uuid:\n                return model\n        raise ValueError(f'LLM model {uuid} not found')\n\n    @alru_cache(ttl=60 * 5)\n    async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:\n        \"\"\"Get embedding model by uuid\"\"\"\n        for model in self.embedding_models:\n            if model.model_entity.uuid == uuid:\n                return model\n        raise ValueError(f'Embedding model {uuid} not found')\n\n    async def remove_llm_model(self, model_uuid: str):\n        \"\"\"Remove LLM model\"\"\"\n        for model in self.llm_models:\n            if model.model_entity.uuid == model_uuid:\n                self.llm_models.remove(model)\n                return\n\n    async def remove_embedding_model(self, model_uuid: str):\n        \"\"\"Remove embedding model\"\"\"\n        for model in self.embedding_models:\n            if model.model_entity.uuid == model_uuid:\n                self.embedding_models.remove(model)\n                return\n\n    def get_available_requesters_info(self, model_type: str) -> list[dict]:\n        \"\"\"Get all available requesters\"\"\"\n        if model_type != '':\n            return [\n                component.to_plain_dict()\n                for component in self.requester_components\n                if model_type in component.spec['support_type']\n            ]\n        else:\n            return [component.to_plain_dict() for component in self.requester_components]\n\n    def get_available_requester_info_by_name(self, name: str) -> dict | None:\n        \"\"\"Get requester info by name\"\"\"\n        for component in self.requester_components:\n            if component.metadata.name == name:\n                return component.to_plain_dict()\n        return None\n\n    def get_available_requester_manifest_by_name(self, name: str) -> engine.Component | None:\n        \"\"\"Get requester manifest by name\"\"\"\n        for component in self.requester_components:\n            if component.metadata.name == name:\n                return component\n        return None\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requester.py",
    "content": "from __future__ import annotations\n\nimport abc\nimport typing\nimport time\n\nfrom ...core import app\nfrom ...entity.persistence import model as persistence_model\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\nfrom . import token\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nclass RuntimeProvider:\n    \"\"\"运行时模型提供商\"\"\"\n\n    provider_entity: persistence_model.ModelProvider\n    \"\"\"提供商数据\"\"\"\n\n    token_mgr: token.TokenManager\n    \"\"\"api key管理器\"\"\"\n\n    requester: ProviderAPIRequester\n    \"\"\"请求器实例\"\"\"\n\n    def __init__(\n        self,\n        provider_entity: persistence_model.ModelProvider,\n        token_mgr: token.TokenManager,\n        requester: ProviderAPIRequester,\n    ):\n        self.provider_entity = provider_entity\n        self.token_mgr = token_mgr\n        self.requester = requester\n\n    async def invoke_llm(\n        self,\n        query: pipeline_query.Query,\n        model: RuntimeLLMModel,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message:\n        \"\"\"Bridge method for invoking LLM with monitoring\"\"\"\n        # Start timing for monitoring\n        start_time = time.time()\n        input_tokens = 0\n        output_tokens = 0\n        status = 'success'\n        error_message = None\n\n        try:\n            # Call the underlying requester\n            result = await self.requester.invoke_llm(\n                query=query,\n                model=model,\n                messages=messages,\n                funcs=funcs,\n                extra_args=extra_args,\n                remove_think=remove_think,\n            )\n\n            # Try to extract token usage if the requester returns it\n            # For requesters that return tuple (message, usage_info)\n            if isinstance(result, tuple):\n                msg, usage_info = result\n                if usage_info:\n                    input_tokens = usage_info.get('input_tokens', 0)\n                    output_tokens = usage_info.get('output_tokens', 0)\n                return msg\n            else:\n                return result\n\n        except Exception as e:\n            status = 'error'\n            error_message = str(e)\n            raise\n        finally:\n            # Record LLM call monitoring data (only if query is provided)\n            if query is not None:\n                duration_ms = int((time.time() - start_time) * 1000)\n\n                # Import monitoring helper\n                try:\n                    from ...pipeline import monitoring_helper\n\n                    # Get monitoring metadata from query variables\n                    if query.variables:\n                        bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')\n                        pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')\n                        message_id = query.variables.get('_monitoring_message_id')\n                    else:\n                        bot_name = 'Unknown'\n                        pipeline_name = 'Unknown'\n                        message_id = None\n\n                    await monitoring_helper.MonitoringHelper.record_llm_call(\n                        ap=self.requester.ap,\n                        query=query,\n                        bot_id=query.bot_uuid or 'unknown',\n                        bot_name=bot_name,\n                        pipeline_id=query.pipeline_uuid or 'unknown',\n                        pipeline_name=pipeline_name,\n                        model_name=model.model_entity.name,\n                        input_tokens=input_tokens,\n                        output_tokens=output_tokens,\n                        duration_ms=duration_ms,\n                        status=status,\n                        error_message=error_message,\n                        message_id=message_id,\n                    )\n                except Exception as monitor_err:\n                    self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {monitor_err}')\n\n    async def invoke_llm_stream(\n        self,\n        query: pipeline_query.Query,\n        model: RuntimeLLMModel,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.MessageChunk:\n        \"\"\"Bridge method for invoking LLM stream with monitoring\"\"\"\n        # Start timing for monitoring\n        start_time = time.time()\n        status = 'success'\n        error_message = None\n        # Note: Stream doesn't easily provide token counts, set to 0\n        input_tokens = 0\n        output_tokens = 0\n\n        try:\n            # Stream the response\n            async for chunk in self.requester.invoke_llm_stream(\n                query=query,\n                model=model,\n                messages=messages,\n                funcs=funcs,\n                extra_args=extra_args,\n                remove_think=remove_think,\n            ):\n                yield chunk\n        except Exception as e:\n            status = 'error'\n            error_message = str(e)\n            raise\n        finally:\n            # Record LLM call monitoring data (only if query is provided)\n            if query is not None:\n                duration_ms = int((time.time() - start_time) * 1000)\n\n                # Import monitoring helper\n                try:\n                    from ...pipeline import monitoring_helper\n\n                    # Get monitoring metadata from query variables\n                    if query.variables:\n                        bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')\n                        pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')\n                        message_id = query.variables.get('_monitoring_message_id')\n                    else:\n                        bot_name = 'Unknown'\n                        pipeline_name = 'Unknown'\n                        message_id = None\n\n                    await monitoring_helper.MonitoringHelper.record_llm_call(\n                        ap=self.requester.ap,\n                        query=query,\n                        bot_id=query.bot_uuid or 'unknown',\n                        bot_name=bot_name,\n                        pipeline_id=query.pipeline_uuid or 'unknown',\n                        pipeline_name=pipeline_name,\n                        model_name=model.model_entity.name,\n                        input_tokens=input_tokens,\n                        output_tokens=output_tokens,\n                        duration_ms=duration_ms,\n                        status=status,\n                        error_message=error_message,\n                        message_id=message_id,\n                    )\n                except Exception as monitor_err:\n                    self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM stream call: {monitor_err}')\n\n    async def invoke_embedding(\n        self,\n        model: RuntimeEmbeddingModel,\n        input_text: typing.List[str],\n        extra_args: dict[str, typing.Any] = {},\n        knowledge_base_id: str | None = None,\n        query_text: str | None = None,\n        session_id: str | None = None,\n        message_id: str | None = None,\n        call_type: str | None = None,\n    ) -> typing.List[typing.List[float]]:\n        \"\"\"Bridge method for invoking embedding with monitoring\"\"\"\n        # Start timing for monitoring\n        start_time = time.time()\n        prompt_tokens = 0\n        total_tokens = 0\n        status = 'success'\n        error_message = None\n\n        try:\n            # Call the underlying requester\n            result = await self.requester.invoke_embedding(\n                model=model,\n                input_text=input_text,\n                extra_args=extra_args,\n            )\n\n            # Handle both old format (list only) and new format (tuple with usage)\n            if isinstance(result, tuple):\n                embeddings, usage_info = result\n                if usage_info:\n                    prompt_tokens = usage_info.get('prompt_tokens', 0)\n                    total_tokens = usage_info.get('total_tokens', 0)\n                return embeddings\n            else:\n                return result\n\n        except Exception as e:\n            status = 'error'\n            error_message = str(e)\n            raise\n        finally:\n            # Record embedding call monitoring data\n            duration_ms = int((time.time() - start_time) * 1000)\n\n            try:\n                await self.requester.ap.monitoring_service.record_embedding_call(\n                    model_name=model.model_entity.name,\n                    prompt_tokens=prompt_tokens,\n                    total_tokens=total_tokens,\n                    duration=duration_ms,\n                    input_count=len(input_text),\n                    status=status,\n                    error_message=error_message,\n                    knowledge_base_id=knowledge_base_id,\n                    query_text=query_text,\n                    session_id=session_id,\n                    message_id=message_id,\n                    call_type=call_type,\n                )\n            except Exception as monitor_err:\n                self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}')\n\n\nclass RuntimeLLMModel:\n    \"\"\"运行时模型\"\"\"\n\n    model_entity: persistence_model.LLMModel\n    \"\"\"模型数据\"\"\"\n\n    provider: RuntimeProvider\n    \"\"\"提供商实例\"\"\"\n\n    def __init__(\n        self,\n        model_entity: persistence_model.LLMModel,\n        provider: RuntimeProvider,\n    ):\n        self.model_entity = model_entity\n        self.provider = provider\n\n\nclass RuntimeEmbeddingModel:\n    \"\"\"运行时 Embedding 模型\"\"\"\n\n    model_entity: persistence_model.EmbeddingModel\n    \"\"\"模型数据\"\"\"\n\n    provider: RuntimeProvider\n    \"\"\"提供商实例\"\"\"\n\n    def __init__(\n        self,\n        model_entity: persistence_model.EmbeddingModel,\n        provider: RuntimeProvider,\n    ):\n        self.model_entity = model_entity\n        self.provider = provider\n\n\nclass ProviderAPIRequester(metaclass=abc.ABCMeta):\n    \"\"\"Provider API请求器\"\"\"\n\n    name: str = None\n\n    ap: app.Application\n\n    default_config: dict[str, typing.Any] = {}\n\n    requester_cfg: dict[str, typing.Any] = {}\n\n    def __init__(self, ap: app.Application, config: dict[str, typing.Any]):\n        self.ap = ap\n        self.requester_cfg = {**self.default_config}\n        self.requester_cfg.update(config)\n\n    async def initialize(self):\n        pass\n\n    @abc.abstractmethod\n    async def invoke_llm(\n        self,\n        query: pipeline_query.Query,\n        model: RuntimeLLMModel,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message:\n        \"\"\"调用API\n\n        Args:\n            model (RuntimeLLMModel): 使用的模型信息\n            messages (typing.List[llm_entities.Message]): 消息对象列表\n            funcs (typing.List[tools_entities.LLMFunction], optional): 使用的工具函数列表. Defaults to None.\n            extra_args (dict[str, typing.Any], optional): 额外的参数. Defaults to {}.\n            remove_think (bool, optional): 是否移思考中的消息. Defaults to False.\n\n        Returns:\n            llm_entities.Message: 返回消息对象\n        \"\"\"\n        pass\n\n    async def invoke_llm_stream(\n        self,\n        query: pipeline_query.Query,\n        model: RuntimeLLMModel,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.MessageChunk:\n        \"\"\"调用API\n\n        Args:\n            model (RuntimeLLMModel): 使用的模型信息\n            messages (typing.List[provider_message.Message]): 消息对象列表\n            funcs (typing.List[resource_tool.LLMTool], optional): 使用的工具函数列表. Defaults to None.\n            extra_args (dict[str, typing.Any], optional): 额外的参数. Defaults to {}.\n            remove_think (bool, optional): 是否移除思考中的消息. Defaults to False.\n\n        Returns:\n            typing.AsyncGenerator[provider_message.MessageChunk]: 返回消息对象\n        \"\"\"\n        pass\n\n    async def invoke_embedding(\n        self,\n        model: RuntimeEmbeddingModel,\n        input_text: typing.List[str],\n        extra_args: dict[str, typing.Any] = {},\n    ) -> typing.Union[typing.List[typing.List[float]], tuple[typing.List[typing.List[float]], dict]]:\n        \"\"\"调用 Embedding API\n\n        Args:\n            model (RuntimeEmbeddingModel): 使用的模型信息\n            input_text (typing.List[str]): 输入文本\n            extra_args (dict[str, typing.Any], optional): 额外的参数. Defaults to {}.\n\n        Returns:\n            typing.List[typing.List[float]]: 返回的 embedding 向量\n            或者 tuple[typing.List[typing.List[float]], dict]: 返回 (embedding 向量, usage_info)\n        \"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requester.yaml",
    "content": "apiVersion: v1\nkind: ComponentTemplate\nmetadata:\n  name: LLMAPIRequester\n  label:\n    en_US: LLM API Requester\n    zh_Hans: LLM API 请求器\nspec:\n  type:\n    - python\nexecution:\n  python:\n    path: ./requester.py\n    attr: LLMAPIRequester"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import chatcmpl\n\n\nclass AI302ChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"302.AI ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.302.ai/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: 302-ai-chat-completions\n  label:\n    en_US: 302.AI\n    zh_Hans: 302.AI\n  icon: 302ai.png\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.302.ai/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./302aichatcmpl.py\n    attr: AI302ChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport json\nimport platform\nimport socket\nimport anthropic\nimport httpx\n\nfrom .. import errors, requester\n\nfrom ....utils import image\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nclass AnthropicMessages(requester.ProviderAPIRequester):\n    \"\"\"Anthropic Messages API 请求器\"\"\"\n\n    client: anthropic.AsyncAnthropic\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.anthropic.com',\n        'timeout': 120,\n    }\n\n    async def initialize(self):\n        # 兼容 Windows 缺失 TCP_KEEPINTVL 和 TCP_KEEPCNT 的问题\n        if platform.system() == 'Windows':\n            if not hasattr(socket, 'TCP_KEEPINTVL'):\n                socket.TCP_KEEPINTVL = 0\n            if not hasattr(socket, 'TCP_KEEPCNT'):\n                socket.TCP_KEEPCNT = 0\n        httpx_client = anthropic._base_client.AsyncHttpxClientWrapper(\n            base_url=self.requester_cfg['base_url'],\n            # cast to a valid type because mypy doesn't understand our type narrowing\n            timeout=typing.cast(httpx.Timeout, self.requester_cfg['timeout']),\n            limits=anthropic._constants.DEFAULT_CONNECTION_LIMITS,\n            follow_redirects=True,\n            trust_env=True,\n        )\n\n        self.client = anthropic.AsyncAnthropic(\n            api_key='',\n            http_client=httpx_client,\n            base_url=self.requester_cfg['base_url'],\n        )\n\n    async def invoke_llm(\n        self,\n        query: pipeline_query.Query,\n        model: requester.RuntimeLLMModel,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message:\n        self.client.api_key = model.provider.token_mgr.get_token()\n\n        args = extra_args.copy()\n        args['model'] = model.model_entity.name\n\n        # 处理消息\n\n        # system\n        system_role_message = None\n\n        for i, m in enumerate(messages):\n            if m.role == 'system':\n                system_role_message = m\n\n                break\n\n        if system_role_message:\n            messages.pop(i)\n\n        if isinstance(system_role_message, provider_message.Message) and isinstance(system_role_message.content, str):\n            args['system'] = system_role_message.content\n\n        req_messages = []\n\n        for m in messages:\n            if m.role == 'tool':\n                tool_call_id = m.tool_call_id\n\n                req_messages.append(\n                    {\n                        'role': 'user',\n                        'content': [\n                            {\n                                'type': 'tool_result',\n                                'tool_use_id': tool_call_id,\n                                'is_error': False,\n                                'content': [{'type': 'text', 'text': m.content}],\n                            }\n                        ],\n                    }\n                )\n\n                continue\n\n            msg_dict = m.dict(exclude_none=True)\n\n            if isinstance(m.content, str) and m.content.strip() != '':\n                msg_dict['content'] = [{'type': 'text', 'text': m.content}]\n            elif isinstance(m.content, list):\n                for i, ce in enumerate(m.content):\n                    if ce.type == 'image_base64':\n                        image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)\n\n                        alter_image_ele = {\n                            'type': 'image',\n                            'source': {\n                                'type': 'base64',\n                                'media_type': f'image/{image_format}',\n                                'data': image_b64,\n                            },\n                        }\n                        msg_dict['content'][i] = alter_image_ele\n\n            if m.tool_calls:\n                for tool_call in m.tool_calls:\n                    msg_dict['content'].append(\n                        {\n                            'type': 'tool_use',\n                            'id': tool_call.id,\n                            'name': tool_call.function.name,\n                            'input': json.loads(tool_call.function.arguments),\n                        }\n                    )\n\n                del msg_dict['tool_calls']\n\n            req_messages.append(msg_dict)\n\n        args['messages'] = req_messages\n\n        if 'thinking' in args:\n            args['thinking'] = {'type': 'enabled', 'budget_tokens': 10000}\n\n        if funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_anthropic(funcs)\n\n            if tools:\n                args['tools'] = tools\n\n        try:\n            resp = await self.client.messages.create(**args)\n\n            args = {\n                'content': '',\n                'role': resp.role,\n            }\n            assert type(resp) is anthropic.types.message.Message\n\n            for block in resp.content:\n                if not remove_think and block.type == 'thinking':\n                    args['content'] = '<think>\\n' + block.thinking + '\\n</think>\\n' + args['content']\n                elif block.type == 'text':\n                    args['content'] += block.text\n                elif block.type == 'tool_use':\n                    assert type(block) is anthropic.types.tool_use_block.ToolUseBlock\n                    tool_call = provider_message.ToolCall(\n                        id=block.id,\n                        type='function',\n                        function=provider_message.FunctionCall(name=block.name, arguments=json.dumps(block.input)),\n                    )\n                    if 'tool_calls' not in args:\n                        args['tool_calls'] = []\n                    args['tool_calls'].append(tool_call)\n\n            return provider_message.Message(**args)\n        except anthropic.AuthenticationError as e:\n            raise errors.RequesterError(f'api-key 无效: {e.message}')\n        except anthropic.BadRequestError as e:\n            raise errors.RequesterError(str(e.message))\n        except anthropic.NotFoundError as e:\n            if 'model: ' in str(e):\n                raise errors.RequesterError(f'模型无效: {e.message}')\n            else:\n                raise errors.RequesterError(f'请求地址无效: {e.message}')\n\n    async def invoke_llm_stream(\n        self,\n        query: pipeline_query.Query,\n        model: requester.RuntimeLLMModel,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message:\n        self.client.api_key = model.provider.token_mgr.get_token()\n\n        args = extra_args.copy()\n        args['model'] = model.model_entity.name\n        args['stream'] = True\n\n        # 处理消息\n\n        # system\n        system_role_message = None\n\n        for i, m in enumerate(messages):\n            if m.role == 'system':\n                system_role_message = m\n\n                break\n\n        if system_role_message:\n            messages.pop(i)\n\n        if isinstance(system_role_message, provider_message.Message) and isinstance(system_role_message.content, str):\n            args['system'] = system_role_message.content\n\n        req_messages = []\n\n        for m in messages:\n            if m.role == 'tool':\n                tool_call_id = m.tool_call_id\n\n                req_messages.append(\n                    {\n                        'role': 'user',\n                        'content': [\n                            {\n                                'type': 'tool_result',\n                                'tool_use_id': tool_call_id,\n                                'is_error': False,  # 暂时直接写false\n                                'content': [\n                                    {'type': 'text', 'text': m.content}\n                                ],  # 这里要是list包裹，应该是多个返回的情况？type类型好像也可以填其他的，暂时只写text\n                            }\n                        ],\n                    }\n                )\n\n                continue\n\n            msg_dict = m.dict(exclude_none=True)\n\n            if isinstance(m.content, str) and m.content.strip() != '':\n                msg_dict['content'] = [{'type': 'text', 'text': m.content}]\n            elif isinstance(m.content, list):\n                for i, ce in enumerate(m.content):\n                    if ce.type == 'image_base64':\n                        image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)\n\n                        alter_image_ele = {\n                            'type': 'image',\n                            'source': {\n                                'type': 'base64',\n                                'media_type': f'image/{image_format}',\n                                'data': image_b64,\n                            },\n                        }\n                        msg_dict['content'][i] = alter_image_ele\n            if isinstance(msg_dict['content'], str) and msg_dict['content'] == '':\n                msg_dict['content'] = []  # 这里不知道为什么会莫名有个空导致content为字符\n            if m.tool_calls:\n                for tool_call in m.tool_calls:\n                    msg_dict['content'].append(\n                        {\n                            'type': 'tool_use',\n                            'id': tool_call.id,\n                            'name': tool_call.function.name,\n                            'input': json.loads(tool_call.function.arguments),\n                        }\n                    )\n\n                del msg_dict['tool_calls']\n\n            req_messages.append(msg_dict)\n        if 'thinking' in args:\n            args['thinking'] = {'type': 'enabled', 'budget_tokens': 10000}\n\n        args['messages'] = req_messages\n\n        if funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_anthropic(funcs)\n\n            if tools:\n                args['tools'] = tools\n\n        try:\n            role = 'assistant'  # 默认角色\n            # chunk_idx = 0\n            think_started = False\n            think_ended = False\n            finish_reason = False\n            content = ''\n            tool_name = ''\n            tool_id = ''\n            async for chunk in await self.client.messages.create(**args):\n                tool_call = {'id': None, 'function': {'name': None, 'arguments': None}, 'type': 'function'}\n                if isinstance(\n                    chunk, anthropic.types.raw_content_block_start_event.RawContentBlockStartEvent\n                ):  # 记录开始\n                    if chunk.content_block.type == 'tool_use':\n                        if chunk.content_block.name is not None:\n                            tool_name = chunk.content_block.name\n                        if chunk.content_block.id is not None:\n                            tool_id = chunk.content_block.id\n\n                        tool_call['function']['name'] = tool_name\n                        tool_call['function']['arguments'] = ''\n                        tool_call['id'] = tool_id\n\n                    if not remove_think:\n                        if chunk.content_block.type == 'thinking' and not remove_think:\n                            think_started = True\n                        elif chunk.content_block.type == 'text' and chunk.index != 0 and not remove_think:\n                            think_ended = True\n                        continue\n                elif isinstance(chunk, anthropic.types.raw_content_block_delta_event.RawContentBlockDeltaEvent):\n                    if chunk.delta.type == 'thinking_delta':\n                        if think_started:\n                            think_started = False\n                            content = '<think>\\n' + chunk.delta.thinking\n                        elif remove_think:\n                            continue\n                        else:\n                            content = chunk.delta.thinking\n                    elif chunk.delta.type == 'text_delta':\n                        if think_ended:\n                            think_ended = False\n                            content = '\\n</think>\\n' + chunk.delta.text\n                        else:\n                            content = chunk.delta.text\n                    elif chunk.delta.type == 'input_json_delta':\n                        tool_call['function']['arguments'] = chunk.delta.partial_json\n                        tool_call['function']['name'] = tool_name\n                        tool_call['id'] = tool_id\n                elif isinstance(chunk, anthropic.types.raw_content_block_stop_event.RawContentBlockStopEvent):\n                    continue  # 记录raw_content_block结束的\n\n                elif isinstance(chunk, anthropic.types.raw_message_delta_event.RawMessageDeltaEvent):\n                    if chunk.delta.stop_reason == 'end_turn':\n                        finish_reason = True\n                elif isinstance(chunk, anthropic.types.raw_message_stop_event.RawMessageStopEvent):\n                    continue  # 这个好像是完全结束\n                else:\n                    # print(chunk)\n                    self.ap.logger.debug(f'anthropic chunk: {chunk}')\n                    continue\n\n                args = {\n                    'content': content,\n                    'role': role,\n                    'is_final': finish_reason,\n                    'tool_calls': None if tool_call['id'] is None else [tool_call],\n                }\n                # if chunk_idx == 0:\n                #     chunk_idx += 1\n                #     continue\n\n                # assert type(chunk) is anthropic.types.message.Chunk\n\n                yield provider_message.MessageChunk(**args)\n\n            # return llm_entities.Message(**args)\n        except anthropic.AuthenticationError as e:\n            raise errors.RequesterError(f'api-key 无效: {e.message}')\n        except anthropic.BadRequestError as e:\n            raise errors.RequesterError(str(e.message))\n        except anthropic.NotFoundError as e:\n            if 'model: ' in str(e):\n                raise errors.RequesterError(f'模型无效: {e.message}')\n            else:\n                raise errors.RequesterError(f'请求地址无效: {e.message}')\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: anthropic-messages\n  label:\n    en_US: Anthropic\n    zh_Hans: Anthropic\n  icon: anthropic.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.anthropic.com\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  provider_category: manufacturer\nexecution:\n  python:\n    path: ./anthropicmsgs.py\n    attr: AnthropicMessages\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport dashscope\nimport openai\n\nfrom . import modelscopechatcmpl\nfrom .. import requester\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nclass BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions):\n    \"\"\"阿里云百炼大模型平台 ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n        'timeout': 120,\n    }\n\n    async def _closure_stream(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]:\n        self.client.api_key = use_model.provider.token_mgr.get_token()\n\n        args = {}\n        args['model'] = use_model.model_entity.name\n\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n\n            if tools:\n                args['tools'] = tools\n\n        # 设置此次请求中的messages\n        messages = req_messages.copy()\n\n        is_use_dashscope_call = False  # 是否使用阿里原生库调用\n        is_enable_multi_model = True  # 是否支持多轮对话\n        use_time_num = 0  # 模型已调用次数，防止存在多文件时重复调用\n        use_time_ids = []  # 已调用的ID列表\n        message_id = 0  # 记录消息序号\n\n        for msg in messages:\n            # print(msg)\n            if 'content' in msg and isinstance(msg['content'], list):\n                for me in msg['content']:\n                    if me['type'] == 'image_base64':\n                        me['image_url'] = {'url': me['image_base64']}\n                        me['type'] = 'image_url'\n                        del me['image_base64']\n                    elif me['type'] == 'file_url' and '.' in me.get('file_name', ''):\n                        # 1. 视频文件推理\n                        # https://bailian.console.aliyun.com/?tab=doc#/doc/?type=model&url=2845871\n                        file_type = me.get('file_name').lower().split('.')[-1]\n                        if file_type in ['mp4', 'avi', 'mkv', 'mov', 'flv', 'wmv']:\n                            me['type'] = 'video_url'\n                            me['video_url'] = {'url': me['file_url']}\n                            del me['file_url']\n                            del me['file_name']\n                            use_time_num += 1\n                            use_time_ids.append(message_id)\n                            is_enable_multi_model = False\n                        # 2. 语音文件识别, 无法通过openai的audio字段传递，暂时不支持\n                        # https://bailian.console.aliyun.com/?tab=doc#/doc/?type=model&url=2979031\n                        elif file_type in [\n                            'aac',\n                            'amr',\n                            'aiff',\n                            'flac',\n                            'm4a',\n                            'mp3',\n                            'mpeg',\n                            'ogg',\n                            'opus',\n                            'wav',\n                            'webm',\n                            'wma',\n                        ]:\n                            me['audio'] = me['file_url']\n                            me['type'] = 'audio'\n                            del me['file_url']\n                            del me['type']\n                            del me['file_name']\n                            is_use_dashscope_call = True\n                            use_time_num += 1\n                            use_time_ids.append(message_id)\n                            is_enable_multi_model = False\n            message_id += 1\n\n        # 使用列表推导式，保留不在 use_time_ids[:-1] 中的元素，仅保留最后一个多媒体消息\n        if not is_enable_multi_model and use_time_num > 1:\n            messages = [msg for idx, msg in enumerate(messages) if idx not in use_time_ids[:-1]]\n\n        if not is_enable_multi_model:\n            messages = [msg for msg in messages if 'resp_message_id' not in msg]\n\n        args['messages'] = messages\n        args['stream'] = True\n\n        # 流式处理状态\n        # tool_calls_map: dict[str, provider_message.ToolCall] = {}\n        chunk_idx = 0\n        thinking_started = False\n        thinking_ended = False\n        role = 'assistant'  # 默认角色\n\n        if is_use_dashscope_call:\n            response = dashscope.MultiModalConversation.call(\n                # 若没有配置环境变量，请用百炼API Key将下行替换为：api_key = \"sk-xxx\"\n                api_key=use_model.provider.token_mgr.get_token(),\n                model=use_model.model_entity.name,\n                messages=messages,\n                result_format='message',\n                asr_options={\n                    # \"language\": \"zh\", # 可选，若已知音频的语种，可通过该参数指定待识别语种，以提升识别准确率\n                    'enable_lid': True,\n                    'enable_itn': False,\n                },\n                stream=True,\n            )\n            content_length_list = []\n            previous_length = 0  # 记录上一次的内容长度\n            for res in response:\n                chunk = res['output']\n                # 解析 chunk 数据\n                if hasattr(chunk, 'choices') and chunk.choices:\n                    choice = chunk.choices[0]\n                    delta_content = choice['message'].content[0]['text']\n                    finish_reason = choice['finish_reason']\n                    content_length_list.append(len(delta_content))\n                else:\n                    delta_content = ''\n                    finish_reason = None\n\n                # 跳过空的第一个 chunk（只有 role 没有内容）\n                if chunk_idx == 0 and not delta_content:\n                    chunk_idx += 1\n                    continue\n\n                # 检查 content_length_list 是否有足够的数据\n                if len(content_length_list) >= 2:\n                    now_content = delta_content[previous_length : content_length_list[-1]]\n                    previous_length = content_length_list[-1]  # 更新上一次的长度\n                else:\n                    now_content = delta_content  # 第一次循环时直接使用 delta_content\n                    previous_length = len(delta_content)  # 更新上一次的长度\n\n                # 构建 MessageChunk - 只包含增量内容\n                chunk_data = {\n                    'role': role,\n                    'content': now_content if now_content else None,\n                    'is_final': bool(finish_reason) and finish_reason != 'null',\n                }\n\n                # 移除 None 值\n                chunk_data = {k: v for k, v in chunk_data.items() if v is not None}\n                yield provider_message.MessageChunk(**chunk_data)\n                chunk_idx += 1\n        else:\n            async for chunk in self._req_stream(args, extra_body=extra_args):\n                # 解析 chunk 数据\n                if hasattr(chunk, 'choices') and chunk.choices:\n                    choice = chunk.choices[0]\n                    delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}\n                    finish_reason = getattr(choice, 'finish_reason', None)\n                else:\n                    delta = {}\n                    finish_reason = None\n\n                # 从第一个 chunk 获取 role，后续使用这个 role\n                if 'role' in delta and delta['role']:\n                    role = delta['role']\n\n                # 获取增量内容\n                delta_content = delta.get('content', '')\n                reasoning_content = delta.get('reasoning_content', '')\n\n                # 处理 reasoning_content\n                if reasoning_content:\n                    # accumulated_reasoning += reasoning_content\n                    # 如果设置了 remove_think，跳过 reasoning_content\n                    if remove_think:\n                        chunk_idx += 1\n                        continue\n\n                    # 第一次出现 reasoning_content，添加 <think> 开始标签\n                    if not thinking_started:\n                        thinking_started = True\n                        delta_content = '<think>\\n' + reasoning_content\n                    else:\n                        # 继续输出 reasoning_content\n                        delta_content = reasoning_content\n                elif thinking_started and not thinking_ended and delta_content:\n                    # reasoning_content 结束，normal content 开始，添加 </think> 结束标签\n                    thinking_ended = True\n                    delta_content = '\\n</think>\\n' + delta_content\n\n                # 处理工具调用增量\n                if delta.get('tool_calls'):\n                    for tool_call in delta['tool_calls']:\n                        if tool_call['id'] != '':\n                            tool_id = tool_call['id']\n                        if tool_call['function']['name'] is not None:\n                            tool_name = tool_call['function']['name']\n\n                        if tool_call['type'] is None:\n                            tool_call['type'] = 'function'\n                        tool_call['id'] = tool_id\n                        tool_call['function']['name'] = tool_name\n                        tool_call['function']['arguments'] = (\n                            '' if tool_call['function']['arguments'] is None else tool_call['function']['arguments']\n                        )\n\n                # 跳过空的第一个 chunk（只有 role 没有内容）\n                if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'):\n                    chunk_idx += 1\n                    continue\n\n                # 构建 MessageChunk - 只包含增量内容\n                chunk_data = {\n                    'role': role,\n                    'content': delta_content if delta_content else None,\n                    'tool_calls': delta.get('tool_calls'),\n                    'is_final': bool(finish_reason),\n                }\n\n                # 移除 None 值\n                chunk_data = {k: v for k, v in chunk_data.items() if v is not None}\n\n                yield provider_message.MessageChunk(**chunk_data)\n                chunk_idx += 1\n                # return\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: bailian-chat-completions\n  label:\n    en_US: Aliyun Bailian\n    zh_Hans: 阿里云百炼\n  icon: bailian.png\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://dashscope.aliyuncs.com/compatible-mode/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  provider_category: maas\nexecution:\n  python:\n    path: ./bailianchatcmpl.py\n    attr: BailianChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport typing\n\nimport openai\nimport openai.types.chat.chat_completion as chat_completion_module\nimport httpx\n\nfrom .. import errors, requester\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nclass OpenAIChatCompletions(requester.ProviderAPIRequester):\n    \"\"\"OpenAI ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.openai.com/v1',\n        'timeout': 120,\n    }\n\n    async def initialize(self):\n        self.client = openai.AsyncClient(\n            api_key='',\n            base_url=self.requester_cfg['base_url'].replace(' ', ''),\n            timeout=self.requester_cfg['timeout'],\n            http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),\n        )\n\n    async def _req(\n        self,\n        args: dict,\n        extra_body: dict = {},\n    ) -> chat_completion_module.ChatCompletion:\n        return await self.client.chat.completions.create(**args, extra_body=extra_body)\n\n    async def _req_stream(\n        self,\n        args: dict,\n        extra_body: dict = {},\n    ):\n        async for chunk in await self.client.chat.completions.create(**args, extra_body=extra_body):\n            yield chunk\n\n    async def _make_msg(\n        self,\n        chat_completion: chat_completion_module.ChatCompletion,\n        remove_think: bool = False,\n    ) -> provider_message.Message:\n        if not isinstance(chat_completion, chat_completion_module.ChatCompletion):\n            raise TypeError(f'Expected ChatCompletion, got {type(chat_completion).__name__}: {chat_completion[:16]}')\n\n        chatcmpl_message = chat_completion.choices[0].message.model_dump()\n\n        # 确保 role 字段存在且不为 None\n        if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None:\n            chatcmpl_message['role'] = 'assistant'\n\n        # 处理思维链\n        content = chatcmpl_message.get('content', '')\n        reasoning_content = chatcmpl_message.get('reasoning_content', None)\n\n        processed_content, _ = await self._process_thinking_content(\n            content=content, reasoning_content=reasoning_content, remove_think=remove_think\n        )\n\n        chatcmpl_message['content'] = processed_content\n\n        # 移除 reasoning_content 字段，避免传递给 Message\n        if 'reasoning_content' in chatcmpl_message:\n            del chatcmpl_message['reasoning_content']\n\n        message = provider_message.Message(**chatcmpl_message)\n\n        return message\n\n    async def _process_thinking_content(\n        self,\n        content: str,\n        reasoning_content: str = None,\n        remove_think: bool = False,\n    ) -> tuple[str, str]:\n        \"\"\"处理思维链内容\n\n        Args:\n            content: 原始内容\n            reasoning_content: reasoning_content 字段内容\n            remove_think: 是否移除思维链\n\n        Returns:\n            (处理后的内容, 提取的思维链内容)\n        \"\"\"\n        thinking_content = ''\n\n        # 1. 从 reasoning_content 提取思维链\n        if reasoning_content:\n            thinking_content = reasoning_content\n\n        # 2. 从 content 中提取 <think> 标签内容\n        if content and '<think>' in content and '</think>' in content:\n            import re\n\n            think_pattern = r'<think>(.*?)</think>'\n            think_matches = re.findall(think_pattern, content, re.DOTALL)\n            if think_matches:\n                # 如果已有 reasoning_content，则追加\n                if thinking_content:\n                    thinking_content += '\\n' + '\\n'.join(think_matches)\n                else:\n                    thinking_content = '\\n'.join(think_matches)\n                # 移除 content 中的 <think> 标签\n                content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()\n\n        # 3. 根据 remove_think 参数决定是否保留思维链\n        if remove_think:\n            return content, ''\n        else:\n            # 如果有思维链内容，将其以 <think> 格式添加到 content 开头\n            if thinking_content:\n                content = f'<think>\\n{thinking_content}\\n</think>\\n{content}'.strip()\n            return content, thinking_content\n\n    async def _closure_stream(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.MessageChunk:\n        self.client.api_key = use_model.provider.token_mgr.get_token()\n\n        args = {}\n        args['model'] = use_model.model_entity.name\n\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n            if tools:\n                args['tools'] = tools\n\n        # 设置此次请求中的messages\n        messages = req_messages.copy()\n\n        # 检查vision\n        for msg in messages:\n            if 'content' in msg and isinstance(msg['content'], list):\n                for me in msg['content']:\n                    if me['type'] == 'image_base64':\n                        me['image_url'] = {'url': me['image_base64']}\n                        me['type'] = 'image_url'\n                        del me['image_base64']\n\n        args['messages'] = messages\n        args['stream'] = True\n\n        # 流式处理状态\n        # tool_calls_map: dict[str, provider_message.ToolCall] = {}\n        chunk_idx = 0\n        thinking_started = False\n        thinking_ended = False\n        role = 'assistant'  # 默认角色\n        tool_id = ''\n        tool_name = ''\n        # accumulated_reasoning = ''  # 仅用于判断何时结束思维链\n\n        async for chunk in self._req_stream(args, extra_body=extra_args):\n            # 解析 chunk 数据\n\n            if hasattr(chunk, 'choices') and chunk.choices:\n                choice = chunk.choices[0]\n                delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}\n\n                finish_reason = getattr(choice, 'finish_reason', None)\n            else:\n                delta = {}\n                finish_reason = None\n            # 从第一个 chunk 获取 role，后续使用这个 role\n            if 'role' in delta and delta['role']:\n                role = delta['role']\n\n            # 获取增量内容\n            delta_content = delta.get('content', '')\n            reasoning_content = delta.get('reasoning_content', '')\n\n            # 处理 reasoning_content\n            if reasoning_content:\n                # accumulated_reasoning += reasoning_content\n                # 如果设置了 remove_think，跳过 reasoning_content\n                if remove_think:\n                    chunk_idx += 1\n                    continue\n\n                # 第一次出现 reasoning_content，添加 <think> 开始标签\n                if not thinking_started:\n                    thinking_started = True\n                    delta_content = '<think>\\n' + reasoning_content\n                else:\n                    # 继续输出 reasoning_content\n                    delta_content = reasoning_content\n            elif thinking_started and not thinking_ended and delta_content:\n                # reasoning_content 结束，normal content 开始，添加 </think> 结束标签\n                thinking_ended = True\n                delta_content = '\\n</think>\\n' + delta_content\n\n            # 处理 content 中已有的 <think> 标签（如果需要移除）\n            # if delta_content and remove_think and '<think>' in delta_content:\n            #     import re\n            #\n            #     # 移除 <think> 标签及其内容\n            #     delta_content = re.sub(r'<think>.*?</think>', '', delta_content, flags=re.DOTALL)\n\n            # 处理工具调用增量\n            # delta_tool_calls = None\n            if delta.get('tool_calls'):\n                for tool_call in delta['tool_calls']:\n                    if tool_call['id'] and tool_call['function']['name']:\n                        tool_id = tool_call['id']\n                        tool_name = tool_call['function']['name']\n                    else:\n                        tool_call['id'] = tool_id\n                        tool_call['function']['name'] = tool_name\n                    if tool_call['type'] is None:\n                        tool_call['type'] = 'function'\n\n            # 跳过空的第一个 chunk（只有 role 没有内容）\n            if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'):\n                chunk_idx += 1\n                continue\n            # 构建 MessageChunk - 只包含增量内容\n            chunk_data = {\n                'role': role,\n                'content': delta_content if delta_content else None,\n                'tool_calls': delta.get('tool_calls'),\n                'is_final': bool(finish_reason),\n            }\n\n            # 移除 None 值\n            chunk_data = {k: v for k, v in chunk_data.items() if v is not None}\n\n            yield provider_message.MessageChunk(**chunk_data)\n            chunk_idx += 1\n\n    async def _closure(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> tuple[provider_message.Message, dict]:\n        self.client.api_key = use_model.provider.token_mgr.get_token()\n\n        args = {}\n        args['model'] = use_model.model_entity.name\n\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n\n            if tools:\n                args['tools'] = tools\n\n        # 设置此次请求中的messages\n        messages = req_messages.copy()\n\n        # 检查vision\n        for msg in messages:\n            if 'content' in msg and isinstance(msg['content'], list):\n                for me in msg['content']:\n                    if me['type'] == 'image_base64':\n                        me['image_url'] = {'url': me['image_base64']}\n                        me['type'] = 'image_url'\n                        del me['image_base64']\n\n        args['messages'] = messages\n\n        # 发送请求\n\n        resp = await self._req(args, extra_body=extra_args)\n        # 处理请求结果\n        message = await self._make_msg(resp, remove_think)\n\n        # Extract token usage from response\n        usage_info = {}\n        if hasattr(resp, 'usage') and resp.usage:\n            usage_info['input_tokens'] = resp.usage.prompt_tokens or 0\n            usage_info['output_tokens'] = resp.usage.completion_tokens or 0\n            usage_info['total_tokens'] = resp.usage.total_tokens or 0\n\n        return message, usage_info\n\n    async def invoke_llm(\n        self,\n        query: pipeline_query.Query,\n        model: requester.RuntimeLLMModel,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> tuple[provider_message.Message, dict]:\n        \"\"\"Invoke LLM and return message with usage info\"\"\"\n        req_messages = []  # req_messages 仅用于类内，外部同步由 query.messages 进行\n        for m in messages:\n            msg_dict = m.dict(exclude_none=True)\n            content = msg_dict.get('content')\n            if isinstance(content, list):\n                # 检查 content 列表中是否每个部分都是文本\n                if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):\n                    # 将所有文本部分合并为一个字符串\n                    msg_dict['content'] = '\\n'.join(part['text'] for part in content)\n            req_messages.append(msg_dict)\n\n        try:\n            msg, usage_info = await self._closure(\n                query=query,\n                req_messages=req_messages,\n                use_model=model,\n                use_funcs=funcs,\n                extra_args=extra_args,\n                remove_think=remove_think,\n            )\n            return msg, usage_info\n        except asyncio.TimeoutError:\n            raise errors.RequesterError('请求超时')\n        except openai.BadRequestError as e:\n            error_message = str(e.message) if hasattr(e, 'message') else str(e)\n            if 'context_length_exceeded' in str(e):\n                raise errors.RequesterError(f'上文过长，请重置会话: {error_message}')\n            else:\n                raise errors.RequesterError(f'请求参数错误: {error_message}')\n        except openai.AuthenticationError as e:\n            error_message = str(e.message) if hasattr(e, 'message') else str(e)\n            raise errors.RequesterError(f'无效的 api-key: {error_message}')\n        except openai.NotFoundError as e:\n            error_message = str(e.message) if hasattr(e, 'message') else str(e)\n            raise errors.RequesterError(f'请求路径错误: {error_message}')\n        except openai.RateLimitError as e:\n            error_message = str(e.message) if hasattr(e, 'message') else str(e)\n            raise errors.RequesterError(f'请求过于频繁或余额不足: {error_message}')\n        except openai.APIConnectionError as e:\n            error_message = f'连接错误: {str(e)}'\n            raise errors.RequesterError(error_message)\n        except openai.APIError as e:\n            error_message = str(e.message) if hasattr(e, 'message') else str(e)\n            raise errors.RequesterError(f'请求错误: {error_message}')\n\n    async def invoke_embedding(\n        self,\n        model: requester.RuntimeEmbeddingModel,\n        input_text: list[str],\n        extra_args: dict[str, typing.Any] = {},\n    ) -> tuple[list[list[float]], dict]:\n        \"\"\"调用 Embedding API, returns (embeddings, usage_info)\"\"\"\n        self.client.api_key = model.provider.token_mgr.get_token()\n\n        args = {\n            'model': model.model_entity.name,\n            'input': input_text,\n        }\n\n        if model.model_entity.extra_args:\n            args.update(model.model_entity.extra_args)\n\n        args.update(extra_args)\n\n        try:\n            resp = await self.client.embeddings.create(**args)\n\n            # Extract usage info\n            usage_info = {}\n            if hasattr(resp, 'usage') and resp.usage:\n                usage_info['prompt_tokens'] = resp.usage.prompt_tokens or 0\n                usage_info['total_tokens'] = resp.usage.total_tokens or 0\n\n            return [d.embedding for d in resp.data], usage_info\n        except asyncio.TimeoutError:\n            raise errors.RequesterError('请求超时')\n        except openai.BadRequestError as e:\n            raise errors.RequesterError(f'请求参数错误: {e.message}')\n\n    async def invoke_llm_stream(\n        self,\n        query: pipeline_query.Query,\n        model: requester.RuntimeLLMModel,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.MessageChunk:\n        req_messages = []  # req_messages 仅用于类内，外部同步由 query.messages 进行\n        for m in messages:\n            msg_dict = m.dict(exclude_none=True)\n            content = msg_dict.get('content')\n            if isinstance(content, list):\n                # 检查 content 列表中是否每个部分都是文本\n                if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):\n                    # 将所有文本部分合并为一个字符串\n                    msg_dict['content'] = '\\n'.join(part['text'] for part in content)\n            req_messages.append(msg_dict)\n\n        try:\n            async for item in self._closure_stream(\n                query=query,\n                req_messages=req_messages,\n                use_model=model,\n                use_funcs=funcs,\n                extra_args=extra_args,\n                remove_think=remove_think,\n            ):\n                yield item\n\n        except asyncio.TimeoutError:\n            raise errors.RequesterError('请求超时')\n        except openai.BadRequestError as e:\n            if 'context_length_exceeded' in e.message:\n                raise errors.RequesterError(f'上文过长，请重置会话: {e.message}')\n            else:\n                raise errors.RequesterError(f'请求参数错误: {e.message}')\n        except openai.AuthenticationError as e:\n            raise errors.RequesterError(f'无效的 api-key: {e.message}')\n        except openai.NotFoundError as e:\n            raise errors.RequesterError(f'请求路径错误: {e.message}')\n        except openai.RateLimitError as e:\n            raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')\n        except openai.APIError as e:\n            raise errors.RequesterError(f'请求错误: {e.message}')\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: openai-chat-completions\n  label:\n    en_US: OpenAI\n    zh_Hans: OpenAI\n  icon: openai.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.openai.com/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: manufacturer\nexecution:\n  python:\n    path: ./chatcmpl.py\n    attr: OpenAIChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import chatcmpl\n\n\nclass CompShareChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"CompShare ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.modelverse.cn/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: compshare-chat-completions\n  label:\n    en_US: CompShare\n    zh_Hans: 优云智算\n  icon: compshare.png\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.modelverse.cn/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  provider_category: maas\nexecution:\n  python:\n    path: ./compsharechatcmpl.py\n    attr: CompShareChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\n\nfrom . import chatcmpl\nfrom .. import errors, requester\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nclass DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"Deepseek ChatCompletion API 请求器\"\"\"\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.deepseek.com',\n        'timeout': 120,\n    }\n\n    async def _closure(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> tuple[provider_message.Message, dict]:\n        self.client.api_key = use_model.provider.token_mgr.get_token()\n\n        args = {}\n        args['model'] = use_model.model_entity.name\n\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n\n            if tools:\n                args['tools'] = tools\n\n        # 设置此次请求中的messages\n        messages = req_messages\n\n        # deepseek 不支持多模态，把content都转换成纯文字\n        for m in messages:\n            if 'content' in m and isinstance(m['content'], list):\n                m['content'] = ' '.join([c['text'] for c in m['content'] if 'text' in c])\n\n        args['messages'] = messages\n\n        # 发送请求\n        resp = await self._req(args, extra_body=extra_args)\n\n        # print(resp)\n\n        if resp is None:\n            raise errors.RequesterError('接口返回为空，请确定模型提供商服务是否正常')\n        # 处理请求结果\n        message = await self._make_msg(resp, remove_think)\n\n        # Extract token usage from response\n        usage_info = {}\n        if hasattr(resp, 'usage') and resp.usage:\n            usage_info['input_tokens'] = resp.usage.prompt_tokens or 0\n            usage_info['output_tokens'] = resp.usage.completion_tokens or 0\n            usage_info['total_tokens'] = resp.usage.total_tokens or 0\n\n        return message, usage_info\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: deepseek-chat-completions\n  label:\n    en_US: DeepSeek\n    zh_Hans: DeepSeek\n  icon: deepseek.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.deepseek.com\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  provider_category: manufacturer\nexecution:\n  python:\n    path: ./deepseekchatcmpl.py\n    attr: DeepseekChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\n\nfrom . import chatcmpl\n\nimport uuid\n\nfrom .. import requester\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\n\n\nclass GeminiChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"Google Gemini API 请求器\"\"\"\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://generativelanguage.googleapis.com/v1beta/openai',\n        'timeout': 120,\n    }\n\n    async def _closure_stream(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.MessageChunk:\n        self.client.api_key = use_model.provider.token_mgr.get_token()\n\n        args = {}\n        args['model'] = use_model.model_entity.name\n\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n            if tools:\n                args['tools'] = tools\n\n        # 设置此次请求中的messages\n        messages = req_messages.copy()\n\n        # 检查vision\n        for msg in messages:\n            if 'content' in msg and isinstance(msg['content'], list):\n                for me in msg['content']:\n                    if me['type'] == 'image_base64':\n                        me['image_url'] = {'url': me['image_base64']}\n                        me['type'] = 'image_url'\n                        del me['image_base64']\n\n        args['messages'] = messages\n        args['stream'] = True\n\n        # 流式处理状态\n        # tool_calls_map: dict[str, provider_message.ToolCall] = {}\n        chunk_idx = 0\n        thinking_started = False\n        thinking_ended = False\n        role = 'assistant'  # 默认角色\n        tool_id = ''\n        tool_name = ''\n        # accumulated_reasoning = ''  # 仅用于判断何时结束思维链\n\n        async for chunk in self._req_stream(args, extra_body=extra_args):\n            # 解析 chunk 数据\n\n            if hasattr(chunk, 'choices') and chunk.choices:\n                choice = chunk.choices[0]\n                delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}\n\n                finish_reason = getattr(choice, 'finish_reason', None)\n            else:\n                delta = {}\n                finish_reason = None\n            # 从第一个 chunk 获取 role，后续使用这个 role\n            if 'role' in delta and delta['role']:\n                role = delta['role']\n\n            # 获取增量内容\n            delta_content = delta.get('content', '')\n            reasoning_content = delta.get('reasoning_content', '')\n\n            # 处理 reasoning_content\n            if reasoning_content:\n                # accumulated_reasoning += reasoning_content\n                # 如果设置了 remove_think，跳过 reasoning_content\n                if remove_think:\n                    chunk_idx += 1\n                    continue\n\n                # 第一次出现 reasoning_content，添加 <think> 开始标签\n                if not thinking_started:\n                    thinking_started = True\n                    delta_content = '<think>\\n' + reasoning_content\n                else:\n                    # 继续输出 reasoning_content\n                    delta_content = reasoning_content\n            elif thinking_started and not thinking_ended and delta_content:\n                # reasoning_content 结束，normal content 开始，添加 </think> 结束标签\n                thinking_ended = True\n                delta_content = '\\n</think>\\n' + delta_content\n\n            # 处理 content 中已有的 <think> 标签（如果需要移除）\n            # if delta_content and remove_think and '<think>' in delta_content:\n            #     import re\n            #\n            #     # 移除 <think> 标签及其内容\n            #     delta_content = re.sub(r'<think>.*?</think>', '', delta_content, flags=re.DOTALL)\n\n            # 处理工具调用增量\n            # delta_tool_calls = None\n            if delta.get('tool_calls'):\n                for tool_call in delta['tool_calls']:\n                    if tool_call['id'] == '' and tool_id == '':\n                        tool_id = str(uuid.uuid4())\n                    if tool_call['function']['name']:\n                        tool_name = tool_call['function']['name']\n                    tool_call['id'] = tool_id\n                    tool_call['function']['name'] = tool_name\n                    if tool_call['type'] is None:\n                        tool_call['type'] = 'function'\n\n            # 跳过空的第一个 chunk（只有 role 没有内容）\n            if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'):\n                chunk_idx += 1\n                continue\n            # 构建 MessageChunk - 只包含增量内容\n            chunk_data = {\n                'role': role,\n                'content': delta_content if delta_content else None,\n                'tool_calls': delta.get('tool_calls'),\n                'is_final': bool(finish_reason),\n            }\n\n            # 移除 None 值\n            chunk_data = {k: v for k, v in chunk_data.items() if v is not None}\n\n            yield provider_message.MessageChunk(**chunk_data)\n            chunk_idx += 1\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: gemini-chat-completions\n  label:\n    en_US: Google Gemini\n    zh_Hans: Google Gemini\n  icon: gemini.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://generativelanguage.googleapis.com/v1beta/openai\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  provider_category: manufacturer\nexecution:\n  python:\n    path: ./geminichatcmpl.py\n    attr: GeminiChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py",
    "content": "from __future__ import annotations\n\n\nimport typing\n\nfrom . import ppiochatcmpl\n\n\nclass GiteeAIChatCompletions(ppiochatcmpl.PPIOChatCompletions):\n    \"\"\"Gitee AI ChatCompletions API 请求器\"\"\"\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://ai.gitee.com/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: gitee-ai-chat-completions\n  label:\n    en_US: Gitee AI\n    zh_Hans: Gitee AI\n  icon: giteeai.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://ai.gitee.com/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./giteeaichatcmpl.py\n    attr: GiteeAIChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.py",
    "content": "from __future__ import annotations\n\nimport openai\nimport typing\n\nfrom . import chatcmpl\nfrom .. import requester\nimport openai.types.chat.chat_completion as chat_completion\nimport re\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\n\n\nclass JieKouAIChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"接口 AI ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.jiekou.ai/openai',\n        'timeout': 120,\n    }\n\n    is_think: bool = False\n\n    async def _make_msg(\n        self,\n        chat_completion: chat_completion.ChatCompletion,\n        remove_think: bool,\n    ) -> provider_message.Message:\n        chatcmpl_message = chat_completion.choices[0].message.model_dump()\n        # print(chatcmpl_message.keys(), chatcmpl_message.values())\n\n        # 确保 role 字段存在且不为 None\n        if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None:\n            chatcmpl_message['role'] = 'assistant'\n\n        reasoning_content = chatcmpl_message['reasoning_content'] if 'reasoning_content' in chatcmpl_message else None\n\n        # deepseek的reasoner模型\n        chatcmpl_message['content'] = await self._process_thinking_content(\n            chatcmpl_message['content'], reasoning_content, remove_think\n        )\n\n        # 移除 reasoning_content 字段，避免传递给 Message\n        if 'reasoning_content' in chatcmpl_message:\n            del chatcmpl_message['reasoning_content']\n\n        message = provider_message.Message(**chatcmpl_message)\n\n        return message\n\n    async def _process_thinking_content(\n        self,\n        content: str,\n        reasoning_content: str = None,\n        remove_think: bool = False,\n    ) -> tuple[str, str]:\n        \"\"\"处理思维链内容\n\n        Args:\n            content: 原始内容\n            reasoning_content: reasoning_content 字段内容\n            remove_think: 是否移除思维链\n\n        Returns:\n            处理后的内容\n        \"\"\"\n        if remove_think:\n            content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)\n        else:\n            if reasoning_content is not None:\n                content = '<think>\\n' + reasoning_content + '\\n</think>\\n' + content\n        return content\n\n    async def _make_msg_chunk(\n        self,\n        delta: dict[str, typing.Any],\n        idx: int,\n    ) -> provider_message.MessageChunk:\n        # 处理流式chunk和完整响应的差异\n        # print(chat_completion.choices[0])\n\n        # 确保 role 字段存在且不为 None\n        if 'role' not in delta or delta['role'] is None:\n            delta['role'] = 'assistant'\n\n        reasoning_content = delta['reasoning_content'] if 'reasoning_content' in delta else None\n\n        delta['content'] = '' if delta['content'] is None else delta['content']\n        # print(reasoning_content)\n\n        # deepseek的reasoner模型\n\n        if reasoning_content is not None:\n            delta['content'] += reasoning_content\n\n        message = provider_message.MessageChunk(**delta)\n\n        return message\n\n    async def _closure_stream(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]:\n        self.client.api_key = use_model.provider.token_mgr.get_token()\n\n        args = {}\n        args['model'] = use_model.model_entity.name\n\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n\n            if tools:\n                args['tools'] = tools\n\n        # 设置此次请求中的messages\n        messages = req_messages.copy()\n\n        # 检查vision\n        for msg in messages:\n            if 'content' in msg and isinstance(msg['content'], list):\n                for me in msg['content']:\n                    if me['type'] == 'image_base64':\n                        me['image_url'] = {'url': me['image_base64']}\n                        me['type'] = 'image_url'\n                        del me['image_base64']\n\n        args['messages'] = messages\n        args['stream'] = True\n\n        # tool_calls_map: dict[str, provider_message.ToolCall] = {}\n        chunk_idx = 0\n        thinking_started = False\n        thinking_ended = False\n        role = 'assistant'  # 默认角色\n        async for chunk in self._req_stream(args, extra_body=extra_args):\n            # 解析 chunk 数据\n            if hasattr(chunk, 'choices') and chunk.choices:\n                choice = chunk.choices[0]\n                delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}\n                finish_reason = getattr(choice, 'finish_reason', None)\n            else:\n                delta = {}\n                finish_reason = None\n\n            # 从第一个 chunk 获取 role，后续使用这个 role\n            if 'role' in delta and delta['role']:\n                role = delta['role']\n\n            # 获取增量内容\n            delta_content = delta.get('content', '')\n            # reasoning_content = delta.get('reasoning_content', '')\n\n            if remove_think:\n                if delta['content'] is not None:\n                    if '<think>' in delta['content'] and not thinking_started and not thinking_ended:\n                        thinking_started = True\n                        continue\n                    elif delta['content'] == r'</think>' and not thinking_ended:\n                        thinking_ended = True\n                        continue\n                    elif thinking_ended and delta['content'] == '\\n\\n' and thinking_started:\n                        thinking_started = False\n                        continue\n                    elif thinking_started and not thinking_ended:\n                        continue\n\n            # delta_tool_calls = None\n            if delta.get('tool_calls'):\n                for tool_call in delta['tool_calls']:\n                    if tool_call['id'] and tool_call['function']['name']:\n                        tool_id = tool_call['id']\n                        tool_name = tool_call['function']['name']\n\n                    if tool_call['id'] is None:\n                        tool_call['id'] = tool_id\n                    if tool_call['function']['name'] is None:\n                        tool_call['function']['name'] = tool_name\n                    if tool_call['function']['arguments'] is None:\n                        tool_call['function']['arguments'] = ''\n                    if tool_call['type'] is None:\n                        tool_call['type'] = 'function'\n\n            # 跳过空的第一个 chunk（只有 role 没有内容）\n            if chunk_idx == 0 and not delta_content and not delta.get('tool_calls'):\n                chunk_idx += 1\n                continue\n\n            # 构建 MessageChunk - 只包含增量内容\n            chunk_data = {\n                'role': role,\n                'content': delta_content if delta_content else None,\n                'tool_calls': delta.get('tool_calls'),\n                'is_final': bool(finish_reason),\n            }\n\n            # 移除 None 值\n            chunk_data = {k: v for k, v in chunk_data.items() if v is not None}\n\n            yield provider_message.MessageChunk(**chunk_data)\n            chunk_idx += 1\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: jiekouai-chat-completions\n  label:\n    en_US: JieKou AI\n    zh_Hans: 接口 AI\n  icon: jiekouai.png\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.jiekou.ai/openai\n  - name: args\n    label:\n      en_US: Args\n      zh_Hans: 附加参数\n    type: object\n    required: true\n    default: {}\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: int\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./jiekouaichatcmpl.py\n    attr: JieKouAIChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import chatcmpl\n\n\nclass LmStudioChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"LMStudio ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'http://127.0.0.1:1234/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: lmstudio-chat-completions\n  label:\n    en_US: LM Studio\n    zh_Hans: LM Studio\n  icon: lmstudio.webp\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: http://127.0.0.1:1234/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: self-hosted\nexecution:\n  python:\n    path: ./lmstudiochatcmpl.py\n    attr: LmStudioChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport typing\n\nimport openai\nimport openai.types.chat.chat_completion as chat_completion\nimport httpx\n\nfrom .. import entities, errors, requester\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nclass ModelScopeChatCompletions(requester.ProviderAPIRequester):\n    \"\"\"ModelScope ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api-inference.modelscope.cn/v1',\n        'timeout': 120,\n    }\n\n    async def initialize(self):\n        self.client = openai.AsyncClient(\n            api_key='',\n            base_url=self.requester_cfg['base_url'],\n            timeout=self.requester_cfg['timeout'],\n            http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),\n        )\n\n    async def _req(\n        self,\n        query: pipeline_query.Query,\n        args: dict,\n        extra_body: dict = {},\n        remove_think: bool = False,\n    ) -> list[dict[str, typing.Any]]:\n        args['stream'] = True\n\n        chunk = None\n\n        pending_content = ''\n\n        tool_calls = []\n\n        resp_gen: openai.AsyncStream = await self.client.chat.completions.create(**args, extra_body=extra_body)\n\n        chunk_idx = 0\n        thinking_started = False\n        thinking_ended = False\n        tool_id = ''\n        tool_name = ''\n        message_delta = {}\n        async for chunk in resp_gen:\n            if not chunk or not chunk.id or not chunk.choices or not chunk.choices[0] or not chunk.choices[0].delta:\n                continue\n\n            delta = chunk.choices[0].delta.model_dump() if hasattr(chunk.choices[0], 'delta') else {}\n            reasoning_content = delta.get('reasoning_content')\n            # 处理 reasoning_content\n            if reasoning_content:\n                # accumulated_reasoning += reasoning_content\n                # 如果设置了 remove_think，跳过 reasoning_content\n                if remove_think:\n                    chunk_idx += 1\n                    continue\n\n                # 第一次出现 reasoning_content，添加 <think> 开始标签\n                if not thinking_started:\n                    thinking_started = True\n                    pending_content += '<think>\\n' + reasoning_content\n                else:\n                    # 继续输出 reasoning_content\n                    pending_content += reasoning_content\n            elif thinking_started and not thinking_ended and delta.get('content'):\n                # reasoning_content 结束，normal content 开始，添加 </think> 结束标签\n                thinking_ended = True\n                pending_content += '\\n</think>\\n' + delta.get('content')\n\n            if delta.get('content') is not None:\n                pending_content += delta.get('content')\n\n            if delta.get('tool_calls') is not None:\n                for tool_call in delta.get('tool_calls'):\n                    if tool_call['id'] != '':\n                        tool_id = tool_call['id']\n                    if tool_call['function']['name'] is not None:\n                        tool_name = tool_call['function']['name']\n                    if tool_call['function']['arguments'] is None:\n                        continue\n                    tool_call['id'] = tool_id\n                    tool_call['name'] = tool_name\n                    for tc in tool_calls:\n                        if tc['index'] == tool_call['index']:\n                            tc['function']['arguments'] += tool_call['function']['arguments']\n                            break\n                    else:\n                        tool_calls.append(tool_call)\n\n            if chunk.choices[0].finish_reason is not None:\n                break\n        message_delta['content'] = pending_content\n        message_delta['role'] = 'assistant'\n\n        message_delta['tool_calls'] = tool_calls if tool_calls else None\n        return [message_delta]\n\n    async def _make_msg(\n        self,\n        chat_completion: list[dict[str, typing.Any]],\n    ) -> provider_message.Message:\n        chatcmpl_message = chat_completion[0]\n\n        # 确保 role 字段存在且不为 None\n        if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None:\n            chatcmpl_message['role'] = 'assistant'\n\n        message = provider_message.Message(**chatcmpl_message)\n\n        return message\n\n    async def _closure(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> tuple[provider_message.Message, dict]:\n        self.client.api_key = use_model.provider.token_mgr.get_token()\n\n        args = {}\n        args['model'] = use_model.model_entity.name\n\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n\n            if tools:\n                args['tools'] = tools\n\n        # 设置此次请求中的messages\n        messages = req_messages.copy()\n\n        # 检查vision\n        for msg in messages:\n            if 'content' in msg and isinstance(msg['content'], list):\n                for me in msg['content']:\n                    if me['type'] == 'image_base64':\n                        me['image_url'] = {'url': me['image_base64']}\n                        me['type'] = 'image_url'\n                        del me['image_base64']\n\n        args['messages'] = messages\n\n        # 发送请求\n        resp = await self._req(query, args, extra_body=extra_args, remove_think=remove_think)\n\n        # 处理请求结果\n        message = await self._make_msg(resp)\n\n        # ModelScope uses streaming, usage info not available\n        usage_info = {}\n\n        return message, usage_info\n\n    async def _req_stream(\n        self,\n        args: dict,\n        extra_body: dict = {},\n    ) -> chat_completion.ChatCompletion:\n        async for chunk in await self.client.chat.completions.create(**args, extra_body=extra_body):\n            yield chunk\n\n    async def _closure_stream(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]:\n        self.client.api_key = use_model.provider.token_mgr.get_token()\n\n        args = {}\n        args['model'] = use_model.model_entity.name\n\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n\n            if tools:\n                args['tools'] = tools\n\n        # 设置此次请求中的messages\n        messages = req_messages.copy()\n\n        # 检查vision\n        for msg in messages:\n            if 'content' in msg and isinstance(msg['content'], list):\n                for me in msg['content']:\n                    if me['type'] == 'image_base64':\n                        me['image_url'] = {'url': me['image_base64']}\n                        me['type'] = 'image_url'\n                        del me['image_base64']\n\n        args['messages'] = messages\n        args['stream'] = True\n\n        # 流式处理状态\n        # tool_calls_map: dict[str, provider_message.ToolCall] = {}\n        chunk_idx = 0\n        thinking_started = False\n        thinking_ended = False\n        role = 'assistant'  # 默认角色\n        # accumulated_reasoning = ''  # 仅用于判断何时结束思维链\n\n        async for chunk in self._req_stream(args, extra_body=extra_args):\n            # 解析 chunk 数据\n            if hasattr(chunk, 'choices') and chunk.choices:\n                choice = chunk.choices[0]\n                delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}\n                finish_reason = getattr(choice, 'finish_reason', None)\n            else:\n                delta = {}\n                finish_reason = None\n\n            # 从第一个 chunk 获取 role，后续使用这个 role\n            if 'role' in delta and delta['role']:\n                role = delta['role']\n\n            # 获取增量内容\n            delta_content = delta.get('content', '')\n            reasoning_content = delta.get('reasoning_content', '')\n\n            # 处理 reasoning_content\n            if reasoning_content:\n                # accumulated_reasoning += reasoning_content\n                # 如果设置了 remove_think，跳过 reasoning_content\n                if remove_think:\n                    chunk_idx += 1\n                    continue\n\n                # 第一次出现 reasoning_content，添加 <think> 开始标签\n                if not thinking_started:\n                    thinking_started = True\n                    delta_content = '<think>\\n' + reasoning_content\n                else:\n                    # 继续输出 reasoning_content\n                    delta_content = reasoning_content\n            elif thinking_started and not thinking_ended and delta_content:\n                # reasoning_content 结束，normal content 开始，添加 </think> 结束标签\n                thinking_ended = True\n                delta_content = '\\n</think>\\n' + delta_content\n\n            # 处理 content 中已有的 <think> 标签（如果需要移除）\n            # if delta_content and remove_think and '<think>' in delta_content:\n            #     import re\n            #\n            #     # 移除 <think> 标签及其内容\n            #     delta_content = re.sub(r'<think>.*?</think>', '', delta_content, flags=re.DOTALL)\n\n            # 处理工具调用增量\n            if delta.get('tool_calls'):\n                for tool_call in delta['tool_calls']:\n                    if tool_call['id'] != '':\n                        tool_id = tool_call['id']\n                    if tool_call['function']['name'] is not None:\n                        tool_name = tool_call['function']['name']\n\n                    if tool_call['type'] is None:\n                        tool_call['type'] = 'function'\n                    tool_call['id'] = tool_id\n                    tool_call['function']['name'] = tool_name\n                    tool_call['function']['arguments'] = (\n                        '' if tool_call['function']['arguments'] is None else tool_call['function']['arguments']\n                    )\n\n            # 跳过空的第一个 chunk（只有 role 没有内容）\n            if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'):\n                chunk_idx += 1\n                continue\n\n            # 构建 MessageChunk - 只包含增量内容\n            chunk_data = {\n                'role': role,\n                'content': delta_content if delta_content else None,\n                'tool_calls': delta.get('tool_calls'),\n                'is_final': bool(finish_reason),\n            }\n\n            # 移除 None 值\n            chunk_data = {k: v for k, v in chunk_data.items() if v is not None}\n\n            yield provider_message.MessageChunk(**chunk_data)\n            chunk_idx += 1\n            # return\n\n    async def invoke_llm(\n        self,\n        query: pipeline_query.Query,\n        model: entities.LLMModelInfo,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message:\n        req_messages = []  # req_messages 仅用于类内，外部同步由 query.messages 进行\n        for m in messages:\n            msg_dict = m.dict(exclude_none=True)\n            content = msg_dict.get('content')\n            if isinstance(content, list):\n                # 检查 content 列表中是否每个部分都是文本\n                if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):\n                    # 将所有文本部分合并为一个字符串\n                    msg_dict['content'] = '\\n'.join(part['text'] for part in content)\n            req_messages.append(msg_dict)\n\n        try:\n            return await self._closure(\n                query=query,\n                req_messages=req_messages,\n                use_model=model,\n                use_funcs=funcs,\n                extra_args=extra_args,\n                remove_think=remove_think,\n            )\n        except asyncio.TimeoutError:\n            raise errors.RequesterError('请求超时')\n        except openai.BadRequestError as e:\n            if 'context_length_exceeded' in e.message:\n                raise errors.RequesterError(f'上文过长，请重置会话: {e.message}')\n            else:\n                raise errors.RequesterError(f'请求参数错误: {e.message}')\n        except openai.AuthenticationError as e:\n            raise errors.RequesterError(f'无效的 api-key: {e.message}')\n        except openai.NotFoundError as e:\n            raise errors.RequesterError(f'请求路径错误: {e.message}')\n        except openai.RateLimitError as e:\n            raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')\n        except openai.APIError as e:\n            raise errors.RequesterError(f'请求错误: {e.message}')\n\n    async def invoke_llm_stream(\n        self,\n        query: pipeline_query.Query,\n        model: requester.RuntimeLLMModel,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.MessageChunk:\n        req_messages = []  # req_messages 仅用于类内，外部同步由 query.messages 进行\n        for m in messages:\n            msg_dict = m.dict(exclude_none=True)\n            content = msg_dict.get('content')\n            if isinstance(content, list):\n                # 检查 content 列表中是否每个部分都是文本\n                if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):\n                    # 将所有文本部分合并为一个字符串\n                    msg_dict['content'] = '\\n'.join(part['text'] for part in content)\n            req_messages.append(msg_dict)\n\n        try:\n            async for item in self._closure_stream(\n                query=query,\n                req_messages=req_messages,\n                use_model=model,\n                use_funcs=funcs,\n                extra_args=extra_args,\n                remove_think=remove_think,\n            ):\n                yield item\n\n        except asyncio.TimeoutError:\n            raise errors.RequesterError('请求超时')\n        except openai.BadRequestError as e:\n            if 'context_length_exceeded' in e.message:\n                raise errors.RequesterError(f'上文过长，请重置会话: {e.message}')\n            else:\n                raise errors.RequesterError(f'请求参数错误: {e.message}')\n        except openai.AuthenticationError as e:\n            raise errors.RequesterError(f'无效的 api-key: {e.message}')\n        except openai.NotFoundError as e:\n            raise errors.RequesterError(f'请求路径错误: {e.message}')\n        except openai.RateLimitError as e:\n            raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')\n        except openai.APIError as e:\n            raise errors.RequesterError(f'请求错误: {e.message}')\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: modelscope-chat-completions\n  label:\n    en_US: ModelScope\n    zh_Hans: 魔搭社区\n  icon: modelscope.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api-inference.modelscope.cn/v1\n  - name: args\n    label:\n      en_US: Args\n      zh_Hans: 附加参数\n    type: object\n    required: true\n    default: {}\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: int\n    required: true\n    default: 120\n  support_type:\n  - llm\n  provider_category: maas\nexecution:\n  python:\n    path: ./modelscopechatcmpl.py\n    attr: ModelScopeChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\n\n\nfrom . import chatcmpl\nfrom .. import requester\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nclass MoonshotChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"Moonshot ChatCompletion API 请求器\"\"\"\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.moonshot.cn/v1',\n        'timeout': 120,\n    }\n\n    async def _closure(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> tuple[provider_message.Message, dict]:\n        self.client.api_key = use_model.provider.token_mgr.get_token()\n\n        args = {}\n        args['model'] = use_model.model_entity.name\n\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n\n            if tools:\n                args['tools'] = tools\n\n        # 设置此次请求中的messages\n        messages = req_messages\n\n        # deepseek 不支持多模态，把content都转换成纯文字\n        for m in messages:\n            if 'content' in m and isinstance(m['content'], list):\n                m['content'] = ' '.join([c['text'] for c in m['content']])\n\n        # 删除空的，不知道干嘛的，直接删了。\n        # messages = [m for m in messages if m[\"content\"].strip() != \"\" and ('tool_calls' not in m or not m['tool_calls'])]\n\n        args['messages'] = messages\n\n        # 发送请求\n        resp = await self._req(args, extra_body=extra_args)\n\n        # 处理请求结果\n        message = await self._make_msg(resp, remove_think)\n\n        # Extract token usage from response\n        usage_info = {}\n        if hasattr(resp, 'usage') and resp.usage:\n            usage_info['input_tokens'] = resp.usage.prompt_tokens or 0\n            usage_info['output_tokens'] = resp.usage.completion_tokens or 0\n            usage_info['total_tokens'] = resp.usage.total_tokens or 0\n\n        return message, usage_info\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: moonshot-chat-completions\n  label:\n    en_US: Moonshot\n    zh_Hans: 月之暗面\n  icon: moonshot.png\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.moonshot.ai/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  provider_category: manufacturer\nexecution:\n  python:\n    path: ./moonshotchatcmpl.py\n    attr: MoonshotChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import chatcmpl\n\n\nclass NewAPIChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"New API ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'http://localhost:3000/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: new-api-chat-completions\n  label:\n    en_US: New API\n    zh_Hans: New API\n  icon: newapi.png\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: http://localhost:3000/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./newapichatcmpl.py\n    attr: NewAPIChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/ollamachat.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport os\nimport typing\nfrom typing import Union, Mapping, Any, AsyncIterator\nimport uuid\nimport json\n\nimport ollama\n\nfrom .. import errors, requester\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\nREQUESTER_NAME: str = 'ollama-chat'\n\n\nclass OllamaChatCompletions(requester.ProviderAPIRequester):\n    \"\"\"Ollama平台 ChatCompletion API请求器\"\"\"\n\n    client: ollama.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'http://127.0.0.1:11434',\n        'timeout': 120,\n    }\n\n    async def initialize(self):\n        os.environ['OLLAMA_HOST'] = self.requester_cfg['base_url']\n        self.client = ollama.AsyncClient(timeout=self.requester_cfg['timeout'])\n\n    async def _req(\n        self,\n        args: dict,\n    ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:\n        return await self.client.chat(**args)\n\n    async def _closure(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message:\n        args = extra_args.copy()\n        args['model'] = use_model.model_entity.name\n\n        messages: list[dict] = req_messages.copy()\n        for msg in messages:\n            if 'content' in msg and isinstance(msg['content'], list):\n                text_content: list = []\n                image_urls: list = []\n                for me in msg['content']:\n                    if me['type'] == 'text':\n                        text_content.append(me['text'])\n                    elif me['type'] == 'image_base64':\n                        image_urls.append(me['image_base64'])\n\n                msg['content'] = '\\n'.join(text_content)\n                msg['images'] = [url.split(',')[1] for url in image_urls]\n            if 'tool_calls' in msg:  # LangBot 内部以 str 存储 tool_calls 的参数，这里需要转换为 dict\n                for tool_call in msg['tool_calls']:\n                    tool_call['function']['arguments'] = json.loads(tool_call['function']['arguments'])\n        args['messages'] = messages\n\n        args['tools'] = []\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n            if tools:\n                args['tools'] = tools\n\n        resp = await self._req(args)\n        message: provider_message.Message = await self._make_msg(resp)\n        return message\n\n    async def _make_msg(self, chat_completions: ollama.ChatResponse) -> provider_message.Message:\n        message: ollama.Message = chat_completions.message\n        if message is None:\n            raise ValueError(\"chat_completions must contain a 'message' field\")\n\n        ret_msg: provider_message.Message = None\n\n        if message.content is not None:\n            ret_msg = provider_message.Message(role='assistant', content=message.content)\n        if message.tool_calls is not None and len(message.tool_calls) > 0:\n            tool_calls: list[provider_message.ToolCall] = []\n\n            for tool_call in message.tool_calls:\n                tool_calls.append(\n                    provider_message.ToolCall(\n                        id=uuid.uuid4().hex,\n                        type='function',\n                        function=provider_message.FunctionCall(\n                            name=tool_call.function.name,\n                            arguments=json.dumps(tool_call.function.arguments),\n                        ),\n                    )\n                )\n            ret_msg.tool_calls = tool_calls\n\n        return ret_msg\n\n    async def invoke_llm(\n        self,\n        query: pipeline_query.Query,\n        model: requester.RuntimeLLMModel,\n        messages: typing.List[provider_message.Message],\n        funcs: typing.List[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message:\n        req_messages: list = []\n        for m in messages:\n            msg_dict: dict = m.dict(exclude_none=True)\n            content: Any = msg_dict.get('content')\n            if isinstance(content, list):\n                if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):\n                    msg_dict['content'] = '\\n'.join(part['text'] for part in content)\n            req_messages.append(msg_dict)\n        try:\n            return await self._closure(\n                query=query,\n                req_messages=req_messages,\n                use_model=model,\n                use_funcs=funcs,\n                extra_args=extra_args,\n                remove_think=remove_think,\n            )\n        except asyncio.TimeoutError:\n            raise errors.RequesterError('请求超时')\n\n    async def invoke_embedding(\n        self,\n        model: requester.RuntimeEmbeddingModel,\n        input_text: list[str],\n        extra_args: dict[str, typing.Any] = {},\n    ) -> list[list[float]]:\n        return (\n            await self.client.embed(\n                model=model.model_entity.name,\n                input=input_text,\n                **extra_args,\n            )\n        ).embeddings\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: ollama-chat\n  label:\n    en_US: Ollama\n    zh_Hans: Ollama\n  icon: ollama.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: http://127.0.0.1:11434\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: self-hosted\nexecution:\n  python:\n    path: ./ollamachat.py\n    attr: OllamaChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import modelscopechatcmpl\n\n\nclass OpenRouterChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions):\n    \"\"\"OpenRouter ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://openrouter.ai/api/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: openrouter-chat-completions\n  label:\n    en_US: OpenRouter\n    zh_Hans: OpenRouter\n  icon: openrouter.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://openrouter.ai/api/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./openrouterchatcmpl.py\n    attr: OpenRouterChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.py",
    "content": "from __future__ import annotations\n\nimport openai\nimport typing\n\nfrom . import chatcmpl\nfrom .. import requester\nimport openai.types.chat.chat_completion as chat_completion\nimport re\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\n\n\nclass PPIOChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"欧派云 ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.ppinfra.com/v3/openai',\n        'timeout': 120,\n    }\n\n    is_think: bool = False\n\n    async def _make_msg(\n        self,\n        chat_completion: chat_completion.ChatCompletion,\n        remove_think: bool,\n    ) -> provider_message.Message:\n        chatcmpl_message = chat_completion.choices[0].message.model_dump()\n        # print(chatcmpl_message.keys(), chatcmpl_message.values())\n\n        # 确保 role 字段存在且不为 None\n        if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None:\n            chatcmpl_message['role'] = 'assistant'\n\n        reasoning_content = chatcmpl_message['reasoning_content'] if 'reasoning_content' in chatcmpl_message else None\n\n        # deepseek的reasoner模型\n        chatcmpl_message['content'] = await self._process_thinking_content(\n            chatcmpl_message['content'], reasoning_content, remove_think\n        )\n\n        # 移除 reasoning_content 字段，避免传递给 Message\n        if 'reasoning_content' in chatcmpl_message:\n            del chatcmpl_message['reasoning_content']\n\n        message = provider_message.Message(**chatcmpl_message)\n\n        return message\n\n    async def _process_thinking_content(\n        self,\n        content: str,\n        reasoning_content: str = None,\n        remove_think: bool = False,\n    ) -> tuple[str, str]:\n        \"\"\"处理思维链内容\n\n        Args:\n            content: 原始内容\n            reasoning_content: reasoning_content 字段内容\n            remove_think: 是否移除思维链\n\n        Returns:\n            处理后的内容\n        \"\"\"\n        if remove_think:\n            content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)\n        else:\n            if reasoning_content is not None:\n                content = '<think>\\n' + reasoning_content + '\\n</think>\\n' + content\n        return content\n\n    async def _make_msg_chunk(\n        self,\n        delta: dict[str, typing.Any],\n        idx: int,\n    ) -> provider_message.MessageChunk:\n        # 处理流式chunk和完整响应的差异\n        # print(chat_completion.choices[0])\n\n        # 确保 role 字段存在且不为 None\n        if 'role' not in delta or delta['role'] is None:\n            delta['role'] = 'assistant'\n\n        reasoning_content = delta['reasoning_content'] if 'reasoning_content' in delta else None\n\n        delta['content'] = '' if delta['content'] is None else delta['content']\n        # print(reasoning_content)\n\n        # deepseek的reasoner模型\n\n        if reasoning_content is not None:\n            delta['content'] += reasoning_content\n\n        message = provider_message.MessageChunk(**delta)\n\n        return message\n\n    async def _closure_stream(\n        self,\n        query: pipeline_query.Query,\n        req_messages: list[dict],\n        use_model: requester.RuntimeLLMModel,\n        use_funcs: list[resource_tool.LLMTool] = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]:\n        self.client.api_key = use_model.provider.token_mgr.get_token()\n\n        args = {}\n        args['model'] = use_model.model_entity.name\n\n        if use_funcs:\n            tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)\n\n            if tools:\n                args['tools'] = tools\n\n        # 设置此次请求中的messages\n        messages = req_messages.copy()\n\n        # 检查vision\n        for msg in messages:\n            if 'content' in msg and isinstance(msg['content'], list):\n                for me in msg['content']:\n                    if me['type'] == 'image_base64':\n                        me['image_url'] = {'url': me['image_base64']}\n                        me['type'] = 'image_url'\n                        del me['image_base64']\n\n        args['messages'] = messages\n        args['stream'] = True\n\n        # tool_calls_map: dict[str, provider_message.ToolCall] = {}\n        chunk_idx = 0\n        thinking_started = False\n        thinking_ended = False\n        role = 'assistant'  # 默认角色\n        async for chunk in self._req_stream(args, extra_body=extra_args):\n            # 解析 chunk 数据\n            if hasattr(chunk, 'choices') and chunk.choices:\n                choice = chunk.choices[0]\n                delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}\n                finish_reason = getattr(choice, 'finish_reason', None)\n            else:\n                delta = {}\n                finish_reason = None\n\n            # 从第一个 chunk 获取 role，后续使用这个 role\n            if 'role' in delta and delta['role']:\n                role = delta['role']\n\n            # 获取增量内容\n            delta_content = delta.get('content', '')\n            # reasoning_content = delta.get('reasoning_content', '')\n\n            if remove_think:\n                if delta['content'] is not None:\n                    if '<think>' in delta['content'] and not thinking_started and not thinking_ended:\n                        thinking_started = True\n                        continue\n                    elif delta['content'] == r'</think>' and not thinking_ended:\n                        thinking_ended = True\n                        continue\n                    elif thinking_ended and delta['content'] == '\\n\\n' and thinking_started:\n                        thinking_started = False\n                        continue\n                    elif thinking_started and not thinking_ended:\n                        continue\n\n            # delta_tool_calls = None\n            if delta.get('tool_calls'):\n                for tool_call in delta['tool_calls']:\n                    if tool_call['id'] and tool_call['function']['name']:\n                        tool_id = tool_call['id']\n                        tool_name = tool_call['function']['name']\n\n                    if tool_call['id'] is None:\n                        tool_call['id'] = tool_id\n                    if tool_call['function']['name'] is None:\n                        tool_call['function']['name'] = tool_name\n                    if tool_call['function']['arguments'] is None:\n                        tool_call['function']['arguments'] = ''\n                    if tool_call['type'] is None:\n                        tool_call['type'] = 'function'\n\n            # 跳过空的第一个 chunk（只有 role 没有内容）\n            if chunk_idx == 0 and not delta_content and not delta.get('tool_calls'):\n                chunk_idx += 1\n                continue\n\n            # 构建 MessageChunk - 只包含增量内容\n            chunk_data = {\n                'role': role,\n                'content': delta_content if delta_content else None,\n                'tool_calls': delta.get('tool_calls'),\n                'is_final': bool(finish_reason),\n            }\n\n            # 移除 None 值\n            chunk_data = {k: v for k, v in chunk_data.items() if v is not None}\n\n            yield provider_message.MessageChunk(**chunk_data)\n            chunk_idx += 1\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: ppio-chat-completions\n  label:\n    en_US: ppio\n    zh_Hans: 派欧云\n  icon: ppio.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.ppinfra.com/v3/openai\n  - name: args\n    label:\n      en_US: Args\n      zh_Hans: 附加参数\n    type: object\n    required: true\n    default: {}\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: int\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./ppiochatcmpl.py\n    attr: PPIOChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.py",
    "content": "from __future__ import annotations\n\nimport openai\nimport typing\n\nfrom . import chatcmpl\n\n\nclass QHAIGCChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"启航 AI ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.qhaigc.com/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: qhaigc-chat-completions\n  label:\n    en_US: QH AI\n    zh_Hans: 启航 AI\n  icon: qhaigc.png\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.qhaigc.net/v1\n  - name: args\n    label:\n      en_US: Args\n      zh_Hans: 附加参数\n    type: object\n    required: true\n    default: {}\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: int\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./qhaigcchatcmpl.py\n    attr: QHAIGCChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py",
    "content": "from __future__ import annotations\n\nimport typing\n\nfrom .. import requester\n\nREQUESTER_NAME: str = 'seekdb-embedding'\n\n\nclass SeekDBEmbedding(requester.ProviderAPIRequester):\n    \"\"\"SeekDB built-in embedding requester.\n\n    Uses pyseekdb's local embedding function (all-MiniLM-L6-v2).\n    The base_url config is reserved for future remote embedding support.\n    \"\"\"\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': '',\n    }\n\n    _embedding_function = None\n\n    async def initialize(self):\n        try:\n            import pyseekdb\n        except ImportError:\n            raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')\n\n        self._embedding_function = pyseekdb.get_default_embedding_function()\n\n    async def invoke_llm(\n        self,\n        query,\n        model: requester.RuntimeLLMModel,\n        messages: typing.List,\n        funcs: typing.List = None,\n        extra_args: dict[str, typing.Any] = {},\n        remove_think: bool = False,\n    ):\n        raise NotImplementedError('SeekDB embedding does not support LLM inference')\n\n    async def invoke_embedding(\n        self,\n        model: requester.RuntimeEmbeddingModel,\n        input_text: typing.List[str],\n        extra_args: dict[str, typing.Any] = {},\n    ) -> typing.List[typing.List[float]]:\n        \"\"\"Generate embeddings using SeekDB's built-in embedding function.\"\"\"\n        try:\n            if self._embedding_function is None:\n                await self.initialize()\n\n            if self._embedding_function is None:\n                raise RuntimeError('SeekDB embedding function initialization failed')\n\n            return self._embedding_function(input_text)\n        except Exception as e:\n            from .. import errors\n\n            raise errors.RequesterError(f'SeekDB embedding failed: {str(e)}')\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: seekdb-embedding\n  label:\n    en_US: SeekDB Embedding\n    zh_Hans: SeekDB 嵌入\n  description:\n    en_US: SeekDB Python library built-in embedding model (all-MiniLM-L6-v2), it will take time to download the model file for the first time\n    zh_Hans: 使用来自 SeekDB Python 库的内置嵌入模型 (all-MiniLM-L6-v2)，首次使用时将会花费时间自动下载模型文件\n    ja_JP: SeekDB Python ライブラリの組み込み埋め込みモデル (all-MiniLM-L6-v2) を使用します。初回使用時にモデルファイルのダウンロードに時間がかかります。\n  icon: seekdb.svg\nspec:\n  config: []\n  support_type:\n  - text-embedding\n  provider_category: builtin\nexecution:\n  python:\n    path: ./seekdbembed.py\n    attr: SeekDBEmbedding\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.py",
    "content": "from __future__ import annotations\n\nimport openai\nimport typing\n\nfrom . import chatcmpl\nimport openai.types.chat.chat_completion as chat_completion\n\n\nclass ShengSuanYunChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"胜算云(ModelSpot.AI) ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://router.shengsuanyun.com/api/v1',\n        'timeout': 120,\n    }\n\n    async def _req(\n        self,\n        args: dict,\n        extra_body: dict = {},\n    ) -> chat_completion.ChatCompletion:\n        return await self.client.chat.completions.create(\n            **args,\n            extra_body=extra_body,\n            extra_headers={\n                'HTTP-Referer': 'https://langbot.app',\n                'X-Title': 'LangBot',\n            },\n        )\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: shengsuanyun-chat-completions\n  label:\n    en_US: ShengSuanYun\n    zh_Hans: 胜算云\n  icon: shengsuanyun.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://router.shengsuanyun.com/api/v1\n  - name: args\n    label:\n      en_US: Args\n      zh_Hans: 附加参数\n    type: object\n    required: true\n    default: {}\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: int\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./shengsuanyun.py\n    attr: ShengSuanYunChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import chatcmpl\n\n\nclass SiliconFlowChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"SiliconFlow ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.siliconflow.cn/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: siliconflow-chat-completions\n  label:\n    en_US: SiliconFlow\n    zh_Hans: 硅基流动\n  icon: siliconflow.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.siliconflow.cn/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./siliconflowchatcmpl.py\n    attr: SiliconFlowChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import chatcmpl\n\n\nclass LangBotSpaceChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"LangBot Space ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.langbot.cloud/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: space-chat-completions\n  label:\n    en_US: Space\n    zh_Hans: Space\n  icon: space.webp\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.langbot.cloud/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./spacechatcmpl.py\n    attr: LangBotSpaceChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: tokenpony-chat-completions\n  label:\n    en_US: TokenPony\n    zh_Hans: 小马算力\n  icon: tokenpony.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.tokenpony.cn/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  - text-embedding\n  provider_category: maas\nexecution:\n  python:\n    path: ./tokenponychatcmpl.py\n    attr: TokenPonyChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/tokenponychatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import chatcmpl\n\n\nclass TokenPonyChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"TokenPony ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.tokenpony.cn/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import chatcmpl\n\n\nclass VolcArkChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"火山方舟大模型平台 ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://ark.cn-beijing.volces.com/api/v3',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: volcark-chat-completions\n  label:\n    en_US: Volc Engine Ark\n    zh_Hans: 火山方舟\n  icon: volcark.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://ark.cn-beijing.volces.com/api/v3\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  provider_category: maas\nexecution:\n  python:\n    path: ./volcarkchatcmpl.py\n    attr: VolcArkChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import chatcmpl\n\n\nclass XaiChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"xAI ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://api.x.ai/v1',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: xai-chat-completions\n  label:\n    en_US: xAI\n    zh_Hans: xAI\n  icon: xai.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://api.x.ai/v1\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  provider_category: manufacturer\nexecution:\n  python:\n    path: ./xaichatcmpl.py\n    attr: XaiChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport openai\n\nfrom . import chatcmpl\n\n\nclass ZhipuAIChatCompletions(chatcmpl.OpenAIChatCompletions):\n    \"\"\"智谱AI ChatCompletion API 请求器\"\"\"\n\n    client: openai.AsyncClient\n\n    default_config: dict[str, typing.Any] = {\n        'base_url': 'https://open.bigmodel.cn/api/paas/v4',\n        'timeout': 120,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml",
    "content": "apiVersion: v1\nkind: LLMAPIRequester\nmetadata:\n  name: zhipuai-chat-completions\n  label:\n    en_US: ZhipuAI\n    zh_Hans: 智谱 AI\n  icon: zhipuai.svg\nspec:\n  config:\n  - name: base_url\n    label:\n      en_US: Base URL\n      zh_Hans: 基础 URL\n    type: string\n    required: true\n    default: https://open.bigmodel.cn/api/paas/v4\n  - name: timeout\n    label:\n      en_US: Timeout\n      zh_Hans: 超时时间\n    type: integer\n    required: true\n    default: 120\n  support_type:\n  - llm\n  provider_category: manufacturer\nexecution:\n  python:\n    path: ./zhipuaichatcmpl.py\n    attr: ZhipuAIChatCompletions\n"
  },
  {
    "path": "src/langbot/pkg/provider/modelmgr/token.py",
    "content": "from __future__ import annotations\n\nimport typing\n\n\nclass TokenManager:\n    \"\"\"鉴权 Token 管理器\"\"\"\n\n    name: str\n\n    tokens: list[str]\n\n    using_token_index: typing.Optional[int] = 0\n\n    def __init__(self, name: str, tokens: list[str]):\n        self.name = name\n        self.tokens = tokens\n        self.using_token_index = 0\n\n    def get_token(self) -> str:\n        if len(self.tokens) == 0:\n            return ''\n        return self.tokens[self.using_token_index]\n\n    def next_token(self):\n        self.using_token_index = (self.using_token_index + 1) % len(self.tokens)\n"
  },
  {
    "path": "src/langbot/pkg/provider/runner.py",
    "content": "from __future__ import annotations\n\nimport abc\nimport typing\n\nfrom ..core import app\n\n\npreregistered_runners: list[typing.Type[RequestRunner]] = []\n\n\ndef runner_class(name: str):\n    \"\"\"注册一个请求运行器\"\"\"\n\n    def decorator(cls: typing.Type[RequestRunner]) -> typing.Type[RequestRunner]:\n        cls.name = name\n        preregistered_runners.append(cls)\n        return cls\n\n    return decorator\n\n\nclass RequestRunner(abc.ABC):\n    \"\"\"请求运行器\"\"\"\n\n    name: str = None\n\n    ap: app.Application\n\n    pipeline_config: dict\n\n    def __init__(self, ap: app.Application, pipeline_config: dict):\n        self.ap = ap\n        self.pipeline_config = pipeline_config\n\n    @abc.abstractmethod\n    async def run(\n        self, query: core_entities.Query\n    ) -> typing.AsyncGenerator[llm_entities.Message | llm_entities.MessageChunk, None]:\n        \"\"\"运行请求\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/provider/runners/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/provider/runners/cozeapi.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport json\nimport base64\n\nfrom langbot.pkg.provider import runner\nfrom langbot.pkg.core import app\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\nfrom langbot.pkg.utils import image\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nfrom langbot.libs.coze_server_api.client import AsyncCozeAPIClient\n\n\n@runner.runner_class('coze-api')\nclass CozeAPIRunner(runner.RequestRunner):\n    \"\"\"Coze API 对话请求器\"\"\"\n\n    def __init__(self, ap: app.Application, pipeline_config: dict):\n        self.pipeline_config = pipeline_config\n        self.ap = ap\n        self.agent_token = pipeline_config['ai']['coze-api']['api-key']\n        self.bot_id = pipeline_config['ai']['coze-api'].get('bot-id')\n        self.chat_timeout = pipeline_config['ai']['coze-api'].get('timeout')\n        self.auto_save_history = pipeline_config['ai']['coze-api'].get('auto_save_history')\n        self.api_base = pipeline_config['ai']['coze-api'].get('api-base')\n\n        self.coze = AsyncCozeAPIClient(self.agent_token, self.api_base)\n\n    def _process_thinking_content(\n        self,\n        content: str,\n    ) -> tuple[str, str]:\n        \"\"\"处理思维链内容\n\n        Args:\n            content: 原始内容\n        Returns:\n            (处理后的内容, 提取的思维链内容)\n        \"\"\"\n        remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)\n        thinking_content = ''\n        # 从 content 中提取 <think> 标签内容\n        if content and '<think>' in content and '</think>' in content:\n            import re\n\n            think_pattern = r'<think>(.*?)</think>'\n            think_matches = re.findall(think_pattern, content, re.DOTALL)\n            if think_matches:\n                thinking_content = '\\n'.join(think_matches)\n                # 移除 content 中的 <think> 标签\n                content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()\n\n        # 根据 remove_think 参数决定是否保留思维链\n        if remove_think:\n            return content, ''\n        else:\n            # 如果有思维链内容，将其以 <think> 格式添加到 content 开头\n            if thinking_content:\n                content = f'<think>\\n{thinking_content}\\n</think>\\n{content}'.strip()\n            return content, thinking_content\n\n    async def _preprocess_user_message(self, query: pipeline_query.Query) -> list[dict]:\n        \"\"\"预处理用户消息，转换为Coze消息格式\n\n        Returns:\n            list[dict]: Coze消息列表\n        \"\"\"\n        messages = []\n\n        if isinstance(query.user_message.content, list):\n            # 多模态消息处理\n            content_parts = []\n\n            for ce in query.user_message.content:\n                if ce.type == 'text':\n                    content_parts.append({'type': 'text', 'text': ce.text})\n                elif ce.type == 'image_base64':\n                    image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)\n                    file_bytes = base64.b64decode(image_b64)\n                    file_id = await self._get_file_id(file_bytes)\n                    content_parts.append({'type': 'image', 'file_id': file_id})\n                elif ce.type == 'file':\n                    # 处理文件，上传到Coze\n                    file_id = await self._get_file_id(ce.file)\n                    content_parts.append({'type': 'file', 'file_id': file_id})\n\n            # 创建多模态消息\n            if content_parts:\n                messages.append(\n                    {\n                        'role': 'user',\n                        'content': json.dumps(content_parts),\n                        'content_type': 'object_string',\n                        'meta_data': None,\n                    }\n                )\n\n        elif isinstance(query.user_message.content, str):\n            # 纯文本消息\n            messages.append(\n                {'role': 'user', 'content': query.user_message.content, 'content_type': 'text', 'meta_data': None}\n            )\n\n        return messages\n\n    async def _get_file_id(self, file) -> str:\n        \"\"\"上传文件到Coze服务\n        Args:\n            file: 文件\n        Returns:\n            str: 文件ID\n        \"\"\"\n        file_id = await self.coze.upload(file=file)\n        return file_id\n\n    async def _chat_messages(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"调用聊天助手（非流式）\n\n        注意：由于cozepy没有提供非流式API，这里使用流式API并在结束后一次性返回完整内容\n        \"\"\"\n        user_id = f'{query.launcher_type.value}_{query.launcher_id}'\n\n        # 预处理用户消息\n        additional_messages = await self._preprocess_user_message(query)\n\n        # 获取会话ID\n        conversation_id = None\n\n        # 收集完整内容\n        full_content = ''\n        full_reasoning = ''\n\n        try:\n            # 调用Coze API流式接口\n            async for chunk in self.coze.chat_messages(\n                bot_id=self.bot_id,\n                user_id=user_id,\n                additional_messages=additional_messages,\n                conversation_id=conversation_id,\n                timeout=self.chat_timeout,\n                auto_save_history=self.auto_save_history,\n                stream=True,\n            ):\n                self.ap.logger.debug(f'coze-chat-stream: {chunk}')\n\n                event_type = chunk.get('event')\n                data = chunk.get('data', {})\n                # Removed debug print statement to avoid cluttering logs in production\n\n                if event_type == 'conversation.message.delta':\n                    # 收集内容\n                    if 'content' in data:\n                        full_content += data.get('content', '')\n\n                    # 收集推理内容（如果有）\n                    if 'reasoning_content' in data:\n                        full_reasoning += data.get('reasoning_content', '')\n\n                elif event_type.split('.')[-1] == 'done':  # 本地部署coze时，结束event不为done\n                    # 保存会话ID\n                    if 'conversation_id' in data:\n                        conversation_id = data.get('conversation_id')\n\n                elif event_type == 'error':\n                    # 处理错误\n                    error_msg = f'Coze API错误: {data.get(\"message\", \"未知错误\")}'\n                    yield provider_message.Message(\n                        role='assistant',\n                        content=error_msg,\n                    )\n                    return\n\n            # 处理思维链内容\n            content, thinking_content = self._process_thinking_content(full_content)\n            if full_reasoning:\n                remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)\n                if not remove_think:\n                    content = f'<think>\\n{full_reasoning}\\n</think>\\n{content}'.strip()\n\n            # 一次性返回完整内容\n            yield provider_message.Message(\n                role='assistant',\n                content=content,\n            )\n\n            # 保存会话ID\n            if conversation_id and query.session.using_conversation:\n                query.session.using_conversation.uuid = conversation_id\n\n        except Exception as e:\n            self.ap.logger.error(f'Coze API错误: {str(e)}')\n            yield provider_message.Message(\n                role='assistant',\n                content=f'Coze API调用失败: {str(e)}',\n            )\n\n    async def _chat_messages_chunk(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:\n        \"\"\"调用聊天助手（流式）\"\"\"\n        user_id = f'{query.launcher_type.value}_{query.launcher_id}'\n\n        # 预处理用户消息\n        additional_messages = await self._preprocess_user_message(query)\n\n        # 获取会话ID\n        conversation_id = None\n\n        start_reasoning = False\n        stop_reasoning = False\n        message_idx = 1\n        is_final = False\n        full_content = ''\n        remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)\n\n        try:\n            # 调用Coze API流式接口\n            async for chunk in self.coze.chat_messages(\n                bot_id=self.bot_id,\n                user_id=user_id,\n                additional_messages=additional_messages,\n                conversation_id=conversation_id,\n                timeout=self.chat_timeout,\n                auto_save_history=self.auto_save_history,\n                stream=True,\n            ):\n                self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}')\n\n                event_type = chunk.get('event')\n                data = chunk.get('data', {})\n                content = ''\n\n                if event_type == 'conversation.message.delta':\n                    message_idx += 1\n                    # 处理内容增量\n                    if 'reasoning_content' in data and not remove_think:\n                        reasoning_content = data.get('reasoning_content', '')\n                        if reasoning_content and not start_reasoning:\n                            content = '<think/>\\n'\n                            start_reasoning = True\n                        content += reasoning_content\n\n                    if 'content' in data:\n                        if data.get('content', ''):\n                            content += data.get('content', '')\n                            if not stop_reasoning and start_reasoning:\n                                content = f'</think>\\n{content}'\n                                stop_reasoning = True\n\n                elif event_type.split('.')[-1] == 'done':  # 本地部署coze时，结束event不为done\n                    # 保存会话ID\n                    if 'conversation_id' in data:\n                        conversation_id = data.get('conversation_id')\n                        if query.session.using_conversation:\n                            query.session.using_conversation.uuid = conversation_id\n                    is_final = True\n\n                elif event_type == 'error':\n                    # 处理错误\n                    error_msg = f'Coze API错误: {data.get(\"message\", \"未知错误\")}'\n                    yield provider_message.MessageChunk(role='assistant', content=error_msg, finish_reason='error')\n                    return\n                full_content += content\n                if message_idx % 8 == 0 or is_final:\n                    if full_content:\n                        yield provider_message.MessageChunk(role='assistant', content=full_content, is_final=is_final)\n\n        except Exception as e:\n            self.ap.logger.error(f'Coze API流式调用错误: {str(e)}')\n            yield provider_message.MessageChunk(\n                role='assistant', content=f'Coze API流式调用失败: {str(e)}', finish_reason='error'\n            )\n\n    async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"运行\"\"\"\n        msg_seq = 0\n        if await query.adapter.is_stream_output_supported():\n            async for msg in self._chat_messages_chunk(query):\n                if isinstance(msg, provider_message.MessageChunk):\n                    msg_seq += 1\n                    msg.msg_sequence = msg_seq\n                yield msg\n        else:\n            async for msg in self._chat_messages(query):\n                yield msg\n"
  },
  {
    "path": "src/langbot/pkg/provider/runners/dashscopeapi.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport re\n\nimport dashscope\n\nfrom .. import runner\nfrom ...core import app\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nclass DashscopeAPIError(Exception):\n    \"\"\"Dashscope API 请求失败\"\"\"\n\n    def __init__(self, message: str):\n        self.message = message\n        super().__init__(self.message)\n\n\n@runner.runner_class('dashscope-app-api')\nclass DashScopeAPIRunner(runner.RequestRunner):\n    \"阿里云百炼DashsscopeAPI对话请求器\"\n\n    # 运行器内部使用的配置\n    app_type: str  # 应用类型\n    app_id: str  # 应用ID\n    api_key: str  # API Key\n    references_quote: (\n        str  # 引用资料提示（当展示回答来源功能开启时，这个变量会作为引用资料名前的提示，可在provider.json中配置）\n    )\n\n    def __init__(self, ap: app.Application, pipeline_config: dict):\n        \"\"\"初始化\"\"\"\n        self.ap = ap\n        self.pipeline_config = pipeline_config\n\n        valid_app_types = ['agent', 'workflow']\n        self.app_type = self.pipeline_config['ai']['dashscope-app-api']['app-type']\n        # 检查配置文件中使用的应用类型是否支持\n        if self.app_type not in valid_app_types:\n            raise DashscopeAPIError(f'不支持的 Dashscope 应用类型: {self.app_type}')\n\n        # 初始化Dashscope 参数配置\n        self.app_id = self.pipeline_config['ai']['dashscope-app-api']['app-id']\n        self.api_key = self.pipeline_config['ai']['dashscope-app-api']['api-key']\n        self.references_quote = self.pipeline_config['ai']['dashscope-app-api']['references_quote']\n\n    def _replace_references(self, text, references_dict):\n        \"\"\"阿里云百炼平台的自定义应用支持资料引用，此函数可以将引用标签替换为参考资料\"\"\"\n\n        # 匹配 <ref>[index_id]</ref> 形式的字符串\n        pattern = re.compile(r'<ref>\\[(.*?)\\]</ref>')\n\n        def replacement(match):\n            # 获取引用编号\n            ref_key = match.group(1)\n            if ref_key in references_dict:\n                # 如果有对应的参考资料按照provider.json中的reference_quote返回提示，来自哪个参考资料文件\n                return f'({self.references_quote} {references_dict[ref_key]})'\n            else:\n                # 如果没有对应的参考资料，保留原样\n                return match.group(0)\n\n        # 使用 re.sub() 进行替换\n        return pattern.sub(replacement, text)\n\n    async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]:\n        \"\"\"预处理用户消息，提取纯文本，阿里云提供的上传文件方法过于复杂，暂不支持上传文件（包括图片）\"\"\"\n        plain_text = ''\n        image_ids = []\n        if isinstance(query.user_message.content, list):\n            for ce in query.user_message.content:\n                if ce.type == 'text':\n                    plain_text += ce.text\n                # 暂时不支持上传图片，保留代码以便后续扩展\n                # elif ce.type == \"image_base64\":\n                #     image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)\n                #     file_bytes = base64.b64decode(image_b64)\n                #     file = (\"img.png\", file_bytes, f\"image/{image_format}\")\n                #     file_upload_resp = await self.dify_client.upload_file(\n                #         file,\n                #         f\"{query.session.launcher_type.value}_{query.session.launcher_id}\",\n                #     )\n                #     image_id = file_upload_resp[\"id\"]\n                #     image_ids.append(image_id)\n        elif isinstance(query.user_message.content, str):\n            plain_text = query.user_message.content\n\n        return plain_text, image_ids\n\n    async def _agent_messages(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"Dashscope 智能体对话请求\"\"\"\n\n        # 局部变量\n        chunk = None  # 流式传输的块\n        pending_content = ''  # 待处理的Agent输出内容\n        references_dict = {}  # 用于存储引用编号和对应的参考资料\n        plain_text = ''  # 用户输入的纯文本信息\n        image_ids = []  # 用户输入的图片ID列表 （暂不支持）\n\n        think_start = False\n        think_end = False\n\n        plain_text, image_ids = await self._preprocess_user_message(query)\n        has_thoughts = True  # 获取思考过程\n        remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')\n        if remove_think:\n            has_thoughts = False\n        # 发送对话请求\n        response = dashscope.Application.call(\n            api_key=self.api_key,  # 智能体应用的API Key\n            app_id=self.app_id,  # 智能体应用的ID\n            prompt=plain_text,  # 用户输入的文本信息\n            stream=True,  # 流式输出\n            incremental_output=True,  # 增量输出，使用流式输出需要开启增量输出\n            session_id=query.session.using_conversation.uuid,  # 会话ID用于，多轮对话\n            enable_thinking=has_thoughts,\n            has_thoughts=has_thoughts,\n            # rag_options={                                     # 主要用于文件交互，暂不支持\n            #     \"session_file_ids\": [\"FILE_ID1\"],             # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个\n            # }\n        )\n        idx_chunk = 0\n        try:\n            is_stream = await query.adapter.is_stream_output_supported()\n\n        except AttributeError:\n            is_stream = False\n        if is_stream:\n            for chunk in response:\n                if chunk.get('status_code') != 200:\n                    raise DashscopeAPIError(\n                        f'Dashscope API 请求失败: status_code={chunk.get(\"status_code\")} message={chunk.get(\"message\")} request_id={chunk.get(\"request_id\")} '\n                    )\n                if not chunk:\n                    continue\n                idx_chunk += 1\n                # 获取流式传输的output\n                stream_output = chunk.get('output', {})\n                stream_think = stream_output.get('thoughts', [])\n                if stream_think and stream_think[0].get('thought'):\n                    if not think_start:\n                        think_start = True\n                        pending_content += f'<think>\\n{stream_think[0].get(\"thought\")}'\n                    else:\n                        # 继续输出 reasoning_content\n                        pending_content += stream_think[0].get('thought')\n                elif (not stream_think or stream_think[0].get('thought') == '') and not think_end:\n                    think_end = True\n                    pending_content += '\\n</think>\\n'\n                if stream_output.get('text') is not None:\n                    pending_content += stream_output.get('text')\n                # 是否是流式最后一个chunk\n                is_final = False if stream_output.get('finish_reason', False) == 'null' else True\n\n                # 获取模型传出的参考资料列表\n                references_dict_list = stream_output.get('doc_references', [])\n\n                # 从模型传出的参考资料信息中提取用于替换的字典\n                if references_dict_list is not None:\n                    for doc in references_dict_list:\n                        if doc.get('index_id') is not None:\n                            references_dict[doc.get('index_id')] = doc.get('doc_name')\n\n                    # 将参考资料替换到文本中\n                    pending_content = self._replace_references(pending_content, references_dict)\n\n                if idx_chunk % 8 == 0 or is_final:\n                    yield provider_message.MessageChunk(\n                        role='assistant',\n                        content=pending_content,\n                        is_final=is_final,\n                    )\n            # 保存当前会话的session_id用于下次对话的语境\n            query.session.using_conversation.uuid = stream_output.get('session_id')\n        else:\n            for chunk in response:\n                if chunk.get('status_code') != 200:\n                    raise DashscopeAPIError(\n                        f'Dashscope API 请求失败: status_code={chunk.get(\"status_code\")} message={chunk.get(\"message\")} request_id={chunk.get(\"request_id\")} '\n                    )\n                if not chunk:\n                    continue\n                idx_chunk += 1\n                # 获取流式传输的output\n                stream_output = chunk.get('output', {})\n                stream_think = stream_output.get('thoughts', [])\n                if stream_think[0].get('thought'):\n                    if not think_start:\n                        think_start = True\n                        pending_content += f'<think>\\n{stream_think[0].get(\"thought\")}'\n                    else:\n                        # 继续输出 reasoning_content\n                        pending_content += stream_think[0].get('thought')\n                elif stream_think[0].get('thought') == '' and not think_end:\n                    think_end = True\n                    pending_content += '\\n</think>\\n'\n                if stream_output.get('text') is not None:\n                    pending_content += stream_output.get('text')\n\n            # 保存当前会话的session_id用于下次对话的语境\n            query.session.using_conversation.uuid = stream_output.get('session_id')\n\n            # 获取模型传出的参考资料列表\n            references_dict_list = stream_output.get('doc_references', [])\n\n            # 从模型传出的参考资料信息中提取用于替换的字典\n            if references_dict_list is not None:\n                for doc in references_dict_list:\n                    if doc.get('index_id') is not None:\n                        references_dict[doc.get('index_id')] = doc.get('doc_name')\n\n                # 将参考资料替换到文本中\n                pending_content = self._replace_references(pending_content, references_dict)\n\n            yield provider_message.Message(\n                role='assistant',\n                content=pending_content,\n            )\n\n    async def _workflow_messages(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"Dashscope 工作流对话请求\"\"\"\n\n        # 局部变量\n        chunk = None  # 流式传输的块\n        pending_content = ''  # 待处理的Agent输出内容\n        references_dict = {}  # 用于存储引用编号和对应的参考资料\n        plain_text = ''  # 用户输入的纯文本信息\n        image_ids = []  # 用户输入的图片ID列表 （暂不支持）\n\n        plain_text, image_ids = await self._preprocess_user_message(query)\n\n        biz_params = {}\n        biz_params.update(query.variables)\n\n        # 发送对话请求\n        response = dashscope.Application.call(\n            api_key=self.api_key,  # 智能体应用的API Key\n            app_id=self.app_id,  # 智能体应用的ID\n            prompt=plain_text,  # 用户输入的文本信息\n            stream=True,  # 流式输出\n            incremental_output=True,  # 增量输出，使用流式输出需要开启增量输出\n            session_id=query.session.using_conversation.uuid,  # 会话ID用于，多轮对话\n            biz_params=biz_params,  # 工作流应用的自定义输入参数传递\n            flow_stream_mode='message_format',  # 消息模式，输出/结束节点的流式结果\n            # rag_options={                                     # 主要用于文件交互，暂不支持\n            #     \"session_file_ids\": [\"FILE_ID1\"],             # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个\n            # }\n        )\n\n        # 处理API返回的流式输出\n        try:\n            is_stream = await query.adapter.is_stream_output_supported()\n\n        except AttributeError:\n            is_stream = False\n        idx_chunk = 0\n        if is_stream:\n            for chunk in response:\n                if chunk.get('status_code') != 200:\n                    raise DashscopeAPIError(\n                        f'Dashscope API 请求失败: status_code={chunk.get(\"status_code\")} message={chunk.get(\"message\")} request_id={chunk.get(\"request_id\")} '\n                    )\n                if not chunk:\n                    continue\n                idx_chunk += 1\n                # 获取流式传输的output\n                stream_output = chunk.get('output', {})\n                if stream_output.get('workflow_message') is not None:\n                    pending_content += stream_output.get('workflow_message').get('message').get('content')\n                # if stream_output.get('text') is not None:\n                #     pending_content += stream_output.get('text')\n\n                is_final = False if stream_output.get('finish_reason', False) == 'null' else True\n\n                # 获取模型传出的参考资料列表\n                references_dict_list = stream_output.get('doc_references', [])\n\n                # 从模型传出的参考资料信息中提取用于替换的字典\n                if references_dict_list is not None:\n                    for doc in references_dict_list:\n                        if doc.get('index_id') is not None:\n                            references_dict[doc.get('index_id')] = doc.get('doc_name')\n\n                    # 将参考资料替换到文本中\n                    pending_content = self._replace_references(pending_content, references_dict)\n                if idx_chunk % 8 == 0 or is_final:\n                    yield provider_message.MessageChunk(\n                        role='assistant',\n                        content=pending_content,\n                        is_final=is_final,\n                    )\n\n            # 保存当前会话的session_id用于下次对话的语境\n            query.session.using_conversation.uuid = stream_output.get('session_id')\n\n        else:\n            for chunk in response:\n                if chunk.get('status_code') != 200:\n                    raise DashscopeAPIError(\n                        f'Dashscope API 请求失败: status_code={chunk.get(\"status_code\")} message={chunk.get(\"message\")} request_id={chunk.get(\"request_id\")} '\n                    )\n                if not chunk:\n                    continue\n\n                # 获取流式传输的output\n                stream_output = chunk.get('output', {})\n                if stream_output.get('text') is not None:\n                    pending_content += stream_output.get('text')\n\n                is_final = False if stream_output.get('finish_reason', False) == 'null' else True\n\n            # 保存当前会话的session_id用于下次对话的语境\n            query.session.using_conversation.uuid = stream_output.get('session_id')\n\n            # 获取模型传出的参考资料列表\n            references_dict_list = stream_output.get('doc_references', [])\n\n            # 从模型传出的参考资料信息中提取用于替换的字典\n            if references_dict_list is not None:\n                for doc in references_dict_list:\n                    if doc.get('index_id') is not None:\n                        references_dict[doc.get('index_id')] = doc.get('doc_name')\n\n                # 将参考资料替换到文本中\n                pending_content = self._replace_references(pending_content, references_dict)\n\n            yield provider_message.Message(\n                role='assistant',\n                content=pending_content,\n            )\n\n    async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"运行\"\"\"\n        msg_seq = 0\n        if self.app_type == 'agent':\n            async for msg in self._agent_messages(query):\n                if isinstance(msg, provider_message.MessageChunk):\n                    msg_seq += 1\n                    msg.msg_sequence = msg_seq\n                yield msg\n        elif self.app_type == 'workflow':\n            async for msg in self._workflow_messages(query):\n                if isinstance(msg, provider_message.MessageChunk):\n                    msg_seq += 1\n                    msg.msg_sequence = msg_seq\n                yield msg\n        else:\n            raise DashscopeAPIError(f'不支持的 Dashscope 应用类型: {self.app_type}')\n"
  },
  {
    "path": "src/langbot/pkg/provider/runners/difysvapi.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport json\nimport uuid\nimport base64\nimport mimetypes\n\n\nfrom langbot.pkg.provider import runner\nfrom langbot.pkg.core import app\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\nfrom langbot.pkg.utils import image\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nfrom langbot.libs.dify_service_api.v1 import client, errors\nimport httpx\n\n\n@runner.runner_class('dify-service-api')\nclass DifyServiceAPIRunner(runner.RequestRunner):\n    \"\"\"Dify Service API 对话请求器\"\"\"\n\n    dify_client: client.AsyncDifyServiceClient\n\n    def __init__(self, ap: app.Application, pipeline_config: dict):\n        self.ap = ap\n        self.pipeline_config = pipeline_config\n\n        valid_app_types = ['chat', 'agent', 'workflow']\n        if self.pipeline_config['ai']['dify-service-api']['app-type'] not in valid_app_types:\n            raise errors.DifyAPIError(\n                f'不支持的 Dify 应用类型: {self.pipeline_config[\"ai\"][\"dify-service-api\"][\"app-type\"]}'\n            )\n\n        api_key = self.pipeline_config['ai']['dify-service-api']['api-key']\n\n        self.dify_client = client.AsyncDifyServiceClient(\n            api_key=api_key,\n            base_url=self.pipeline_config['ai']['dify-service-api']['base-url'],\n        )\n\n    def _process_thinking_content(\n        self,\n        content: str,\n    ) -> tuple[str, str]:\n        \"\"\"处理思维链内容\n\n        Args:\n            content: 原始内容\n        Returns:\n            (处理后的内容, 提取的思维链内容)\n        \"\"\"\n        remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')\n        thinking_content = ''\n        # 从 content 中提取 <think> 标签内容\n        if content and '<think>' in content and '</think>' in content:\n            import re\n\n            think_pattern = r'<think>(.*?)</think>'\n            think_matches = re.findall(think_pattern, content, re.DOTALL)\n            if think_matches:\n                thinking_content = '\\n'.join(think_matches)\n                # 移除 content 中的 <think> 标签\n                content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()\n\n        # 3. 根据 remove_think 参数决定是否保留思维链\n        if remove_think:\n            return content, ''\n        else:\n            # 如果有思维链内容，将其以 <think> 格式添加到 content 开头\n            if thinking_content:\n                content = f'<think>\\n{thinking_content}\\n</think>\\n{content}'.strip()\n            return content, thinking_content\n\n    def _extract_dify_text_output(self, value: typing.Any) -> str:\n        \"\"\"Extract text content from Dify output payload.\"\"\"\n        if value is None:\n            return ''\n        if isinstance(value, dict):\n            content = value.get('content')\n            if isinstance(content, str):\n                return content\n            return json.dumps(value, ensure_ascii=False)\n        if isinstance(value, str):\n            text = value.strip()\n            if not text:\n                return ''\n            try:\n                parsed = json.loads(text)\n            except json.JSONDecodeError:\n                return value\n            if isinstance(parsed, dict) and isinstance(parsed.get('content'), str):\n                return parsed['content']\n            return value\n        return str(value)\n\n    async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[dict]]:\n        \"\"\"预处理用户消息，提取纯文本，并将图片/文件上传到 Dify 服务\n\n        Returns:\n            tuple[str, list[dict]]: 纯文本和上传后的文件描述（包含 type 与 id）\n        \"\"\"\n        plain_text = ''\n        upload_files: list[dict] = []\n        user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'\n\n        async def upload_file_bytes(file_name: str, file_bytes: bytes, content_type: str) -> str:\n            file_name = file_name or 'file'\n            content_type = content_type or 'application/octet-stream'\n            file = (file_name, file_bytes, content_type)\n            resp = await self.dify_client.upload_file(file, user_tag)\n            return resp['id']\n\n        async def download_file(file_url: str) -> tuple[bytes, str]:\n            \"\"\"Download file from url (supports data url).\"\"\"\n\n            async with httpx.AsyncClient() as client_session:\n                resp = await client_session.get(file_url)\n                resp.raise_for_status()\n                content_type = (\n                    resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream'\n                )\n                return resp.content, content_type\n\n        def _detect_file_type(content_type: str) -> str:\n            \"\"\"Map MIME to dify file type.\"\"\"\n            if content_type and content_type.startswith('image/'):\n                return 'image'\n            if content_type and content_type.startswith('audio/'):\n                return 'audio'\n            if content_type and content_type.startswith('video/'):\n                return 'video'\n            return 'document'\n\n        if isinstance(query.user_message.content, list):\n            for ce in query.user_message.content:\n                if ce.type == 'text':\n                    plain_text += ce.text\n                elif ce.type == 'image_base64':\n                    image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)\n                    file_bytes = base64.b64decode(image_b64)\n                    image_id = await upload_file_bytes(f'img.{image_format}', file_bytes, f'image/{image_format}')\n                    upload_files.append({'type': 'image', 'id': image_id})\n                elif ce.type == 'file_url':\n                    file_url = getattr(ce, 'file_url', None)\n                    file_name = getattr(ce, 'file_name', None) or 'file'\n                    try:\n                        file_bytes, content_type = await download_file(file_url)\n                        file_id = await upload_file_bytes(file_name, file_bytes, content_type)\n                        file_type = _detect_file_type(content_type)\n                        upload_files.append({'type': file_type, 'id': file_id})\n                    except Exception as e:\n                        self.ap.logger.warning(f'dify file upload failed: {e}')\n                elif ce.type == 'file_base64':\n                    file_name = getattr(ce, 'file_name', None) or 'file'\n\n                    header, b64_data = ce.file_base64.split(',', 1)\n                    content_type = 'application/octet-stream'\n                    if ';' in header:\n                        content_type = header.split(';')[0][5:] or content_type\n                    file_bytes = base64.b64decode(b64_data)\n                    file_id = await upload_file_bytes(file_name, file_bytes, content_type)\n                    file_type = _detect_file_type(content_type)\n                    upload_files.append({'type': file_type, 'id': file_id})\n\n        elif isinstance(query.user_message.content, str):\n            plain_text = query.user_message.content\n\n        plain_text = plain_text if plain_text else self.pipeline_config['ai']['dify-service-api']['base-prompt']\n\n        return plain_text, upload_files\n\n    async def _chat_messages(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"调用聊天助手\"\"\"\n        cov_id = query.session.using_conversation.uuid or None\n        query.variables['conversation_id'] = cov_id\n\n        plain_text, upload_files = await self._preprocess_user_message(query)\n\n        files = [\n            {\n                'type': f['type'],\n                'transfer_method': 'local_file',\n                'upload_file_id': f['id'],\n            }\n            for f in upload_files\n        ]\n\n        mode = 'basic'  # 标记是基础编排还是工作流编排\n\n        basic_mode_pending_chunk = ''\n\n        inputs = {}\n\n        inputs.update(query.variables)\n\n        chunk = None  # 初始化chunk变量，防止在没有响应时引用错误\n\n        async for chunk in self.dify_client.chat_messages(\n            inputs=inputs,\n            query=plain_text,\n            user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',\n            conversation_id=cov_id,\n            files=files,\n            timeout=120,\n        ):\n            self.ap.logger.debug('dify-chat-chunk: ' + str(chunk))\n\n            if chunk['event'] == 'workflow_started':\n                mode = 'workflow'\n\n            if mode == 'workflow':\n                if chunk['event'] == 'node_finished':\n                    if chunk['data']['node_type'] == 'answer':\n                        answer = self._extract_dify_text_output(chunk['data']['outputs'].get('answer'))\n                        content, _ = self._process_thinking_content(answer)\n\n                        yield provider_message.Message(\n                            role='assistant',\n                            content=content,\n                        )\n            elif mode == 'basic':\n                if chunk['event'] == 'message':\n                    basic_mode_pending_chunk += chunk['answer']\n                elif chunk['event'] == 'message_end':\n                    content, _ = self._process_thinking_content(basic_mode_pending_chunk)\n                    yield provider_message.Message(\n                        role='assistant',\n                        content=content,\n                    )\n                    basic_mode_pending_chunk = ''\n\n        if chunk is None:\n            raise errors.DifyAPIError('Dify API 没有返回任何响应，请检查网络连接和API配置')\n\n        query.session.using_conversation.uuid = chunk['conversation_id']\n\n    async def _agent_chat_messages(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"调用聊天助手\"\"\"\n        cov_id = query.session.using_conversation.uuid or None\n        query.variables['conversation_id'] = cov_id\n\n        plain_text, upload_files = await self._preprocess_user_message(query)\n\n        files = [\n            {\n                'type': f['type'],\n                'transfer_method': 'local_file',\n                'upload_file_id': f['id'],\n            }\n            for f in upload_files\n        ]\n\n        ignored_events = []\n\n        inputs = {}\n\n        inputs.update(query.variables)\n\n        pending_agent_message = ''\n\n        chunk = None  # 初始化chunk变量，防止在没有响应时引用错误\n\n        async for chunk in self.dify_client.chat_messages(\n            inputs=inputs,\n            query=plain_text,\n            user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',\n            response_mode='streaming',\n            conversation_id=cov_id,\n            files=files,\n            timeout=120,\n        ):\n            self.ap.logger.debug('dify-agent-chunk: ' + str(chunk))\n\n            if chunk['event'] in ignored_events:\n                continue\n\n            if chunk['event'] == 'agent_message' or chunk['event'] == 'message':\n                pending_agent_message += chunk['answer']\n            else:\n                if pending_agent_message.strip() != '':\n                    pending_agent_message = pending_agent_message.replace('</details>Action:', '</details>')\n                    content, _ = self._process_thinking_content(pending_agent_message)\n                    yield provider_message.Message(\n                        role='assistant',\n                        content=content,\n                    )\n                pending_agent_message = ''\n\n                if chunk['event'] == 'agent_thought':\n                    if chunk['tool'] != '' and chunk['observation'] != '':  # 工具调用结果，跳过\n                        continue\n\n                    if chunk['tool']:\n                        msg = provider_message.Message(\n                            role='assistant',\n                            tool_calls=[\n                                provider_message.ToolCall(\n                                    id=chunk['id'],\n                                    type='function',\n                                    function=provider_message.FunctionCall(\n                                        name=chunk['tool'],\n                                        arguments=json.dumps({}),\n                                    ),\n                                )\n                            ],\n                        )\n                        yield msg\n                if chunk['event'] == 'message_file':\n                    if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant':\n                        # 检查URL是否已经是完整的连接\n                        if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'):\n                            image_url = chunk['url']\n                        else:\n                            base_url = self.dify_client.base_url\n\n                            if base_url.endswith('/v1'):\n                                base_url = base_url[:-3]\n\n                            image_url = base_url + chunk['url']\n\n                        yield provider_message.Message(\n                            role='assistant',\n                            content=[provider_message.ContentElement.from_image_url(image_url)],\n                        )\n                if chunk['event'] == 'error':\n                    raise errors.DifyAPIError('dify 服务错误: ' + chunk['message'])\n\n        if chunk is None:\n            raise errors.DifyAPIError('Dify API 没有返回任何响应，请检查网络连接和API配置')\n\n        query.session.using_conversation.uuid = chunk['conversation_id']\n\n    async def _workflow_messages(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"调用工作流\"\"\"\n\n        if not query.session.using_conversation.uuid:\n            query.session.using_conversation.uuid = str(uuid.uuid4())\n\n        query.variables['conversation_id'] = query.session.using_conversation.uuid\n\n        plain_text, upload_files = await self._preprocess_user_message(query)\n\n        files = [\n            {\n                'type': f['type'],\n                'transfer_method': 'local_file',\n                'upload_file_id': f['id'],\n            }\n            for f in upload_files\n        ]\n\n        ignored_events = ['text_chunk', 'workflow_started']\n\n        inputs = {  # these variables are legacy variables, we need to keep them for compatibility\n            'langbot_user_message_text': plain_text,\n            'langbot_session_id': query.variables['session_id'],\n            'langbot_conversation_id': query.variables['conversation_id'],\n            'langbot_msg_create_time': query.variables['msg_create_time'],\n        }\n\n        inputs.update(query.variables)\n\n        async for chunk in self.dify_client.workflow_run(\n            inputs=inputs,\n            user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',\n            files=files,\n            timeout=120,\n        ):\n            self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk))\n            if chunk['event'] in ignored_events:\n                continue\n\n            if chunk['event'] == 'node_started':\n                if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end':\n                    continue\n\n                msg = provider_message.Message(\n                    role='assistant',\n                    content=None,\n                    tool_calls=[\n                        provider_message.ToolCall(\n                            id=chunk['data']['node_id'],\n                            type='function',\n                            function=provider_message.FunctionCall(\n                                name=chunk['data']['title'],\n                                arguments=json.dumps({}),\n                            ),\n                        )\n                    ],\n                )\n\n                yield msg\n\n            elif chunk['event'] == 'workflow_finished':\n                if chunk['data']['error']:\n                    raise errors.DifyAPIError(chunk['data']['error'])\n                content, _ = self._process_thinking_content(chunk['data']['outputs']['summary'])\n\n                msg = provider_message.Message(\n                    role='assistant',\n                    content=content,\n                )\n\n                yield msg\n\n    async def _chat_messages_chunk(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:\n        \"\"\"调用聊天助手\"\"\"\n        cov_id = query.session.using_conversation.uuid or None\n        query.variables['conversation_id'] = cov_id\n\n        plain_text, upload_files = await self._preprocess_user_message(query)\n\n        files = [\n            {\n                'type': f['type'],\n                'transfer_method': 'local_file',\n                'upload_file_id': f['id'],\n            }\n            for f in upload_files\n        ]\n\n        mode = 'basic'\n        basic_mode_pending_chunk = ''\n\n        inputs = {}\n\n        inputs.update(query.variables)\n        message_idx = 0\n\n        chunk = None  # 初始化chunk变量，防止在没有响应时引用错误\n\n        is_final = False\n        think_start = False\n        think_end = False\n        yielded_final = False\n\n        remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')\n\n        async for chunk in self.dify_client.chat_messages(\n            inputs=inputs,\n            query=plain_text,\n            user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',\n            conversation_id=cov_id,\n            files=files,\n            timeout=120,\n        ):\n            self.ap.logger.debug('dify-chat-chunk: ' + str(chunk))\n\n            if chunk['event'] == 'workflow_started':\n                mode = 'workflow'\n            elif chunk['event'] in ('node_started', 'node_finished', 'workflow_finished'):\n                # Some Dify deployments may omit workflow_started in streamed chunks.\n                mode = 'workflow'\n\n            if chunk['event'] == 'message':\n                message_idx += 1\n                if remove_think:\n                    if '<think>' in chunk['answer'] and not think_start:\n                        think_start = True\n                        continue\n                    if '</think>' in chunk['answer'] and not think_end:\n                        import re\n\n                        content = re.sub(r'^\\n</think>', '', chunk['answer'])\n                        basic_mode_pending_chunk += content\n                        think_end = True\n                    elif think_end:\n                        basic_mode_pending_chunk += chunk['answer']\n                    if think_start:\n                        continue\n\n                else:\n                    basic_mode_pending_chunk += chunk['answer']\n\n            if chunk['event'] == 'message_end':\n                is_final = True\n            elif chunk['event'] == 'workflow_finished':\n                is_final = True\n                if chunk['data'].get('error'):\n                    raise errors.DifyAPIError(chunk['data']['error'])\n\n            if mode == 'workflow' and chunk['event'] == 'node_finished':\n                if chunk['data'].get('node_type') == 'answer':\n                    answer = self._extract_dify_text_output(chunk['data'].get('outputs', {}).get('answer'))\n                    if answer:\n                        basic_mode_pending_chunk = answer\n\n            if (\n                not yielded_final\n                and (is_final or message_idx % 8 == 0)\n                and (basic_mode_pending_chunk != '' or is_final)\n            ):\n                # content, _ = self._process_thinking_content(basic_mode_pending_chunk)\n                yield provider_message.MessageChunk(\n                    role='assistant',\n                    content=basic_mode_pending_chunk,\n                    is_final=is_final,\n                )\n                if is_final:\n                    yielded_final = True\n\n        if chunk is None:\n            raise errors.DifyAPIError('Dify API 没有返回任何响应，请检查网络连接和API配置')\n\n        query.session.using_conversation.uuid = chunk['conversation_id']\n\n    async def _agent_chat_messages_chunk(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:\n        \"\"\"调用聊天助手\"\"\"\n        cov_id = query.session.using_conversation.uuid or None\n        query.variables['conversation_id'] = cov_id\n\n        plain_text, upload_files = await self._preprocess_user_message(query)\n\n        files = [\n            {\n                'type': f['type'],\n                'transfer_method': 'local_file',\n                'upload_file_id': f['id'],\n            }\n            for f in upload_files\n        ]\n\n        ignored_events = []\n\n        inputs = {}\n\n        inputs.update(query.variables)\n\n        pending_agent_message = ''\n\n        chunk = None  # 初始化chunk变量，防止在没有响应时引用错误\n        message_idx = 0\n        is_final = False\n        think_start = False\n        think_end = False\n\n        remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')\n\n        async for chunk in self.dify_client.chat_messages(\n            inputs=inputs,\n            query=plain_text,\n            user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',\n            response_mode='streaming',\n            conversation_id=cov_id,\n            files=files,\n            timeout=120,\n        ):\n            self.ap.logger.debug('dify-agent-chunk: ' + str(chunk))\n\n            if chunk['event'] in ignored_events:\n                continue\n\n            if chunk['event'] == 'agent_message':\n                message_idx += 1\n                if remove_think:\n                    if '<think>' in chunk['answer'] and not think_start:\n                        think_start = True\n                        continue\n                    if '</think>' in chunk['answer'] and not think_end:\n                        import re\n\n                        content = re.sub(r'^\\n</think>', '', chunk['answer'])\n                        pending_agent_message += content\n                        think_end = True\n                    elif think_end or not think_start:\n                        pending_agent_message += chunk['answer']\n                    if think_start and not think_end:\n                        continue\n\n                else:\n                    pending_agent_message += chunk['answer']\n            elif chunk['event'] == 'message_end':\n                is_final = True\n            else:\n                if chunk['event'] == 'agent_thought':\n                    if chunk['tool'] != '' and chunk['observation'] != '':  # 工具调用结果，跳过\n                        continue\n                    message_idx += 1\n                    if chunk['tool']:\n                        msg = provider_message.MessageChunk(\n                            role='assistant',\n                            tool_calls=[\n                                provider_message.ToolCall(\n                                    id=chunk['id'],\n                                    type='function',\n                                    function=provider_message.FunctionCall(\n                                        name=chunk['tool'],\n                                        arguments=json.dumps({}),\n                                    ),\n                                )\n                            ],\n                        )\n                        yield msg\n                if chunk['event'] == 'message_file':\n                    message_idx += 1\n                    if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant':\n                        # 检查URL是否已经是完整的连接\n                        if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'):\n                            image_url = chunk['url']\n                        else:\n                            base_url = self.dify_client.base_url\n\n                            if base_url.endswith('/v1'):\n                                base_url = base_url[:-3]\n\n                            image_url = base_url + chunk['url']\n\n                        yield provider_message.MessageChunk(\n                            role='assistant',\n                            content=[provider_message.ContentElement.from_image_url(image_url)],\n                            is_final=is_final,\n                        )\n\n                if chunk['event'] == 'error':\n                    raise errors.DifyAPIError('dify 服务错误: ' + chunk['message'])\n            if message_idx % 8 == 0 or is_final:\n                yield provider_message.MessageChunk(\n                    role='assistant',\n                    content=pending_agent_message,\n                    is_final=is_final,\n                )\n\n        if chunk is None:\n            raise errors.DifyAPIError('Dify API 没有返回任何响应，请检查网络连接和API配置')\n\n        query.session.using_conversation.uuid = chunk['conversation_id']\n\n    async def _workflow_messages_chunk(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:\n        \"\"\"调用工作流\"\"\"\n\n        if not query.session.using_conversation.uuid:\n            query.session.using_conversation.uuid = str(uuid.uuid4())\n\n        query.variables['conversation_id'] = query.session.using_conversation.uuid\n\n        plain_text, upload_files = await self._preprocess_user_message(query)\n\n        files = [\n            {\n                'type': f['type'],\n                'transfer_method': 'local_file',\n                'upload_file_id': f['id'],\n            }\n            for f in upload_files\n        ]\n\n        ignored_events = ['workflow_started']\n\n        inputs = {  # these variables are legacy variables, we need to keep them for compatibility\n            'langbot_user_message_text': plain_text,\n            'langbot_session_id': query.variables['session_id'],\n            'langbot_conversation_id': query.variables['conversation_id'],\n            'langbot_msg_create_time': query.variables['msg_create_time'],\n        }\n\n        inputs.update(query.variables)\n        messsage_idx = 0\n        is_final = False\n        think_start = False\n        think_end = False\n        workflow_contents = ''\n\n        remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')\n        async for chunk in self.dify_client.workflow_run(\n            inputs=inputs,\n            user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',\n            files=files,\n            timeout=120,\n        ):\n            self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk))\n            if chunk['event'] in ignored_events:\n                continue\n            if chunk['event'] == 'workflow_finished':\n                is_final = True\n                if chunk['data']['error']:\n                    raise errors.DifyAPIError(chunk['data']['error'])\n\n            if chunk['event'] == 'text_chunk':\n                messsage_idx += 1\n                if remove_think:\n                    if '<think>' in chunk['data']['text'] and not think_start:\n                        think_start = True\n                        continue\n                    if '</think>' in chunk['data']['text'] and not think_end:\n                        import re\n\n                        content = re.sub(r'^\\n</think>', '', chunk['data']['text'])\n                        workflow_contents += content\n                        think_end = True\n                    elif think_end:\n                        workflow_contents += chunk['data']['text']\n                    if think_start:\n                        continue\n\n                else:\n                    workflow_contents += chunk['data']['text']\n\n            if chunk['event'] == 'node_started':\n                if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end':\n                    continue\n                messsage_idx += 1\n                msg = provider_message.MessageChunk(\n                    role='assistant',\n                    content=None,\n                    tool_calls=[\n                        provider_message.ToolCall(\n                            id=chunk['data']['node_id'],\n                            type='function',\n                            function=provider_message.FunctionCall(\n                                name=chunk['data']['title'],\n                                arguments=json.dumps({}),\n                            ),\n                        )\n                    ],\n                )\n\n                yield msg\n\n            if messsage_idx % 8 == 0 or is_final:\n                yield provider_message.MessageChunk(\n                    role='assistant',\n                    content=workflow_contents,\n                    is_final=is_final,\n                )\n\n    async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"运行请求\"\"\"\n        if await query.adapter.is_stream_output_supported():\n            msg_idx = 0\n            if self.pipeline_config['ai']['dify-service-api']['app-type'] == 'chat':\n                async for msg in self._chat_messages_chunk(query):\n                    msg_idx += 1\n                    msg.msg_sequence = msg_idx\n                    yield msg\n            elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'agent':\n                async for msg in self._agent_chat_messages_chunk(query):\n                    msg_idx += 1\n                    msg.msg_sequence = msg_idx\n                    yield msg\n            elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'workflow':\n                async for msg in self._workflow_messages_chunk(query):\n                    msg_idx += 1\n                    msg.msg_sequence = msg_idx\n                    yield msg\n            else:\n                raise errors.DifyAPIError(\n                    f'不支持的 Dify 应用类型: {self.pipeline_config[\"ai\"][\"dify-service-api\"][\"app-type\"]}'\n                )\n        else:\n            if self.pipeline_config['ai']['dify-service-api']['app-type'] == 'chat':\n                async for msg in self._chat_messages(query):\n                    yield msg\n            elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'agent':\n                async for msg in self._agent_chat_messages(query):\n                    yield msg\n            elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'workflow':\n                async for msg in self._workflow_messages(query):\n                    yield msg\n            else:\n                raise errors.DifyAPIError(\n                    f'不支持的 Dify 应用类型: {self.pipeline_config[\"ai\"][\"dify-service-api\"][\"app-type\"]}'\n                )\n"
  },
  {
    "path": "src/langbot/pkg/provider/runners/langflowapi.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport json\nimport httpx\nimport uuid\nimport traceback\n\nfrom .. import runner\nfrom ...core import app\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\n@runner.runner_class('langflow-api')\nclass LangflowAPIRunner(runner.RequestRunner):\n    \"\"\"Langflow API 对话请求器\"\"\"\n\n    def __init__(self, ap: app.Application, pipeline_config: dict):\n        self.ap = ap\n        self.pipeline_config = pipeline_config\n\n    async def _build_request_payload(self, query: pipeline_query.Query) -> dict:\n        \"\"\"构建请求负载\n\n        Args:\n            query: 用户查询对象\n\n        Returns:\n            dict: 请求负载\n        \"\"\"\n        # 获取用户消息文本\n        user_message_text = ''\n        if isinstance(query.user_message.content, str):\n            user_message_text = query.user_message.content\n        elif isinstance(query.user_message.content, list):\n            for item in query.user_message.content:\n                if item.type == 'text':\n                    user_message_text += item.text\n\n        # 从配置中获取 input_type 和 output_type，如果未配置则使用默认值\n        input_type = self.pipeline_config['ai']['langflow-api'].get('input_type', 'chat')\n        output_type = self.pipeline_config['ai']['langflow-api'].get('output_type', 'chat')\n\n        # 构建基本负载\n        payload = {\n            'output_type': output_type,\n            'input_type': input_type,\n            'input_value': user_message_text,\n            'session_id': str(uuid.uuid4()),\n        }\n\n        # 如果配置中有tweaks，则添加到负载中\n        tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks'))\n        if tweaks:\n            payload['tweaks'] = tweaks\n\n        return payload\n\n    async def run(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:\n        \"\"\"运行请求\n\n        Args:\n            query: 用户查询对象\n\n        Yields:\n            Message: 回复消息\n        \"\"\"\n        # 检查是否支持流式输出\n        is_stream = False\n        try:\n            is_stream = await query.adapter.is_stream_output_supported()\n        except AttributeError:\n            is_stream = False\n\n        # 从配置中获取API参数\n        base_url = self.pipeline_config['ai']['langflow-api']['base-url']\n        api_key = self.pipeline_config['ai']['langflow-api']['api-key']\n        flow_id = self.pipeline_config['ai']['langflow-api']['flow-id']\n\n        # 构建API URL\n        url = f'{base_url.rstrip(\"/\")}/api/v1/run/{flow_id}'\n\n        # 构建请求负载\n        payload = await self._build_request_payload(query)\n\n        # 设置请求头\n        headers = {'Content-Type': 'application/json', 'x-api-key': api_key}\n\n        # 发送请求\n        async with httpx.AsyncClient() as client:\n            if is_stream:\n                # 流式请求\n                async with client.stream('POST', url, json=payload, headers=headers, timeout=120.0) as response:\n                    response.raise_for_status()\n\n                    accumulated_content = ''\n                    message_count = 0\n\n                    async for line in response.aiter_lines():\n                        data_str = line\n\n                        if data_str.startswith('data: '):\n                            data_str = data_str[6:]  # 移除 \"data: \" 前缀\n\n                        try:\n                            data = json.loads(data_str)\n\n                            # 提取消息内容\n                            message_text = ''\n                            if 'outputs' in data and len(data['outputs']) > 0:\n                                output = data['outputs'][0]\n                                if 'outputs' in output and len(output['outputs']) > 0:\n                                    inner_output = output['outputs'][0]\n                                    if 'outputs' in inner_output and 'message' in inner_output['outputs']:\n                                        message_data = inner_output['outputs']['message']\n                                        if 'message' in message_data:\n                                            message_text = message_data['message']\n\n                            # 如果没有找到消息，尝试其他可能的路径\n                            if not message_text and 'messages' in data:\n                                messages = data['messages']\n                                if messages and len(messages) > 0:\n                                    message_text = messages[0].get('message', '')\n\n                            if message_text:\n                                # 更新累积内容\n                                accumulated_content = message_text\n                                message_count += 1\n\n                                # 每8条消息或有新内容时生成一个chunk\n                                if message_count % 8 == 0 or len(message_text) > 0:\n                                    yield provider_message.MessageChunk(\n                                        role='assistant', content=accumulated_content, is_final=False\n                                    )\n                        except json.JSONDecodeError:\n                            # 如果不是JSON，跳过这一行\n                            traceback.print_exc()\n                            continue\n\n                    # 发送最终消息\n                    yield provider_message.MessageChunk(role='assistant', content=accumulated_content, is_final=True)\n            else:\n                # 非流式请求\n                response = await client.post(url, json=payload, headers=headers, timeout=120.0)\n                response.raise_for_status()\n\n                # 解析响应\n                response_data = response.json()\n\n                # 提取消息内容\n                # 根据Langflow API文档，响应结构可能在outputs[0].outputs[0].outputs.message.message中\n                message_text = ''\n                if 'outputs' in response_data and len(response_data['outputs']) > 0:\n                    output = response_data['outputs'][0]\n                    if 'outputs' in output and len(output['outputs']) > 0:\n                        inner_output = output['outputs'][0]\n                        if 'outputs' in inner_output and 'message' in inner_output['outputs']:\n                            message_data = inner_output['outputs']['message']\n                            if 'message' in message_data:\n                                message_text = message_data['message']\n\n                # 如果没有找到消息，尝试其他可能的路径\n                if not message_text and 'messages' in response_data:\n                    messages = response_data['messages']\n                    if messages and len(messages) > 0:\n                        message_text = messages[0].get('message', '')\n\n                # 如果仍然没有找到消息，返回完整响应的字符串表示\n                if not message_text:\n                    message_text = json.dumps(response_data, ensure_ascii=False, indent=2)\n\n                # 生成回复消息\n                if is_stream:\n                    yield provider_message.MessageChunk(role='assistant', content=message_text, is_final=True)\n                else:\n                    reply_message = provider_message.Message(role='assistant', content=message_text)\n                    yield reply_message\n"
  },
  {
    "path": "src/langbot/pkg/provider/runners/localagent.py",
    "content": "from __future__ import annotations\n\nimport json\nimport copy\nimport typing\nfrom .. import runner\nfrom ..modelmgr import requester as modelmgr_requester\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\nimport langbot_plugin.api.entities.builtin.rag.context as rag_context\n\n\nrag_combined_prompt_template = \"\"\"\nThe following are relevant context entries retrieved from the knowledge base. \nPlease use them to answer the user's message. \nRespond in the same language as the user's input.\n\n<context>\n{rag_context}\n</context>\n\n<user_message>\n{user_message}\n</user_message>\n\"\"\"\n\n\n@runner.runner_class('local-agent')\nclass LocalAgentRunner(runner.RequestRunner):\n    \"\"\"Local agent request runner\"\"\"\n\n    async def _get_model_candidates(\n        self,\n        query: pipeline_query.Query,\n    ) -> list[modelmgr_requester.RuntimeLLMModel]:\n        \"\"\"Build ordered list of models to try: primary model + fallback models.\"\"\"\n        candidates = []\n\n        # Primary model\n        if query.use_llm_model_uuid:\n            try:\n                primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)\n                candidates.append(primary)\n            except ValueError:\n                self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')\n\n        # Fallback models\n        fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])\n        for fb_uuid in fallback_uuids:\n            try:\n                fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)\n                candidates.append(fb_model)\n            except ValueError:\n                self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')\n\n        return candidates\n\n    async def _invoke_with_fallback(\n        self,\n        query: pipeline_query.Query,\n        candidates: list[modelmgr_requester.RuntimeLLMModel],\n        messages: list,\n        funcs: list,\n        remove_think: bool,\n    ) -> tuple[provider_message.Message, modelmgr_requester.RuntimeLLMModel]:\n        \"\"\"Try non-streaming invocation with sequential fallback. Returns (message, model_used).\"\"\"\n        last_error = None\n        for model in candidates:\n            try:\n                msg = await model.provider.invoke_llm(\n                    query,\n                    model,\n                    messages,\n                    funcs if model.model_entity.abilities.__contains__('func_call') else [],\n                    extra_args=model.model_entity.extra_args,\n                    remove_think=remove_think,\n                )\n                return msg, model\n            except Exception as e:\n                last_error = e\n                self.ap.logger.warning(f'Model {model.model_entity.name} failed: {e}, trying next fallback...')\n        raise last_error or RuntimeError('No model candidates available')\n\n    async def _invoke_stream_with_fallback(\n        self,\n        query: pipeline_query.Query,\n        candidates: list[modelmgr_requester.RuntimeLLMModel],\n        messages: list,\n        funcs: list,\n        remove_think: bool,\n    ) -> tuple[typing.AsyncGenerator, modelmgr_requester.RuntimeLLMModel]:\n        \"\"\"Try streaming invocation with sequential fallback. Returns (stream_generator, model_used).\n\n        Fallback is only possible before any chunks have been yielded to the client.\n        Once streaming starts, the model is committed.\n        \"\"\"\n        last_error = None\n        for model in candidates:\n            try:\n                stream = model.provider.invoke_llm_stream(\n                    query,\n                    model,\n                    messages,\n                    funcs if model.model_entity.abilities.__contains__('func_call') else [],\n                    extra_args=model.model_entity.extra_args,\n                    remove_think=remove_think,\n                )\n                # Attempt to get the first chunk to verify the stream works\n                first_chunk = await stream.__anext__()\n\n                async def _chain_stream(first, rest):\n                    yield first\n                    async for chunk in rest:\n                        yield chunk\n\n                return _chain_stream(first_chunk, stream), model\n            except StopAsyncIteration:\n                # Empty stream — treat as success (model returned nothing)\n                async def _empty_stream():\n                    return\n                    yield  # make it a generator\n\n                return _empty_stream(), model\n            except Exception as e:\n                last_error = e\n                self.ap.logger.warning(f'Model {model.model_entity.name} stream failed: {e}, trying next fallback...')\n        raise last_error or RuntimeError('No model candidates available')\n\n    async def run(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:\n        \"\"\"Run request\"\"\"\n        pending_tool_calls = []\n\n        # Get knowledge bases list from query variables (set by PreProcessor,\n        # may have been modified by plugins during PromptPreProcessing)\n        kb_uuids = query.variables.get('_knowledge_base_uuids', [])\n\n        user_message = copy.deepcopy(query.user_message)\n\n        user_message_text = ''\n\n        if isinstance(user_message.content, str):\n            user_message_text = user_message.content\n        elif isinstance(user_message.content, list):\n            for ce in user_message.content:\n                if ce.type == 'text':\n                    user_message_text += ce.text\n                    break\n\n        if kb_uuids and user_message_text:\n            # only support text for now\n            all_results: list[rag_context.RetrievalResultEntry] = []\n\n            # Retrieve from each knowledge base\n            for kb_uuid in kb_uuids:\n                kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)\n\n                if not kb:\n                    self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')\n                    continue\n\n                result = await kb.retrieve(\n                    user_message_text,\n                    settings={\n                        'bot_uuid': query.bot_uuid or '',\n                        'sender_id': str(query.sender_id),\n                        'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}',\n                    },\n                )\n\n                if result:\n                    all_results.extend(result)\n\n            final_user_message_text = ''\n\n            if all_results:\n                texts = []\n                idx = 1\n                for entry in all_results:\n                    for content in entry.content:\n                        if content.type == 'text' and content.text is not None:\n                            texts.append(f'[{idx}] {content.text}')\n                            idx += 1\n                rag_context_text = '\\n\\n'.join(texts)\n                final_user_message_text = rag_combined_prompt_template.format(\n                    rag_context=rag_context_text, user_message=user_message_text\n                )\n\n            else:\n                final_user_message_text = user_message_text\n\n            self.ap.logger.debug(f'Final user message text: {final_user_message_text}')\n\n            for ce in user_message.content:\n                if ce.type == 'text':\n                    ce.text = final_user_message_text\n                    break\n\n        req_messages = query.prompt.messages.copy() + query.messages.copy() + [user_message]\n\n        try:\n            is_stream = await query.adapter.is_stream_output_supported()\n        except AttributeError:\n            is_stream = False\n\n        remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')\n\n        # Build ordered candidate list (primary + fallbacks)\n        candidates = await self._get_model_candidates(query)\n        if not candidates:\n            raise RuntimeError('No LLM model configured for local-agent runner')\n\n        self.ap.logger.debug(\n            f'localagent req: query={query.query_id} req_messages={req_messages} '\n            f'candidates={[m.model_entity.name for m in candidates]}'\n        )\n\n        if not is_stream:\n            # Non-streaming: invoke with fallback\n            msg, use_llm_model = await self._invoke_with_fallback(\n                query,\n                candidates,\n                req_messages,\n                query.use_funcs,\n                remove_think,\n            )\n            yield msg\n            final_msg = msg\n        else:\n            # Streaming: invoke with fallback\n            tool_calls_map: dict[str, provider_message.ToolCall] = {}\n            msg_idx = 0\n            accumulated_content = ''\n            last_role = 'assistant'\n            msg_sequence = 1\n\n            stream_src, use_llm_model = await self._invoke_stream_with_fallback(\n                query,\n                candidates,\n                req_messages,\n                query.use_funcs,\n                remove_think,\n            )\n            async for msg in stream_src:\n                msg_idx = msg_idx + 1\n\n                if msg.role:\n                    last_role = msg.role\n\n                if msg.content:\n                    accumulated_content += msg.content\n\n                if msg.tool_calls:\n                    for tool_call in msg.tool_calls:\n                        if tool_call.id not in tool_calls_map:\n                            tool_calls_map[tool_call.id] = provider_message.ToolCall(\n                                id=tool_call.id,\n                                type=tool_call.type,\n                                function=provider_message.FunctionCall(\n                                    name=tool_call.function.name if tool_call.function else '', arguments=''\n                                ),\n                            )\n                        if tool_call.function and tool_call.function.arguments:\n                            tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments\n\n                if msg_idx % 8 == 0 or msg.is_final:\n                    msg_sequence += 1\n                    yield provider_message.MessageChunk(\n                        role=last_role,\n                        content=accumulated_content,\n                        tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,\n                        is_final=msg.is_final,\n                        msg_sequence=msg_sequence,\n                    )\n\n            final_msg = provider_message.MessageChunk(\n                role=last_role,\n                content=accumulated_content,\n                tool_calls=list(tool_calls_map.values()) if tool_calls_map else None,\n                msg_sequence=msg_sequence,\n            )\n\n        pending_tool_calls = final_msg.tool_calls\n        first_content = final_msg.content\n        if isinstance(final_msg, provider_message.MessageChunk):\n            first_end_sequence = final_msg.msg_sequence\n\n        req_messages.append(final_msg)\n\n        # Once a model succeeds, commit to it for the tool call loop\n        # (no fallback mid-conversation — different models may interpret tool results differently)\n        while pending_tool_calls:\n            for tool_call in pending_tool_calls:\n                try:\n                    func = tool_call.function\n\n                    if func.arguments:\n                        parameters = json.loads(func.arguments)\n                    else:\n                        parameters = {}\n\n                    func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)\n\n                    # Handle return value content\n                    tool_content = None\n                    if (\n                        isinstance(func_ret, list)\n                        and len(func_ret) > 0\n                        and isinstance(func_ret[0], provider_message.ContentElement)\n                    ):\n                        tool_content = func_ret\n                    else:\n                        tool_content = json.dumps(func_ret, ensure_ascii=False)\n\n                    if is_stream:\n                        msg = provider_message.MessageChunk(\n                            role='tool',\n                            content=tool_content,\n                            tool_call_id=tool_call.id,\n                        )\n                    else:\n                        msg = provider_message.Message(\n                            role='tool',\n                            content=tool_content,\n                            tool_call_id=tool_call.id,\n                        )\n\n                    yield msg\n\n                    req_messages.append(msg)\n                except Exception as e:\n                    err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id)\n\n                    yield err_msg\n\n                    req_messages.append(err_msg)\n\n            self.ap.logger.debug(\n                f'localagent req: query={query.query_id} req_messages={req_messages} '\n                f'use_llm_model={use_llm_model.model_entity.name}'\n            )\n\n            if is_stream:\n                tool_calls_map = {}\n                msg_idx = 0\n                accumulated_content = ''\n                last_role = 'assistant'\n                msg_sequence = first_end_sequence\n\n                tool_stream_src = use_llm_model.provider.invoke_llm_stream(\n                    query,\n                    use_llm_model,\n                    req_messages,\n                    query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],\n                    extra_args=use_llm_model.model_entity.extra_args,\n                    remove_think=remove_think,\n                )\n                async for msg in tool_stream_src:\n                    msg_idx += 1\n\n                    if msg.role:\n                        last_role = msg.role\n\n                    # Prepend first-round content on first chunk of tool-call round\n                    if msg_idx == 1:\n                        accumulated_content = first_content if first_content is not None else accumulated_content\n\n                    if msg.content:\n                        accumulated_content += msg.content\n\n                    if msg.tool_calls:\n                        for tool_call in msg.tool_calls:\n                            if tool_call.id not in tool_calls_map:\n                                tool_calls_map[tool_call.id] = provider_message.ToolCall(\n                                    id=tool_call.id,\n                                    type=tool_call.type,\n                                    function=provider_message.FunctionCall(\n                                        name=tool_call.function.name if tool_call.function else '', arguments=''\n                                    ),\n                                )\n                            if tool_call.function and tool_call.function.arguments:\n                                tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments\n\n                    if msg_idx % 8 == 0 or msg.is_final:\n                        msg_sequence += 1\n                        yield provider_message.MessageChunk(\n                            role=last_role,\n                            content=accumulated_content,\n                            tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,\n                            is_final=msg.is_final,\n                            msg_sequence=msg_sequence,\n                        )\n\n                final_msg = provider_message.MessageChunk(\n                    role=last_role,\n                    content=accumulated_content,\n                    tool_calls=list(tool_calls_map.values()) if tool_calls_map else None,\n                    msg_sequence=msg_sequence,\n                )\n            else:\n                # Non-streaming: use committed model directly (no fallback in tool loop)\n                msg = await use_llm_model.provider.invoke_llm(\n                    query,\n                    use_llm_model,\n                    req_messages,\n                    query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],\n                    extra_args=use_llm_model.model_entity.extra_args,\n                    remove_think=remove_think,\n                )\n\n                yield msg\n                final_msg = msg\n\n            pending_tool_calls = final_msg.tool_calls\n\n            req_messages.append(final_msg)\n"
  },
  {
    "path": "src/langbot/pkg/provider/runners/n8nsvapi.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport json\nimport uuid\nimport aiohttp\n\nfrom langbot.pkg.utils import httpclient\n\nfrom .. import runner\nfrom ...core import app\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nclass N8nAPIError(Exception):\n    \"\"\"N8n API 请求失败\"\"\"\n\n    def __init__(self, message: str):\n        self.message = message\n        super().__init__(self.message)\n\n\n@runner.runner_class('n8n-service-api')\nclass N8nServiceAPIRunner(runner.RequestRunner):\n    \"\"\"N8n Service API 工作流请求器\"\"\"\n\n    def __init__(self, ap: app.Application, pipeline_config: dict):\n        self.ap = ap\n        self.pipeline_config = pipeline_config\n\n        # 获取webhook URL\n        self.webhook_url = self.pipeline_config['ai']['n8n-service-api']['webhook-url']\n\n        # 获取超时设置，默认为120秒\n        self.timeout = self.pipeline_config['ai']['n8n-service-api'].get('timeout', 120)\n\n        # 获取输出键名，默认为response\n        self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response')\n\n        # 获取认证类型，默认为none\n        self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')\n\n        # 根据认证类型获取相应的认证信息\n        if self.auth_type == 'basic':\n            self.basic_username = self.pipeline_config['ai']['n8n-service-api'].get('basic-username', '')\n            self.basic_password = self.pipeline_config['ai']['n8n-service-api'].get('basic-password', '')\n        elif self.auth_type == 'jwt':\n            self.jwt_secret = self.pipeline_config['ai']['n8n-service-api'].get('jwt-secret', '')\n            self.jwt_algorithm = self.pipeline_config['ai']['n8n-service-api'].get('jwt-algorithm', 'HS256')\n        elif self.auth_type == 'header':\n            self.header_name = self.pipeline_config['ai']['n8n-service-api'].get('header-name', '')\n            self.header_value = self.pipeline_config['ai']['n8n-service-api'].get('header-value', '')\n\n    async def _preprocess_user_message(self, query: pipeline_query.Query) -> str:\n        \"\"\"预处理用户消息，提取纯文本\n\n        Returns:\n            str: 纯文本消息\n        \"\"\"\n        plain_text = ''\n\n        if isinstance(query.user_message.content, list):\n            for ce in query.user_message.content:\n                if ce.type == 'text':\n                    plain_text += ce.text\n                # 注意：n8n webhook目前不支持直接处理图片，如需支持可在此扩展\n        elif isinstance(query.user_message.content, str):\n            plain_text = query.user_message.content\n\n        return plain_text\n\n    async def _process_stream_response(\n        self, response: aiohttp.ClientResponse\n    ) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"处理流式响应——支持部分 JSON 和多个 JSON 对象在同一 chunk 的情况\"\"\"\n        full_content = ''\n        chunk_idx = 0\n        is_final = False\n        message_idx = 0\n\n        buffer = ''\n        decoder = json.JSONDecoder()\n\n        async for raw_chunk in response.content.iter_chunked(1024):\n            if not raw_chunk:\n                continue\n\n            try:\n                # 将 bytes 解码为字符串（容忍错误）\n                if isinstance(raw_chunk, (bytes, bytearray)):\n                    chunk_str = raw_chunk.decode('utf-8', errors='replace')\n                else:\n                    chunk_str = str(raw_chunk)\n\n                buffer += chunk_str\n\n                # 尝试从 buffer 中循环解析出 JSON 对象（处理多个对象或部分对象）\n                while buffer:\n                    buffer = buffer.lstrip()\n                    if not buffer:\n                        break\n                    try:\n                        obj, idx = decoder.raw_decode(buffer)\n                        buffer = buffer[idx:]\n\n                        if not isinstance(obj, dict):\n                            # 忽略非字典类型的顶级 JSON\n                            continue\n\n                        if obj.get('type') == 'item' and 'content' in obj:\n                            chunk_idx += 1\n                            content = obj['content']\n                            full_content += content\n                        elif obj.get('type') == 'end':\n                            is_final = True\n\n                        if is_final or chunk_idx % 8 == 0:\n                            message_idx += 1\n                            yield provider_message.MessageChunk(\n                                role='assistant',\n                                content=full_content,\n                                is_final=is_final,\n                                msg_sequence=message_idx,\n                            )\n                    except json.JSONDecodeError:\n                        # buffer 末尾可能是一个不完整的 JSON，等待更多数据\n                        break\n            except Exception as e:\n                # 记录解析失败并继续接收后续 chunk\n                try:\n                    preview = chunk_str[:200]\n                except Exception:\n                    preview = '<unavailable>'\n                self.ap.logger.warning(f'Failed to process chunk: {e}; chunk preview: {preview}')\n\n        # 流结束后，尝试解析残余 buffer\n        if buffer:\n            try:\n                buffer = buffer.strip()\n                if buffer:\n                    obj, _ = decoder.raw_decode(buffer)\n                    if isinstance(obj, dict):\n                        if obj.get('type') == 'item' and 'content' in obj:\n                            full_content += obj['content']\n                        elif obj.get('type') == 'end':\n                            is_final = True\n                    message_idx += 1\n                    yield provider_message.MessageChunk(\n                        role='assistant',\n                        content=full_content,\n                        is_final=is_final,\n                        msg_sequence=message_idx,\n                    )\n            except Exception as e:\n                preview = buffer[:200]\n                self.ap.logger.warning(f'Failed to parse remaining buffer: {e}; buffer preview: {preview}')\n\n    async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"调用n8n webhook\"\"\"\n        # 生成会话ID（如果不存在）\n        if not query.session.using_conversation.uuid:\n            query.session.using_conversation.uuid = str(uuid.uuid4())\n\n        # 预处理用户消息\n        plain_text = await self._preprocess_user_message(query)\n\n        # 准备请求数据\n        payload = {\n            # 基本消息内容\n            'chatInput': plain_text,  # 考虑到之前用户直接用的message model这里添加新键\n            'message': plain_text,\n            'user_message_text': plain_text,\n            'conversation_id': query.session.using_conversation.uuid,\n            'session_id': query.variables.get('session_id', ''),\n            'user_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',\n            'msg_create_time': query.variables.get('msg_create_time', ''),\n        }\n\n        # 添加所有变量到payload\n        payload.update(query.variables)\n\n        try:\n            is_stream = await query.adapter.is_stream_output_supported()\n        except AttributeError:\n            is_stream = False\n\n        try:\n            # 准备请求头和认证信息\n            headers = {}\n            auth = None\n\n            # 根据认证类型设置相应的认证信息\n            if self.auth_type == 'basic':\n                # 使用Basic认证\n                auth = aiohttp.BasicAuth(self.basic_username, self.basic_password)\n                self.ap.logger.debug(f'using basic auth: {self.basic_username}')\n            elif self.auth_type == 'jwt':\n                # 使用JWT认证\n                import jwt\n                import time\n\n                # 创建JWT令牌\n                payload_jwt = {\n                    'exp': int(time.time()) + 3600,  # 1小时过期\n                    'iat': int(time.time()),\n                    'sub': 'n8n-webhook',\n                }\n                token = jwt.encode(payload_jwt, self.jwt_secret, algorithm=self.jwt_algorithm)\n\n                # 添加到Authorization头\n                headers['Authorization'] = f'Bearer {token}'\n                self.ap.logger.debug('using jwt auth')\n            elif self.auth_type == 'header':\n                # 使用自定义请求头认证\n                headers[self.header_name] = self.header_value\n                self.ap.logger.debug(f'using header auth: {self.header_name}')\n            else:\n                self.ap.logger.debug('no auth')\n\n            # 调用webhook\n            session = httpclient.get_session()\n            if is_stream:\n                # 流式请求\n                async with session.post(\n                    self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout\n                ) as response:\n                    if response.status != 200:\n                        error_text = await response.text()\n                        self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')\n                        raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')\n\n                    # 处理流式响应\n                    async for chunk in self._process_stream_response(response):\n                        yield chunk\n            else:\n                async with session.post(\n                    self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout\n                ) as response:\n                    try:\n                        async for chunk in self._process_stream_response(response):\n                            output_content = chunk.content if chunk.is_final else ''\n                    except:\n                        # 非流式请求（保持原有逻辑）\n                        if response.status != 200:\n                            error_text = await response.text()\n                            self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')\n                            raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')\n\n                        # 解析响应\n                        response_data = await response.json()\n                        self.ap.logger.debug(f'n8n webhook response: {response_data}')\n\n                        # 从响应中提取输出\n                        if self.output_key in response_data:\n                            output_content = response_data[self.output_key]\n                        else:\n                            # 如果没有指定的输出键，则使用整个响应\n                            output_content = json.dumps(response_data, ensure_ascii=False)\n\n                    # 返回消息\n                    yield provider_message.Message(\n                        role='assistant',\n                        content=output_content,\n                    )\n        except Exception as e:\n            self.ap.logger.error(f'n8n webhook call exception: {str(e)}')\n            raise N8nAPIError(f'n8n webhook call exception: {str(e)}')\n\n    async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"运行请求\"\"\"\n        async for msg in self._call_webhook(query):\n            yield msg\n"
  },
  {
    "path": "src/langbot/pkg/provider/runners/tboxapi.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport json\nimport base64\nimport tempfile\nimport os\n\nfrom tboxsdk.tbox import TboxClient\nfrom tboxsdk.model.file import File, FileType\n\nfrom .. import runner\nfrom ...core import app\nfrom ...utils import image\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\n\n\nclass TboxAPIError(Exception):\n    \"\"\"TBox API 请求失败\"\"\"\n\n    def __init__(self, message: str):\n        self.message = message\n        super().__init__(self.message)\n\n\n@runner.runner_class('tbox-app-api')\nclass TboxAPIRunner(runner.RequestRunner):\n    \"蚂蚁百宝箱API对话请求器\"\n\n    # 运行器内部使用的配置\n    app_id: str  # 蚂蚁百宝箱平台中的应用ID\n    api_key: str  # 在蚂蚁百宝箱平台中申请的令牌\n\n    def __init__(self, ap: app.Application, pipeline_config: dict):\n        \"\"\"初始化\"\"\"\n        self.ap = ap\n        self.pipeline_config = pipeline_config\n\n        # 初始化Tbox 参数配置\n        self.app_id = self.pipeline_config['ai']['tbox-app-api']['app-id']\n        self.api_key = self.pipeline_config['ai']['tbox-app-api']['api-key']\n\n        # 初始化Tbox client\n        self.tbox_client = TboxClient(authorization=self.api_key)\n\n    async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]:\n        \"\"\"预处理用户消息，提取纯文本，并将图片上传到 Tbox 服务\n\n        Returns:\n            tuple[str, list[str]]: 纯文本和图片的 Tbox 文件ID\n        \"\"\"\n        plain_text = ''\n        image_ids = []\n\n        if isinstance(query.user_message.content, list):\n            for ce in query.user_message.content:\n                if ce.type == 'text':\n                    plain_text += ce.text\n                elif ce.type == 'image_base64':\n                    image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)\n                    # 创建临时文件\n                    file_bytes = base64.b64decode(image_b64)\n                    try:\n                        with tempfile.NamedTemporaryFile(suffix=f'.{image_format}', delete=False) as tmp_file:\n                            tmp_file.write(file_bytes)\n                            tmp_file_path = tmp_file.name\n                        file_upload_resp = self.tbox_client.upload_file(tmp_file_path)\n                        image_id = file_upload_resp.get('data', '')\n                        image_ids.append(image_id)\n                    finally:\n                        # 清理临时文件\n                        if os.path.exists(tmp_file_path):\n                            os.unlink(tmp_file_path)\n        elif isinstance(query.user_message.content, str):\n            plain_text = query.user_message.content\n\n        return plain_text, image_ids\n\n    async def _agent_messages(\n        self, query: pipeline_query.Query\n    ) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"TBox 智能体对话请求\"\"\"\n\n        plain_text, image_ids = await self._preprocess_user_message(query)\n        remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think')\n\n        try:\n            is_stream = await query.adapter.is_stream_output_supported()\n        except AttributeError:\n            is_stream = False\n\n        # 获取Tbox的conversation_id\n        conversation_id = query.session.using_conversation.uuid or None\n\n        files = None\n        if image_ids:\n            files = [File(file_id=image_id, type=FileType.IMAGE) for image_id in image_ids]\n\n        # 发送对话请求\n        response = self.tbox_client.chat(\n            app_id=self.app_id,  # Tbox中智能体应用的ID\n            user_id=query.bot_uuid,  # 用户ID\n            query=plain_text,  # 用户输入的文本信息\n            stream=is_stream,  # 是否流式输出\n            conversation_id=conversation_id,  # 会话ID，为None时Tbox会自动创建一个新会话\n            files=files,  # 图片内容\n        )\n\n        if is_stream:\n            # 解析Tbox流式输出内容，并发送给上游\n            for chunk in self._process_stream_message(response, query, remove_think):\n                yield chunk\n        else:\n            message = self._process_non_stream_message(response, query, remove_think)\n            yield provider_message.Message(\n                role='assistant',\n                content=message,\n            )\n\n    def _process_non_stream_message(self, response: typing.Dict, query: pipeline_query.Query, remove_think: bool):\n        if response.get('errorCode') != '0':\n            raise TboxAPIError(f'Tbox API 请求失败: {response.get(\"errorMsg\", \"\")}')\n        payload = response.get('data', {})\n        conversation_id = payload.get('conversationId', '')\n        query.session.using_conversation.uuid = conversation_id\n        thinking_content = payload.get('reasoningContent', [])\n        result = ''\n        if thinking_content and not remove_think:\n            result += f'<think>\\n{thinking_content[0].get(\"text\", \"\")}\\n</think>\\n'\n        content = payload.get('result', [])\n        if content:\n            result += content[0].get('chunk', '')\n        return result\n\n    def _process_stream_message(\n        self, response: typing.Generator[dict], query: pipeline_query.Query, remove_think: bool\n    ):\n        idx_msg = 0\n        pending_content = ''\n        conversation_id = None\n        think_start = False\n        think_end = False\n        for chunk in response:\n            if chunk.get('type', '') == 'chunk':\n                \"\"\"\n                Tbox返回的消息内容chunk结构\n                {'lane': 'default', 'payload': {'conversationId': '20250918tBI947065406', 'messageId': '20250918TB1f53230954', 'text': '️'}, 'type': 'chunk'}\n                \"\"\"\n                # 如果包含思考过程，拼接</think>\n                if think_start and not think_end:\n                    pending_content += '\\n</think>\\n'\n                    think_end = True\n\n                payload = chunk.get('payload', {})\n                if not conversation_id:\n                    conversation_id = payload.get('conversationId')\n                    query.session.using_conversation.uuid = conversation_id\n                if payload.get('text'):\n                    idx_msg += 1\n                    pending_content += payload.get('text')\n            elif chunk.get('type', '') == 'thinking' and not remove_think:\n                \"\"\"\n                Tbox返回的思考过程chunk结构\n                {'payload': '{\"ext_data\":{\"text\":\"日期\"},\"event\":\"flow.node.llm.thinking\",\"entity\":{\"node_type\":\"text-completion\",\"execute_id\":\"6\",\"group_id\":0,\"parent_execute_id\":\"6\",\"node_name\":\"模型推理\",\"node_id\":\"TC_5u6gl0\"}}', 'type': 'thinking'}\n                \"\"\"\n                payload = json.loads(chunk.get('payload', '{}'))\n                if payload.get('ext_data', {}).get('text'):\n                    idx_msg += 1\n                    content = payload.get('ext_data', {}).get('text')\n                    if not think_start:\n                        think_start = True\n                        pending_content += f'<think>\\n{content}'\n                    else:\n                        pending_content += content\n            elif chunk.get('type', '') == 'error':\n                raise TboxAPIError(\n                    f'Tbox API 请求失败: status_code={chunk.get(\"status_code\")} message={chunk.get(\"message\")} request_id={chunk.get(\"request_id\")} '\n                )\n\n            if idx_msg % 8 == 0:\n                yield provider_message.MessageChunk(\n                    role='assistant',\n                    content=pending_content,\n                    is_final=False,\n                )\n\n        # Tbox不返回END事件，默认发一个最终消息\n        yield provider_message.MessageChunk(\n            role='assistant',\n            content=pending_content,\n            is_final=True,\n        )\n\n    async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:\n        \"\"\"运行\"\"\"\n        msg_seq = 0\n        async for msg in self._agent_messages(query):\n            if isinstance(msg, provider_message.MessageChunk):\n                msg_seq += 1\n                msg.msg_sequence = msg_seq\n            yield msg\n"
  },
  {
    "path": "src/langbot/pkg/provider/session/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/provider/session/sessionmgr.py",
    "content": "from __future__ import annotations\n\nimport asyncio\n\nfrom ...core import app\nfrom langbot_plugin.api.entities.builtin.provider import message as provider_message, prompt as provider_prompt\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n\nclass SessionManager:\n    \"\"\"会话管理器\"\"\"\n\n    ap: app.Application\n\n    session_list: list[provider_session.Session]\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        self.session_list = []\n\n    async def initialize(self):\n        pass\n\n    async def get_session(self, query: pipeline_query.Query) -> provider_session.Session:\n        \"\"\"获取会话\"\"\"\n        for session in self.session_list:\n            if query.launcher_type == session.launcher_type and query.launcher_id == session.launcher_id:\n                return session\n\n        session_concurrency = self.ap.instance_config.data['concurrency']['session']\n\n        session = provider_session.Session(\n            launcher_type=query.launcher_type,\n            launcher_id=query.launcher_id,\n            sender_id=query.sender_id,\n        )\n        session._semaphore = asyncio.Semaphore(session_concurrency)\n        self.session_list.append(session)\n        return session\n\n    async def get_conversation(\n        self,\n        query: pipeline_query.Query,\n        session: provider_session.Session,\n        prompt_config: list[dict],\n        pipeline_uuid: str,\n        bot_uuid: str,\n    ) -> provider_session.Conversation:\n        \"\"\"获取对话或创建对话\"\"\"\n\n        if not session.conversations:\n            session.conversations = []\n\n        # set prompt\n        prompt_messages = []\n\n        for prompt_message in prompt_config:\n            prompt_messages.append(provider_message.Message(**prompt_message))\n\n        prompt = provider_prompt.Prompt(\n            name='default',\n            messages=prompt_messages,\n        )\n\n        if session.using_conversation is None or session.using_conversation.pipeline_uuid != pipeline_uuid:\n            conversation = provider_session.Conversation(\n                prompt=prompt,\n                messages=[],\n                pipeline_uuid=pipeline_uuid,\n                bot_uuid=bot_uuid,\n            )\n            session.conversations.append(conversation)\n            session.using_conversation = conversation\n\n        return session.using_conversation\n"
  },
  {
    "path": "src/langbot/pkg/provider/tools/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/provider/tools/loader.py",
    "content": "from __future__ import annotations\n\nimport abc\nimport typing\n\nfrom langbot_plugin.api.entities.events import pipeline_query\n\nfrom ...core import app\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\n\n\npreregistered_loaders: list[typing.Type[ToolLoader]] = []\n\n\ndef loader_class(name: str):\n    \"\"\"注册一个工具加载器\"\"\"\n\n    def decorator(cls: typing.Type[ToolLoader]) -> typing.Type[ToolLoader]:\n        cls.name = name\n        preregistered_loaders.append(cls)\n        return cls\n\n    return decorator\n\n\nclass ToolLoader(abc.ABC):\n    \"\"\"工具加载器\"\"\"\n\n    name: str = None\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        pass\n\n    @abc.abstractmethod\n    async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:\n        \"\"\"获取所有工具\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def has_tool(self, name: str) -> bool:\n        \"\"\"检查工具是否存在\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:\n        \"\"\"执行工具调用\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def shutdown(self):\n        \"\"\"关闭工具\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/provider/tools/loaders/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/provider/tools/loaders/mcp.py",
    "content": "from __future__ import annotations\n\nimport enum\nimport typing\nfrom contextlib import AsyncExitStack\nimport traceback\nfrom langbot_plugin.api.entities.events import pipeline_query\nimport sqlalchemy\nimport asyncio\nimport httpx\n\nimport uuid as uuid_module\nfrom mcp import ClientSession, StdioServerParameters\nfrom mcp.client.stdio import stdio_client\nfrom mcp.client.sse import sse_client\nfrom mcp.client.streamable_http import streamable_http_client\n\nfrom .. import loader\nfrom ....core import app\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\nimport langbot_plugin.api.entities.builtin.provider.message as provider_message\nfrom ....entity.persistence import mcp as persistence_mcp\n\n\nclass MCPSessionStatus(enum.Enum):\n    CONNECTING = 'connecting'\n    CONNECTED = 'connected'\n    ERROR = 'error'\n\n\nclass RuntimeMCPSession:\n    \"\"\"运行时 MCP 会话\"\"\"\n\n    ap: app.Application\n\n    server_name: str\n\n    server_uuid: str\n\n    server_config: dict\n\n    session: ClientSession | None\n\n    exit_stack: AsyncExitStack\n\n    functions: list[resource_tool.LLMTool] = []\n\n    enable: bool\n\n    # connected: bool\n    status: MCPSessionStatus\n\n    _lifecycle_task: asyncio.Task | None\n\n    _shutdown_event: asyncio.Event\n\n    _ready_event: asyncio.Event\n\n    error_message: str | None = None\n\n    def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):\n        self.server_name = server_name\n        self.server_uuid = server_config.get('uuid', '')\n        self.server_config = server_config\n        self.ap = ap\n        self.enable = enable\n        self.session = None\n\n        self.exit_stack = AsyncExitStack()\n        self.functions = []\n\n        self.status = MCPSessionStatus.CONNECTING\n\n        self._lifecycle_task = None\n        self._shutdown_event = asyncio.Event()\n        self._ready_event = asyncio.Event()\n\n    async def _init_stdio_python_server(self):\n        server_params = StdioServerParameters(\n            command=self.server_config['command'],\n            args=self.server_config['args'],\n            env=self.server_config['env'],\n        )\n\n        stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))\n\n        stdio, write = stdio_transport\n\n        self.session = await self.exit_stack.enter_async_context(ClientSession(stdio, write))\n\n        await self.session.initialize()\n\n    async def _init_sse_server(self):\n        sse_transport = await self.exit_stack.enter_async_context(\n            sse_client(\n                self.server_config['url'],\n                headers=self.server_config.get('headers', {}),\n                timeout=self.server_config.get('timeout', 10),\n                sse_read_timeout=self.server_config.get('ssereadtimeout', 30),\n            )\n        )\n\n        sseio, write = sse_transport\n\n        self.session = await self.exit_stack.enter_async_context(ClientSession(sseio, write))\n\n        await self.session.initialize()\n\n    async def _init_streamable_http_server(self):\n        transport = await self.exit_stack.enter_async_context(\n            streamable_http_client(\n                self.server_config['url'],\n                http_client=httpx.AsyncClient(\n                    headers=self.server_config.get('headers', {}),\n                    timeout=self.server_config.get('timeout', 10),\n                    follow_redirects=True,\n                ),\n            )\n        )\n\n        read, write, _ = transport\n\n        self.session = await self.exit_stack.enter_async_context(ClientSession(read, write))\n\n        await self.session.initialize()\n\n    async def _lifecycle_loop(self):\n        \"\"\"在后台任务中管理整个MCP会话的生命周期\"\"\"\n        try:\n            if self.server_config['mode'] == 'stdio':\n                await self._init_stdio_python_server()\n            elif self.server_config['mode'] == 'sse':\n                await self._init_sse_server()\n            elif self.server_config['mode'] == 'http':\n                await self._init_streamable_http_server()\n            else:\n                raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')\n\n            await self.refresh()\n\n            self.status = MCPSessionStatus.CONNECTED\n\n            # 通知start()方法连接已建立\n            self._ready_event.set()\n\n            # 等待shutdown信号\n            await self._shutdown_event.wait()\n\n        except Exception as e:\n            self.status = MCPSessionStatus.ERROR\n            self.error_message = str(e)\n            self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\\n{traceback.format_exc()}')\n            # 即使出错也要设置ready事件，让start()方法知道初始化已完成\n            self._ready_event.set()\n        finally:\n            # 在同一个任务中清理所有资源\n            try:\n                if self.exit_stack:\n                    await self.exit_stack.aclose()\n                self.functions.clear()\n                self.session = None\n            except Exception as e:\n                self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\\n{traceback.format_exc()}')\n\n    async def start(self):\n        if not self.enable:\n            return\n\n        # 创建后台任务来管理生命周期\n        self._lifecycle_task = asyncio.create_task(self._lifecycle_loop())\n\n        # 等待连接建立或失败（带超时）\n        try:\n            await asyncio.wait_for(self._ready_event.wait(), timeout=30.0)\n        except asyncio.TimeoutError:\n            self.status = MCPSessionStatus.ERROR\n            raise Exception('Connection timeout after 30 seconds')\n\n        # 检查是否有错误\n        if self.status == MCPSessionStatus.ERROR:\n            raise Exception('Connection failed, please check URL')\n\n    async def refresh(self):\n        if not self.session:\n            return\n\n        self.functions.clear()\n\n        tools = await self.session.list_tools()\n\n        self.ap.logger.debug(f'Refresh MCP tools: {tools}')\n\n        for tool in tools.tools:\n\n            async def func(*, _tool=tool, **kwargs):\n                if not self.session:\n                    raise Exception('MCP session is not connected')\n\n                result = await self.session.call_tool(_tool.name, kwargs)\n                if result.isError:\n                    error_texts = []\n                    for content in result.content:\n                        if content.type == 'text':\n                            error_texts.append(content.text)\n                    raise Exception('\\n'.join(error_texts) if error_texts else 'Unknown error from MCP tool')\n\n                result_contents: list[provider_message.ContentElement] = []\n                for content in result.content:\n                    if content.type == 'text':\n                        result_contents.append(provider_message.ContentElement.from_text(content.text))\n                    elif content.type == 'image':\n                        result_contents.append(provider_message.ContentElement.from_image_base64(content.image_base64))\n                    elif content.type == 'resource':\n                        # TODO: Handle resource content\n                        pass\n\n                return result_contents\n\n            func.__name__ = tool.name\n\n            self.functions.append(\n                resource_tool.LLMTool(\n                    name=tool.name,\n                    human_desc=tool.description or '',\n                    description=tool.description or '',\n                    parameters=tool.inputSchema,\n                    func=func,\n                )\n            )\n\n    def get_tools(self) -> list[resource_tool.LLMTool]:\n        return self.functions\n\n    def get_runtime_info_dict(self) -> dict:\n        return {\n            'status': self.status.value,\n            'error_message': self.error_message,\n            'tool_count': len(self.get_tools()),\n            'tools': [\n                {\n                    'name': tool.name,\n                    'description': tool.description,\n                }\n                for tool in self.get_tools()\n            ],\n        }\n\n    async def shutdown(self):\n        \"\"\"关闭会话并清理资源\"\"\"\n        try:\n            # 设置shutdown事件，通知lifecycle任务退出\n            self._shutdown_event.set()\n\n            # 等待lifecycle任务完成（带超时）\n            if self._lifecycle_task and not self._lifecycle_task.done():\n                try:\n                    await asyncio.wait_for(self._lifecycle_task, timeout=5.0)\n                except asyncio.TimeoutError:\n                    self.ap.logger.warning(f'MCP session {self.server_name} shutdown timeout, cancelling task')\n                    self._lifecycle_task.cancel()\n                    try:\n                        await self._lifecycle_task\n                    except asyncio.CancelledError:\n                        pass\n\n            self.ap.logger.info(f'MCP session {self.server_name} shutdown complete')\n        except Exception as e:\n            self.ap.logger.error(f'Error shutting down MCP session {self.server_name}: {e}\\n{traceback.format_exc()}')\n\n\n# @loader.loader_class('mcp')\nclass MCPLoader(loader.ToolLoader):\n    \"\"\"MCP 工具加载器。\n\n    在此加载器中管理所有与 MCP Server 的连接。\n    \"\"\"\n\n    sessions: dict[str, RuntimeMCPSession]\n\n    _last_listed_functions: list[resource_tool.LLMTool]\n\n    _hosted_mcp_tasks: list[asyncio.Task]\n\n    def __init__(self, ap: app.Application):\n        super().__init__(ap)\n        self.sessions = {}\n        self._last_listed_functions = []\n        self._hosted_mcp_tasks = []\n\n    async def initialize(self):\n        await self.load_mcp_servers_from_db()\n\n    async def load_mcp_servers_from_db(self):\n        self.ap.logger.info('Loading MCP servers from db...')\n\n        self.sessions = {}\n\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))\n        servers = result.all()\n\n        for server in servers:\n            config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)\n\n            task = asyncio.create_task(self.host_mcp_server(config))\n            self._hosted_mcp_tasks.append(task)\n\n    async def host_mcp_server(self, server_config: dict):\n        self.ap.logger.debug(f'Loading MCP server {server_config}')\n        try:\n            session = await self.load_mcp_server(server_config)\n            self.sessions[server_config['name']] = session\n        except Exception as e:\n            self.ap.logger.error(\n                f'Failed to load MCP server from db: {server_config[\"name\"]}({server_config[\"uuid\"]}): {e}\\n{traceback.format_exc()}'\n            )\n            return\n\n        self.ap.logger.debug(f'Starting MCP server {server_config[\"name\"]}({server_config[\"uuid\"]})')\n        try:\n            await session.start()\n        except Exception as e:\n            self.ap.logger.error(\n                f'Failed to start MCP server {server_config[\"name\"]}({server_config[\"uuid\"]}): {e}\\n{traceback.format_exc()}'\n            )\n            return\n\n        self.ap.logger.debug(f'Started MCP server {server_config[\"name\"]}({server_config[\"uuid\"]})')\n\n    async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession:\n        \"\"\"加载 MCP 服务器到运行时\n\n        Args:\n            server_config: 服务器配置字典，必须包含:\n                - name: 服务器名称\n                - mode: 连接模式 (stdio/sse)\n                - enable: 是否启用\n                - extra_args: 额外的配置参数 (可选)\n        \"\"\"\n        uuid_ = server_config.get('uuid')\n        if not uuid_:\n            self.ap.logger.warning('Server UUID is None for MCP server, maybe testing in the config page.')\n            uuid_ = str(uuid_module.uuid4())\n            server_config['uuid'] = uuid_\n\n        name = server_config['name']\n        uuid = server_config['uuid']\n        mode = server_config['mode']\n        enable = server_config['enable']\n        extra_args = server_config.get('extra_args', {})\n\n        mixed_config = {\n            'name': name,\n            'uuid': uuid,\n            'mode': mode,\n            'enable': enable,\n            **extra_args,\n        }\n\n        session = RuntimeMCPSession(name, mixed_config, enable, self.ap)\n\n        return session\n\n    async def get_tools(self, bound_mcp_servers: list[str] | None = None) -> list[resource_tool.LLMTool]:\n        all_functions = []\n\n        for session in self.sessions.values():\n            # If bound_mcp_servers is specified, only include tools from those servers\n            if bound_mcp_servers is not None:\n                if session.server_uuid in bound_mcp_servers:\n                    all_functions.extend(session.get_tools())\n            else:\n                # If no bound servers specified, include all tools\n                all_functions.extend(session.get_tools())\n\n        self._last_listed_functions = all_functions\n\n        return all_functions\n\n    async def has_tool(self, name: str) -> bool:\n        \"\"\"检查工具是否存在\"\"\"\n        for session in self.sessions.values():\n            for function in session.get_tools():\n                if function.name == name:\n                    return True\n        return False\n\n    async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:\n        \"\"\"执行工具调用\"\"\"\n        for session in self.sessions.values():\n            for function in session.get_tools():\n                if function.name == name:\n                    self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}')\n                    try:\n                        result = await function.func(**parameters)\n                        self.ap.logger.debug(f'MCP tool {name} executed successfully')\n                        return result\n                    except Exception as e:\n                        self.ap.logger.error(f'Error invoking MCP tool {name}: {e}\\n{traceback.format_exc()}')\n                        raise\n\n        raise ValueError(f'Tool not found: {name}')\n\n    async def remove_mcp_server(self, server_name: str):\n        \"\"\"移除 MCP 服务器\"\"\"\n        if server_name not in self.sessions:\n            self.ap.logger.warning(f'MCP server {server_name} not found in sessions, skipping removal')\n            return\n\n        session = self.sessions.pop(server_name)\n        await session.shutdown()\n        self.ap.logger.info(f'Removed MCP server: {server_name}')\n\n    def get_session(self, server_name: str) -> RuntimeMCPSession | None:\n        \"\"\"获取指定名称的 MCP 会话\"\"\"\n        return self.sessions.get(server_name)\n\n    def has_session(self, server_name: str) -> bool:\n        \"\"\"检查是否存在指定名称的 MCP 会话\"\"\"\n        return server_name in self.sessions\n\n    def get_all_server_names(self) -> list[str]:\n        \"\"\"获取所有已加载的 MCP 服务器名称\"\"\"\n        return list(self.sessions.keys())\n\n    def get_server_tool_count(self, server_name: str) -> int:\n        \"\"\"获取指定服务器的工具数量\"\"\"\n        session = self.get_session(server_name)\n        return len(session.get_tools()) if session else 0\n\n    def get_all_servers_info(self) -> dict[str, dict]:\n        \"\"\"获取所有服务器的信息\"\"\"\n        info = {}\n        for server_name, session in self.sessions.items():\n            info[server_name] = {\n                'name': server_name,\n                'mode': session.server_config.get('mode'),\n                'enable': session.enable,\n                'tools_count': len(session.get_tools()),\n                'tool_names': [f.name for f in session.get_tools()],\n            }\n        return info\n\n    async def shutdown(self):\n        \"\"\"关闭所有工具\"\"\"\n        self.ap.logger.info('Shutting down all MCP sessions...')\n        for server_name, session in list(self.sessions.items()):\n            try:\n                await session.shutdown()\n                self.ap.logger.debug(f'Shutdown MCP session: {server_name}')\n            except Exception as e:\n                self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\\n{traceback.format_exc()}')\n        self.sessions.clear()\n        self.ap.logger.info('All MCP sessions shutdown complete')\n"
  },
  {
    "path": "src/langbot/pkg/provider/tools/loaders/plugin.py",
    "content": "from __future__ import annotations\n\nimport typing\nimport traceback\n\nfrom langbot_plugin.api.entities.events import pipeline_query\n\nfrom .. import loader\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\n\n\n# @loader.loader_class('plugin-tool-loader')\nclass PluginToolLoader(loader.ToolLoader):\n    \"\"\"插件工具加载器。\n\n    本加载器中不存储工具信息，仅负责从插件系统中获取工具信息。\n    \"\"\"\n\n    async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:\n        # 从插件系统获取工具（内容函数）\n        all_functions: list[resource_tool.LLMTool] = []\n\n        for tool in await self.ap.plugin_connector.list_tools(bound_plugins):\n            tool_obj = resource_tool.LLMTool(\n                name=tool.metadata.name,\n                human_desc=tool.metadata.description.en_US,\n                description=tool.spec['llm_prompt'],\n                parameters=tool.spec['parameters'],\n                func=lambda parameters: {},\n            )\n            all_functions.append(tool_obj)\n\n        return all_functions\n\n    async def has_tool(self, name: str) -> bool:\n        \"\"\"检查工具是否存在\"\"\"\n        for tool in await self.ap.plugin_connector.list_tools():\n            if tool.metadata.name == name:\n                return True\n        return False\n\n    async def _get_tool(self, name: str) -> resource_tool.LLMTool:\n        for tool in await self.ap.plugin_connector.list_tools():\n            if tool.metadata.name == name:\n                return tool\n        return None\n\n    async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:\n        try:\n            return await self.ap.plugin_connector.call_tool(\n                name, parameters, session=query.session, query_id=query.query_id\n            )\n        except Exception as e:\n            self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')\n            traceback.print_exc()\n            return f'error occurred when executing function {name}: {e}'\n\n    async def shutdown(self):\n        \"\"\"关闭工具\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/provider/tools/toolmgr.py",
    "content": "from __future__ import annotations\n\nimport typing\n\nfrom ...core import app\nfrom langbot.pkg.utils import importutil\nfrom langbot.pkg.provider.tools import loaders\nfrom langbot.pkg.provider.tools.loaders import mcp as mcp_loader, plugin as plugin_loader\nimport langbot_plugin.api.entities.builtin.resource.tool as resource_tool\nfrom langbot_plugin.api.entities.events import pipeline_query\n\nimportutil.import_modules_in_pkg(loaders)\n\n\nclass ToolManager:\n    \"\"\"LLM工具管理器\"\"\"\n\n    ap: app.Application\n\n    plugin_tool_loader: plugin_loader.PluginToolLoader\n    mcp_tool_loader: mcp_loader.MCPLoader\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap)\n        await self.plugin_tool_loader.initialize()\n        self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)\n        await self.mcp_tool_loader.initialize()\n\n    async def get_all_tools(\n        self, bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None\n    ) -> list[resource_tool.LLMTool]:\n        \"\"\"获取所有函数\"\"\"\n        all_functions: list[resource_tool.LLMTool] = []\n\n        all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))\n        all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers))\n\n        return all_functions\n\n    async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:\n        \"\"\"生成函数列表\"\"\"\n        tools = []\n\n        for function in use_funcs:\n            function_schema = {\n                'type': 'function',\n                'function': {\n                    'name': function.name,\n                    'description': function.description,\n                    'parameters': function.parameters,\n                },\n            }\n            tools.append(function_schema)\n\n        return tools\n\n    async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list:\n        \"\"\"为anthropic生成函数列表\n\n        e.g.\n\n        [\n          {\n            \"name\": \"get_stock_price\",\n            \"description\": \"Get the current stock price for a given ticker symbol.\",\n            \"input_schema\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"ticker\": {\n                  \"type\": \"string\",\n                  \"description\": \"The stock ticker symbol, e.g. AAPL for Apple Inc.\"\n                }\n              },\n              \"required\": [\"ticker\"]\n            }\n          }\n        ]\n        \"\"\"\n\n        tools = []\n\n        for function in use_funcs:\n            function_schema = {\n                'name': function.name,\n                'description': function.description,\n                'input_schema': function.parameters,\n            }\n            tools.append(function_schema)\n\n        return tools\n\n    async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:\n        \"\"\"执行函数调用\"\"\"\n\n        if await self.plugin_tool_loader.has_tool(name):\n            return await self.plugin_tool_loader.invoke_tool(name, parameters, query)\n        elif await self.mcp_tool_loader.has_tool(name):\n            return await self.mcp_tool_loader.invoke_tool(name, parameters, query)\n        else:\n            raise ValueError(f'未找到工具: {name}')\n\n    async def shutdown(self):\n        \"\"\"关闭所有工具\"\"\"\n        await self.plugin_tool_loader.shutdown()\n        await self.mcp_tool_loader.shutdown()\n"
  },
  {
    "path": "src/langbot/pkg/rag/knowledge/base.py",
    "content": "\"\"\"Base classes and interfaces for knowledge bases\"\"\"\n\nfrom __future__ import annotations\n\nimport abc\n\nfrom langbot.pkg.core import app\nfrom langbot_plugin.api.entities.builtin.rag import context as rag_context\n\n\nclass KnowledgeBaseInterface(metaclass=abc.ABCMeta):\n    \"\"\"Abstract interface for all knowledge base types\"\"\"\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    @abc.abstractmethod\n    async def initialize(self):\n        \"\"\"Initialize the knowledge base\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]:\n        \"\"\"Retrieve relevant documents from the knowledge base\n\n        Args:\n            query: The query string\n            settings: Optional per-request retrieval settings overrides\n\n        Returns:\n            List of retrieve result entries\n        \"\"\"\n        pass\n\n    @abc.abstractmethod\n    def get_uuid(self) -> str:\n        \"\"\"Get the UUID of the knowledge base\"\"\"\n        pass\n\n    @abc.abstractmethod\n    def get_name(self) -> str:\n        \"\"\"Get the name of the knowledge base\"\"\"\n        pass\n\n    @abc.abstractmethod\n    def get_knowledge_engine_plugin_id(self) -> str:\n        \"\"\"Get the Knowledge Engine plugin ID\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def dispose(self):\n        \"\"\"Clean up resources\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/rag/knowledge/kbmgr.py",
    "content": "from __future__ import annotations\nimport mimetypes\nimport os.path\nimport traceback\nimport uuid\nimport zipfile\nimport io\nfrom typing import Any\nfrom langbot.pkg.core import app\nimport sqlalchemy\n\n\nfrom langbot.pkg.entity.persistence import rag as persistence_rag\nfrom langbot.pkg.core import taskmgr\nfrom langbot_plugin.api.entities.builtin.rag import context as rag_context\nfrom .base import KnowledgeBaseInterface\n\n\nclass RuntimeKnowledgeBase(KnowledgeBaseInterface):\n    ap: app.Application\n\n    knowledge_base_entity: persistence_rag.KnowledgeBase\n\n    def __init__(self, ap: app.Application, knowledge_base_entity: persistence_rag.KnowledgeBase):\n        super().__init__(ap)\n        self.knowledge_base_entity = knowledge_base_entity\n\n    async def initialize(self):\n        pass\n\n    async def _store_file_task(\n        self, file: persistence_rag.File, task_context: taskmgr.TaskContext, parser_plugin_id: str | None = None\n    ):\n        try:\n            # set file status to processing\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.update(persistence_rag.File)\n                .where(persistence_rag.File.uuid == file.uuid)\n                .values(status='processing')\n            )\n\n            task_context.set_current_action('Processing file')\n\n            # Get file size from storage\n            file_size = await self.ap.storage_mgr.storage_provider.size(file.file_name)\n\n            # Detect MIME type from extension\n            mime_type, _ = mimetypes.guess_type(file.file_name)\n            if mime_type is None:\n                mime_type = 'application/octet-stream'\n\n            # If a parser plugin is specified, call it before ingestion\n            parsed_content = None\n            if parser_plugin_id:\n                task_context.set_current_action('Parsing file')\n                file_bytes = await self.ap.storage_mgr.storage_provider.load(file.file_name)\n                parse_context = {\n                    'mime_type': mime_type,\n                    'filename': file.file_name,\n                    'metadata': {},\n                }\n                parsed_content = await self.ap.plugin_connector.call_parser(parser_plugin_id, parse_context, file_bytes)\n\n            # Call plugin to ingest document\n            result = await self._ingest_document(\n                {\n                    'document_id': file.uuid,\n                    'filename': file.file_name,\n                    'extension': file.extension,\n                    'file_size': file_size,\n                    'mime_type': mime_type,\n                },\n                file.file_name,  # storage path\n                parsed_content=parsed_content,\n            )\n\n            # Check plugin result status\n            if result.get('status') == 'failed':\n                error_msg = result.get('error_message', 'Plugin ingestion returned failed status')\n                raise Exception(error_msg)\n\n            # set file status to completed\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.update(persistence_rag.File)\n                .where(persistence_rag.File.uuid == file.uuid)\n                .values(status='completed')\n            )\n\n        except Exception as e:\n            self.ap.logger.error(f'Error storing file {file.uuid}: {e}')\n            traceback.print_exc()\n            # set file status to failed\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.update(persistence_rag.File)\n                .where(persistence_rag.File.uuid == file.uuid)\n                .values(status='failed')\n            )\n\n            raise\n        finally:\n            # delete file from storage\n            await self.ap.storage_mgr.storage_provider.delete(file.file_name)\n\n    async def store_file(self, file_id: str, parser_plugin_id: str | None = None) -> str:\n        # pre checking\n        if not await self.ap.storage_mgr.storage_provider.exists(file_id):\n            raise Exception(f'File {file_id} not found')\n\n        file_name = file_id\n        _, ext = os.path.splitext(file_name)\n        extension = ext.lstrip('.').lower() if ext else ''\n\n        if extension == 'zip':\n            return await self._store_zip_file(file_id, parser_plugin_id=parser_plugin_id)\n\n        file_uuid = str(uuid.uuid4())\n        kb_id = self.knowledge_base_entity.uuid\n\n        file_obj_data = {\n            'uuid': file_uuid,\n            'kb_id': kb_id,\n            'file_name': file_name,\n            'extension': extension,\n            'status': 'pending',\n        }\n\n        file_obj = persistence_rag.File(**file_obj_data)\n\n        await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.File).values(file_obj_data))\n\n        # run background task asynchronously\n        ctx = taskmgr.TaskContext.new()\n        wrapper = self.ap.task_mgr.create_user_task(\n            self._store_file_task(file_obj, task_context=ctx, parser_plugin_id=parser_plugin_id),\n            kind='knowledge-operation',\n            name=f'knowledge-store-file-{file_id}',\n            label=f'Store file {file_id}',\n            context=ctx,\n        )\n        return wrapper.id\n\n    async def _store_zip_file(self, zip_file_id: str, parser_plugin_id: str | None = None) -> str:\n        \"\"\"Handle ZIP file by extracting each document and storing them separately.\"\"\"\n        self.ap.logger.info(f'Processing ZIP file: {zip_file_id}')\n\n        zip_bytes = await self.ap.storage_mgr.storage_provider.load(zip_file_id)\n\n        supported_extensions = {'txt', 'pdf', 'docx', 'md', 'html'}\n        stored_file_tasks = []\n\n        # use utf-8 encoding\n        with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r', metadata_encoding='utf-8') as zip_ref:\n            for file_info in zip_ref.filelist:\n                # skip directories and hidden files\n                if file_info.is_dir() or file_info.filename.startswith('.'):\n                    continue\n\n                _, file_ext = os.path.splitext(file_info.filename)\n                file_extension = file_ext.lstrip('.').lower()\n                if file_extension not in supported_extensions:\n                    self.ap.logger.debug(f'Skipping unsupported file in ZIP: {file_info.filename}')\n                    continue\n\n                try:\n                    file_content = zip_ref.read(file_info.filename)\n\n                    base_name = file_info.filename.replace('/', '_').replace('\\\\', '_')\n                    file_stem, file_ext = os.path.splitext(base_name)\n                    extension = file_ext.lstrip('.')\n\n                    if file_stem.startswith('__MACOSX'):\n                        continue\n\n                    extracted_file_id = file_stem + '_' + str(uuid.uuid4())[:8] + '.' + extension\n                    # save file to storage\n\n                    await self.ap.storage_mgr.storage_provider.save(extracted_file_id, file_content)\n\n                    task_id = await self.store_file(extracted_file_id, parser_plugin_id=parser_plugin_id)\n                    stored_file_tasks.append(task_id)\n\n                    self.ap.logger.info(\n                        f'Extracted and stored file from ZIP: {file_info.filename} -> {extracted_file_id}'\n                    )\n\n                except Exception as e:\n                    self.ap.logger.warning(f'Failed to extract file {file_info.filename} from ZIP: {e}')\n                    continue\n\n        if not stored_file_tasks:\n            raise Exception('No supported files found in ZIP archive')\n\n        self.ap.logger.info(f'Successfully processed ZIP file {zip_file_id}, extracted {len(stored_file_tasks)} files')\n        await self.ap.storage_mgr.storage_provider.delete(zip_file_id)\n\n        return stored_file_tasks[0] if stored_file_tasks else ''\n\n    async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]:\n        # Merge stored retrieval_settings with per-request overrides\n        stored = self.knowledge_base_entity.retrieval_settings or {}\n        merged = {**stored, **(settings or {})}\n        if 'top_k' not in merged:\n            merged['top_k'] = 5  # fallback default\n\n        response = await self._retrieve(query, merged)\n\n        results_data = response.get('results', [])\n        entries = []\n        for r in results_data:\n            if isinstance(r, dict):\n                entries.append(rag_context.RetrievalResultEntry(**r))\n            elif isinstance(r, rag_context.RetrievalResultEntry):\n                entries.append(r)\n        return entries\n\n    async def delete_file(self, file_id: str):\n        await self._delete_document(file_id)\n\n        # Also cleanup DB record\n        await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file_id)\n        )\n\n    def get_uuid(self) -> str:\n        \"\"\"Get the UUID of the knowledge base\"\"\"\n        return self.knowledge_base_entity.uuid\n\n    def get_name(self) -> str:\n        \"\"\"Get the name of the knowledge base\"\"\"\n        return self.knowledge_base_entity.name\n\n    def get_knowledge_engine_plugin_id(self) -> str:\n        \"\"\"Get the Knowledge Engine plugin ID\"\"\"\n        return self.knowledge_base_entity.knowledge_engine_plugin_id or ''\n\n    async def dispose(self):\n        \"\"\"Dispose the knowledge base, notifying the plugin to cleanup.\"\"\"\n        await self._on_kb_delete()\n\n    # ========== Plugin Communication Methods ==========\n\n    async def _on_kb_create(self) -> None:\n        \"\"\"Notify plugin about KB creation.\"\"\"\n        plugin_id = self.knowledge_base_entity.knowledge_engine_plugin_id\n        if not plugin_id:\n            return\n\n        try:\n            config = self.knowledge_base_entity.creation_settings or {}\n            self.ap.logger.info(\n                f'Calling RAG plugin {plugin_id}: on_knowledge_base_create(kb_id={self.knowledge_base_entity.uuid})'\n            )\n            await self.ap.plugin_connector.rag_on_kb_create(plugin_id, self.knowledge_base_entity.uuid, config)\n        except Exception as e:\n            self.ap.logger.error(f'Failed to notify plugin {plugin_id} on KB create: {e}')\n            raise\n\n    async def _on_kb_delete(self) -> None:\n        \"\"\"Notify plugin about KB deletion.\"\"\"\n        plugin_id = self.knowledge_base_entity.knowledge_engine_plugin_id\n        if not plugin_id:\n            return\n\n        try:\n            self.ap.logger.info(\n                f'Calling RAG plugin {plugin_id}: on_knowledge_base_delete(kb_id={self.knowledge_base_entity.uuid})'\n            )\n            await self.ap.plugin_connector.rag_on_kb_delete(plugin_id, self.knowledge_base_entity.uuid)\n        except Exception as e:\n            self.ap.logger.error(f'Failed to notify plugin {plugin_id} on KB delete: {e}')\n\n    async def _ingest_document(\n        self,\n        file_metadata: dict[str, Any],\n        storage_path: str,\n        parsed_content: dict[str, Any] | None = None,\n    ) -> dict[str, Any]:\n        \"\"\"Call plugin to ingest document.\"\"\"\n        kb = self.knowledge_base_entity\n        plugin_id = kb.knowledge_engine_plugin_id\n        if not plugin_id:\n            self.ap.logger.error(f'No RAG plugin ID configured for KB {kb.uuid}. Ingestion failed.')\n            raise ValueError('RAG Plugin ID required')\n\n        self.ap.logger.info(f'Calling RAG plugin {plugin_id}: ingest(doc={file_metadata.get(\"filename\")})')\n\n        # Inject knowledge_base_id into file metadata as required by SDK schema\n        file_metadata['knowledge_base_id'] = kb.uuid\n\n        context_data = {\n            'file_object': {\n                'metadata': file_metadata,\n                'storage_path': storage_path,\n            },\n            'knowledge_base_id': kb.uuid,\n            'collection_id': kb.collection_id or kb.uuid,\n            'creation_settings': kb.creation_settings or {},\n            'parsed_content': parsed_content,\n        }\n\n        try:\n            result = await self.ap.plugin_connector.call_rag_ingest(plugin_id, context_data)\n            return result\n        except Exception as e:\n            self.ap.logger.error(f'Plugin ingestion failed: {e}')\n            raise\n\n    async def _retrieve(\n        self,\n        query: str,\n        settings: dict[str, Any],\n    ) -> dict[str, Any]:\n        \"\"\"Call plugin to retrieve documents.\n\n        Raises:\n            ValueError: If no RAG plugin is configured for this KB.\n            Exception: If the plugin retrieval call fails.\n        \"\"\"\n        kb = self.knowledge_base_entity\n        plugin_id = kb.knowledge_engine_plugin_id\n        if not plugin_id:\n            raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.')\n\n        # Session context (e.g. session_name) stays in retrieval_settings\n        # for plugins that need it. Do NOT move them into filters, as filters\n        # are passed directly to vector_search by some plugins (e.g. LangRAG)\n        # and would cause empty results when the metadata field doesn't exist.\n        filters = settings.pop('filters', {})\n\n        retrieval_context = {\n            'query': query,\n            'knowledge_base_id': kb.uuid,\n            'collection_id': kb.collection_id or kb.uuid,\n            'retrieval_settings': settings,\n            'creation_settings': kb.creation_settings or {},\n            'filters': filters,\n        }\n\n        result = await self.ap.plugin_connector.call_rag_retrieve(\n            plugin_id,\n            retrieval_context,\n        )\n        return result\n\n    async def _delete_document(self, document_id: str) -> bool:\n        \"\"\"Call plugin to delete document.\"\"\"\n        kb = self.knowledge_base_entity\n        plugin_id = kb.knowledge_engine_plugin_id\n        if not plugin_id:\n            return False\n\n        self.ap.logger.info(f'Calling RAG plugin {plugin_id}: delete_document(doc_id={document_id})')\n\n        try:\n            return await self.ap.plugin_connector.call_rag_delete_document(plugin_id, document_id, kb.uuid)\n        except Exception as e:\n            self.ap.logger.error(f'Plugin document deletion failed: {e}')\n            return False\n\n\nclass RAGManager:\n    ap: app.Application\n\n    knowledge_bases: dict[str, KnowledgeBaseInterface]\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        self.knowledge_bases = {}\n\n    async def initialize(self):\n        await self.load_knowledge_bases_from_db()\n\n    async def get_all_knowledge_base_details(self) -> list[dict]:\n        \"\"\"Get all knowledge bases with enriched Knowledge Engine details.\"\"\"\n        # 1. Get raw KBs from DB\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.KnowledgeBase))\n        knowledge_bases = result.all()\n\n        # 2. Get all available Knowledge Engines for enrichment\n        engine_map = {}\n        if self.ap.plugin_connector.is_enable_plugin:\n            try:\n                engines = await self.ap.plugin_connector.list_knowledge_engines()\n                engine_map = {e['plugin_id']: e for e in engines}\n            except Exception as e:\n                self.ap.logger.warning(f'Failed to list Knowledge Engines: {e}')\n\n        # 3. Serialize and enrich\n        kb_list = []\n        for kb in knowledge_bases:\n            kb_dict = self.ap.persistence_mgr.serialize_model(persistence_rag.KnowledgeBase, kb)\n            self._enrich_kb_dict(kb_dict, engine_map)\n            kb_list.append(kb_dict)\n\n        return kb_list\n\n    async def get_knowledge_base_details(self, kb_uuid: str) -> dict | None:\n        \"\"\"Get specific knowledge base with enriched Knowledge Engine details.\"\"\"\n        result = await self.ap.persistence_mgr.execute_async(\n            sqlalchemy.select(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid)\n        )\n        kb = result.first()\n        if not kb:\n            return None\n\n        kb_dict = self.ap.persistence_mgr.serialize_model(persistence_rag.KnowledgeBase, kb)\n\n        # Fetch engines\n        engine_map = {}\n        if self.ap.plugin_connector.is_enable_plugin:\n            try:\n                engines = await self.ap.plugin_connector.list_knowledge_engines()\n                engine_map = {e['plugin_id']: e for e in engines}\n            except Exception as e:\n                self.ap.logger.warning(f'Failed to list Knowledge Engines: {e}')\n\n        self._enrich_kb_dict(kb_dict, engine_map)\n        return kb_dict\n\n    @staticmethod\n    def _to_i18n_name(name) -> dict:\n        \"\"\"Ensure name is always an I18nObject-compatible dict.\n\n        If *name* is already a dict (with ``en_US`` / ``zh_Hans`` keys) it is\n        returned as-is.  A plain string is wrapped into an I18nObject so the\n        frontend ``extractI18nObject`` helper never receives an unexpected type.\n        \"\"\"\n        if isinstance(name, dict):\n            return name\n        return {'en_US': str(name), 'zh_Hans': str(name)}\n\n    def _enrich_kb_dict(self, kb_dict: dict, engine_map: dict) -> None:\n        \"\"\"Helper to inject engine info into KB dict.\"\"\"\n        plugin_id = kb_dict.get('knowledge_engine_plugin_id')\n\n        # Default fallback structure — name must be I18nObject for frontend compatibility\n        fallback_name = self._to_i18n_name(plugin_id or 'Internal (Legacy)')\n        fallback_info = {\n            'plugin_id': plugin_id,\n            'name': fallback_name,\n            'capabilities': [],\n        }\n\n        if not plugin_id:\n            kb_dict['knowledge_engine'] = fallback_info\n            return\n\n        engine_info = engine_map.get(plugin_id)\n        if engine_info:\n            kb_dict['knowledge_engine'] = {\n                'plugin_id': plugin_id,\n                'name': self._to_i18n_name(engine_info.get('name', plugin_id)),\n                'capabilities': engine_info.get('capabilities', []),\n            }\n        else:\n            kb_dict['knowledge_engine'] = fallback_info\n\n    async def create_knowledge_base(\n        self,\n        name: str,\n        knowledge_engine_plugin_id: str,\n        creation_settings: dict,\n        retrieval_settings: dict | None = None,\n        description: str = '',\n    ) -> persistence_rag.KnowledgeBase:\n        \"\"\"Create a new knowledge base using a RAG plugin.\"\"\"\n        # Validate that the Knowledge Engine plugin exists\n        if self.ap.plugin_connector.is_enable_plugin:\n            try:\n                engines = await self.ap.plugin_connector.list_knowledge_engines()\n                engine_ids = [e.get('plugin_id') for e in engines]\n                if knowledge_engine_plugin_id not in engine_ids:\n                    raise ValueError(f'Knowledge Engine plugin {knowledge_engine_plugin_id} not found')\n            except ValueError:\n                raise\n            except Exception as e:\n                self.ap.logger.warning(f'Failed to validate Knowledge Engine plugin existence: {e}')\n\n        kb_uuid = str(uuid.uuid4())\n        # Use UUID as collection ID by default for isolation\n        collection_id = kb_uuid\n\n        kb_data = {\n            'uuid': kb_uuid,\n            'name': name,\n            'description': description,\n            'knowledge_engine_plugin_id': knowledge_engine_plugin_id,\n            'collection_id': collection_id,\n            'creation_settings': creation_settings,\n            'retrieval_settings': retrieval_settings or {},\n        }\n\n        # Create Entity\n        kb = persistence_rag.KnowledgeBase(**kb_data)\n\n        # Persist\n        await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.KnowledgeBase).values(kb_data))\n\n        # Load into Runtime\n        runtime_kb = await self.load_knowledge_base(kb)\n\n        # Notify Plugin — rollback DB record and runtime entry on failure\n        try:\n            await runtime_kb._on_kb_create()\n        except Exception:\n            self.knowledge_bases.pop(kb_uuid, None)\n            await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.delete(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid)\n            )\n            raise\n\n        self.ap.logger.info(f'Created new Knowledge Base {name} ({kb_uuid}) using plugin {knowledge_engine_plugin_id}')\n        return kb\n\n    async def load_knowledge_bases_from_db(self):\n        self.ap.logger.info('Loading knowledge bases from db...')\n\n        self.knowledge_bases = {}\n\n        # Load knowledge bases\n        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.KnowledgeBase))\n        knowledge_bases = result.all()\n\n        for knowledge_base in knowledge_bases:\n            try:\n                await self.load_knowledge_base(knowledge_base)\n            except Exception as e:\n                self.ap.logger.error(\n                    f'Error loading knowledge base {knowledge_base.uuid}: {e}\\n{traceback.format_exc()}'\n                )\n\n    async def load_knowledge_base(\n        self,\n        knowledge_base_entity: persistence_rag.KnowledgeBase | sqlalchemy.Row | dict,\n    ) -> RuntimeKnowledgeBase:\n        if isinstance(knowledge_base_entity, sqlalchemy.Row):\n            # Safe access to _mapping for SQLAlchemy 1.4+\n            knowledge_base_entity = persistence_rag.KnowledgeBase(**knowledge_base_entity._mapping)\n        elif isinstance(knowledge_base_entity, dict):\n            # Filter out non-database fields (like knowledge_engine which is computed)\n            filtered_dict = {\n                k: v for k, v in knowledge_base_entity.items() if k in persistence_rag.KnowledgeBase.ALL_DB_FIELDS\n            }\n            knowledge_base_entity = persistence_rag.KnowledgeBase(**filtered_dict)\n\n        runtime_knowledge_base = RuntimeKnowledgeBase(ap=self.ap, knowledge_base_entity=knowledge_base_entity)\n\n        await runtime_knowledge_base.initialize()\n\n        self.knowledge_bases[runtime_knowledge_base.get_uuid()] = runtime_knowledge_base\n\n        return runtime_knowledge_base\n\n    async def get_knowledge_base_by_uuid(self, kb_uuid: str) -> KnowledgeBaseInterface | None:\n        return self.knowledge_bases.get(kb_uuid)\n\n    async def remove_knowledge_base_from_runtime(self, kb_uuid: str):\n        self.knowledge_bases.pop(kb_uuid, None)\n\n    async def delete_knowledge_base(self, kb_uuid: str):\n        kb = self.knowledge_bases.pop(kb_uuid, None)\n        if kb is not None:\n            await kb.dispose()\n        else:\n            self.ap.logger.warning(f'Knowledge base {kb_uuid} not found in runtime, skipping plugin notification')\n"
  },
  {
    "path": "src/langbot/pkg/rag/service/__init__.py",
    "content": "from .runtime import RAGRuntimeService as RAGRuntimeService\n"
  },
  {
    "path": "src/langbot/pkg/rag/service/runtime.py",
    "content": "from __future__ import annotations\n\nimport posixpath\nfrom typing import Any\nfrom langbot.pkg.core import app\n\n\nclass RAGRuntimeService:\n    \"\"\"Service to handle RAG-related requests from plugins (Runtime).\n\n    This service acts as the bridge between plugin RPC requests and\n    LangBot's infrastructure (embedding models, vector databases, file storage).\n    \"\"\"\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def vector_upsert(\n        self,\n        collection_id: str,\n        vectors: list[list[float]],\n        ids: list[str],\n        metadata: list[dict[str, Any]] | None = None,\n        documents: list[str] | None = None,\n    ) -> None:\n        \"\"\"Handle VECTOR_UPSERT action.\"\"\"\n        metadatas = metadata if metadata else [{} for _ in vectors]\n        await self.ap.vector_db_mgr.upsert(\n            collection_name=collection_id,\n            vectors=vectors,\n            ids=ids,\n            metadata=metadatas,\n            documents=documents,\n        )\n\n    async def vector_search(\n        self,\n        collection_id: str,\n        query_vector: list[float],\n        top_k: int,\n        filters: dict[str, Any] | None = None,\n        search_type: str = 'vector',\n        query_text: str = '',\n    ) -> list[dict[str, Any]]:\n        \"\"\"Handle VECTOR_SEARCH action.\"\"\"\n        return await self.ap.vector_db_mgr.search(\n            collection_name=collection_id,\n            query_vector=query_vector,\n            limit=top_k,\n            filter=filters,\n            search_type=search_type,\n            query_text=query_text,\n        )\n\n    async def vector_delete(\n        self, collection_id: str, file_ids: list[str] | None = None, filters: dict[str, Any] | None = None\n    ) -> int:\n        \"\"\"Handle VECTOR_DELETE action.\n\n        Deletes vectors associated with the given file IDs from the collection.\n        Each file_id corresponds to a document whose vectors will be removed.\n\n        Args:\n            collection_id: The collection to delete from.\n            file_ids: File IDs whose associated vectors should be deleted.\n                Each file_id maps to a set of vectors stored with that file_id\n                in their metadata.\n            filters: Filter-based deletion (not yet supported, will raise).\n        \"\"\"\n        count = 0\n        if file_ids:\n            await self.ap.vector_db_mgr.delete_by_file_id(collection_name=collection_id, file_ids=file_ids)\n            count = len(file_ids)\n        elif filters:\n            count = await self.ap.vector_db_mgr.delete_by_filter(collection_name=collection_id, filter=filters)\n        return count\n\n    async def vector_list(\n        self,\n        collection_id: str,\n        filters: dict[str, Any] | None = None,\n        limit: int = 20,\n        offset: int = 0,\n    ) -> tuple[list[dict[str, Any]], int]:\n        \"\"\"Handle VECTOR_LIST action.\n\n        Args:\n            collection_id: The collection to list from.\n            filters: Optional metadata filters.\n            limit: Maximum number of items to return.\n            offset: Number of items to skip.\n\n        Returns:\n            Tuple of (items, total).\n        \"\"\"\n        return await self.ap.vector_db_mgr.list_by_filter(\n            collection_name=collection_id,\n            filter=filters,\n            limit=limit,\n            offset=offset,\n        )\n\n    async def get_file_stream(self, storage_path: str) -> bytes:\n        \"\"\"Handle GET_KNOWLEDEGE_FILE_STREAM action.\n\n        Uses the storage manager abstraction to load file content,\n        regardless of the underlying storage provider.\n        \"\"\"\n        # Validate storage_path to prevent path traversal\n        normalized = posixpath.normpath(storage_path)\n        if normalized.startswith('/') or '..' in normalized.split('/'):\n            raise ValueError('Invalid storage path')\n        content_bytes = await self.ap.storage_mgr.storage_provider.load(normalized)\n        return content_bytes if content_bytes else b''\n"
  },
  {
    "path": "src/langbot/pkg/storage/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/storage/mgr.py",
    "content": "from __future__ import annotations\n\n\nfrom ..core import app\nfrom . import provider\nfrom .providers import localstorage\n\n\nclass StorageMgr:\n    \"\"\"Storage manager\"\"\"\n\n    ap: app.Application\n\n    storage_provider: provider.StorageProvider\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        storage_config = self.ap.instance_config.data.get('storage', {})\n        storage_type = storage_config.get('use', 'local')\n\n        if storage_type == 's3':\n            from .providers import s3storage\n\n            self.storage_provider = s3storage.S3StorageProvider(self.ap)\n            self.ap.logger.info('Initialized S3 storage backend.')\n        else:\n            self.storage_provider = localstorage.LocalStorageProvider(self.ap)\n            self.ap.logger.info('Initialized local storage backend.')\n\n        await self.storage_provider.initialize()\n"
  },
  {
    "path": "src/langbot/pkg/storage/provider.py",
    "content": "from __future__ import annotations\n\nimport abc\n\nfrom ..core import app\n\n\nclass StorageProvider(abc.ABC):\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        pass\n\n    @abc.abstractmethod\n    async def save(\n        self,\n        key: str,\n        value: bytes,\n    ):\n        pass\n\n    @abc.abstractmethod\n    async def load(\n        self,\n        key: str,\n    ) -> bytes:\n        pass\n\n    @abc.abstractmethod\n    async def exists(\n        self,\n        key: str,\n    ) -> bool:\n        pass\n\n    @abc.abstractmethod\n    async def delete(\n        self,\n        key: str,\n    ):\n        pass\n\n    @abc.abstractmethod\n    async def size(\n        self,\n        key: str,\n    ) -> int:\n        pass\n\n    @abc.abstractmethod\n    async def delete_dir_recursive(\n        self,\n        dir_path: str,\n    ):\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/storage/providers/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/storage/providers/localstorage.py",
    "content": "from __future__ import annotations\n\nimport os\nimport aiofiles\nimport shutil\n\nfrom ...core import app\n\nfrom .. import provider\n\n\nLOCAL_STORAGE_PATH = os.path.join('data', 'storage')\n\n\nclass LocalStorageProvider(provider.StorageProvider):\n    def __init__(self, ap: app.Application):\n        super().__init__(ap)\n        if not os.path.exists(LOCAL_STORAGE_PATH):\n            os.makedirs(LOCAL_STORAGE_PATH)\n\n    async def save(\n        self,\n        key: str,\n        value: bytes,\n    ):\n        if not os.path.exists(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key))):\n            os.makedirs(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key)))\n        async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'wb') as f:\n            await f.write(value)\n\n    async def load(\n        self,\n        key: str,\n    ) -> bytes:\n        async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'rb') as f:\n            return await f.read()\n\n    async def exists(\n        self,\n        key: str,\n    ) -> bool:\n        return os.path.exists(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))\n\n    async def delete(\n        self,\n        key: str,\n    ):\n        os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))\n\n    async def size(\n        self,\n        key: str,\n    ) -> int:\n        return os.path.getsize(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))\n\n    async def delete_dir_recursive(\n        self,\n        dir_path: str,\n    ):\n        # 直接删除整个目录\n        if os.path.exists(os.path.join(LOCAL_STORAGE_PATH, dir_path)):\n            shutil.rmtree(os.path.join(LOCAL_STORAGE_PATH, dir_path))\n"
  },
  {
    "path": "src/langbot/pkg/storage/providers/s3storage.py",
    "content": "from __future__ import annotations\n\nimport boto3\nfrom botocore.exceptions import ClientError\n\nfrom ...core import app\nfrom .. import provider\n\n\nclass S3StorageProvider(provider.StorageProvider):\n    \"\"\"S3 object storage provider\"\"\"\n\n    def __init__(self, ap: app.Application):\n        super().__init__(ap)\n        self.s3_client = None\n        self.bucket_name = None\n\n    async def initialize(self):\n        \"\"\"Initialize S3 client with configuration from config.yaml\"\"\"\n        storage_config = self.ap.instance_config.data.get('storage', {})\n        s3_config = storage_config.get('s3', {})\n\n        # Get S3 configuration\n        endpoint_url = s3_config.get('endpoint_url', '')\n        access_key_id = s3_config.get('access_key_id', '')\n        secret_access_key = s3_config.get('secret_access_key', '')\n        region_name = s3_config.get('region', 'us-east-1')\n        self.bucket_name = s3_config.get('bucket', 'langbot-storage')\n\n        # Initialize S3 client\n        session = boto3.session.Session()\n        self.s3_client = session.client(\n            service_name='s3',\n            region_name=region_name,\n            endpoint_url=endpoint_url if endpoint_url else None,\n            aws_access_key_id=access_key_id,\n            aws_secret_access_key=secret_access_key,\n        )\n\n        # Ensure bucket exists\n        try:\n            self.s3_client.head_bucket(Bucket=self.bucket_name)\n        except ClientError as e:\n            error_code = e.response['Error']['Code']\n            if error_code == '404':\n                # Bucket doesn't exist, create it\n                try:\n                    self.s3_client.create_bucket(Bucket=self.bucket_name)\n                    self.ap.logger.info(f'Created S3 bucket: {self.bucket_name}')\n                except Exception as create_error:\n                    self.ap.logger.error(f'Failed to create S3 bucket: {create_error}')\n                    raise\n            else:\n                self.ap.logger.error(f'Failed to access S3 bucket: {e}')\n                raise\n\n    async def save(\n        self,\n        key: str,\n        value: bytes,\n    ):\n        \"\"\"Save bytes to S3\"\"\"\n        try:\n            self.s3_client.put_object(\n                Bucket=self.bucket_name,\n                Key=key,\n                Body=value,\n            )\n        except Exception as e:\n            self.ap.logger.error(f'Failed to save to S3: {e}')\n            raise\n\n    async def load(\n        self,\n        key: str,\n    ) -> bytes:\n        \"\"\"Load bytes from S3\"\"\"\n        try:\n            response = self.s3_client.get_object(\n                Bucket=self.bucket_name,\n                Key=key,\n            )\n            return response['Body'].read()\n        except Exception as e:\n            self.ap.logger.error(f'Failed to load from S3: {e}')\n            raise\n\n    async def exists(\n        self,\n        key: str,\n    ) -> bool:\n        \"\"\"Check if object exists in S3\"\"\"\n        try:\n            self.s3_client.head_object(\n                Bucket=self.bucket_name,\n                Key=key,\n            )\n            return True\n        except ClientError as e:\n            if e.response['Error']['Code'] == '404':\n                return False\n            else:\n                self.ap.logger.error(f'Failed to check existence in S3: {e}')\n                raise\n\n    async def delete(\n        self,\n        key: str,\n    ):\n        \"\"\"Delete object from S3\"\"\"\n        try:\n            self.s3_client.delete_object(\n                Bucket=self.bucket_name,\n                Key=key,\n            )\n        except Exception as e:\n            self.ap.logger.error(f'Failed to delete from S3: {e}')\n            raise\n\n    async def size(\n        self,\n        key: str,\n    ) -> int:\n        \"\"\"Get object size from S3 without downloading it\"\"\"\n        try:\n            response = self.s3_client.head_object(\n                Bucket=self.bucket_name,\n                Key=key,\n            )\n            return response['ContentLength']\n        except Exception as e:\n            self.ap.logger.error(f'Failed to get size from S3: {e}')\n            raise\n\n    async def delete_dir_recursive(\n        self,\n        dir_path: str,\n    ):\n        \"\"\"Delete all objects with the given prefix (directory)\"\"\"\n        try:\n            # Ensure dir_path ends with /\n            if not dir_path.endswith('/'):\n                dir_path = dir_path + '/'\n\n            # List all objects with the prefix\n            paginator = self.s3_client.get_paginator('list_objects_v2')\n            pages = paginator.paginate(Bucket=self.bucket_name, Prefix=dir_path)\n\n            # Delete all objects\n            for page in pages:\n                if 'Contents' in page:\n                    objects_to_delete = [{'Key': obj['Key']} for obj in page['Contents']]\n                    if objects_to_delete:\n                        self.s3_client.delete_objects(\n                            Bucket=self.bucket_name,\n                            Delete={'Objects': objects_to_delete},\n                        )\n        except Exception as e:\n            self.ap.logger.error(f'Failed to delete directory from S3: {e}')\n            raise\n"
  },
  {
    "path": "src/langbot/pkg/survey/__init__.py",
    "content": "\"\"\"Survey module for in-product surveys triggered by events.\"\"\"\n"
  },
  {
    "path": "src/langbot/pkg/survey/manager.py",
    "content": "\"\"\"Survey manager: tracks events, communicates with Space to fetch/submit surveys.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport typing\nimport httpx\nimport sqlalchemy\n\nfrom ..core import app as core_app\nfrom ..entity.persistence.metadata import Metadata\nfrom ..utils import constants\n\nSURVEY_TRIGGERED_KEY = 'survey_triggered_events'\n\n\nclass SurveyManager:\n    \"\"\"Manages survey lifecycle: event tracking, pending survey fetch, submission.\"\"\"\n\n    def __init__(self, ap: core_app.Application):\n        self.ap = ap\n        self._triggered_events: set[str] = set()\n        self._pending_survey: typing.Optional[dict] = None\n        self._space_url: str = ''\n\n    async def initialize(self):\n        space_config = self.ap.instance_config.data.get('space', {})\n        self._space_url = space_config.get('url', '').rstrip('/')\n        await self._load_triggered_events()\n\n    async def _load_triggered_events(self):\n        \"\"\"Load previously triggered events from metadata table.\"\"\"\n        try:\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.select(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY)\n            )\n            row = result.first()\n            if row:\n                self._triggered_events = set(json.loads(row[0].value))\n        except Exception:\n            self._triggered_events = set()\n\n    async def _save_triggered_events(self):\n        \"\"\"Persist triggered events to metadata table.\"\"\"\n        try:\n            value = json.dumps(list(self._triggered_events))\n            result = await self.ap.persistence_mgr.execute_async(\n                sqlalchemy.select(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY)\n            )\n            if result.first():\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.update(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY).values(value=value)\n                )\n            else:\n                await self.ap.persistence_mgr.execute_async(\n                    sqlalchemy.insert(Metadata).values(key=SURVEY_TRIGGERED_KEY, value=value)\n                )\n        except Exception as e:\n            self.ap.logger.debug(f'Failed to save survey triggered events: {e}')\n\n    def _is_space_configured(self) -> bool:\n        space_config = self.ap.instance_config.data.get('space', {})\n        if space_config.get('disable_telemetry', False):\n            return False\n        return bool(self._space_url)\n\n    async def trigger_event(self, event: str):\n        \"\"\"Called when an event occurs. Checks Space for a pending survey.\"\"\"\n        if event in self._triggered_events:\n            return\n        if not self._is_space_configured():\n            return\n\n        self._triggered_events.add(event)\n        await self._save_triggered_events()\n\n        # Check for pending survey asynchronously\n        asyncio.create_task(self._fetch_pending_survey(event))\n\n    async def _fetch_pending_survey(self, event: str):\n        \"\"\"Fetch pending survey from Space for this event.\"\"\"\n        try:\n            url = f'{self._space_url}/api/v1/survey/pending'\n            payload = {\n                'instance_id': constants.instance_id,\n                'event': event,\n            }\n            async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:\n                resp = await client.post(url, json=payload)\n                if resp.status_code == 200:\n                    data = resp.json()\n                    if data.get('code') == 0 and data.get('data', {}).get('survey'):\n                        self._pending_survey = data['data']['survey']\n                        self.ap.logger.info(f'Survey pending: {self._pending_survey.get(\"survey_id\")}')\n        except Exception as e:\n            self.ap.logger.debug(f'Failed to fetch pending survey: {e}')\n\n    def get_pending_survey(self) -> typing.Optional[dict]:\n        \"\"\"Return the current pending survey (if any) for the frontend to display.\"\"\"\n        return self._pending_survey\n\n    def clear_pending_survey(self):\n        \"\"\"Clear the pending survey (after user responds or dismisses).\"\"\"\n        self._pending_survey = None\n\n    async def submit_response(self, survey_id: str, answers: dict, completed: bool = True) -> bool:\n        \"\"\"Submit a survey response to Space.\"\"\"\n        if not self._is_space_configured():\n            return False\n        try:\n            url = f'{self._space_url}/api/v1/survey/respond'\n            payload = {\n                'survey_id': survey_id,\n                'instance_id': constants.instance_id,\n                'answers': answers,\n                'metadata': {\n                    'version': constants.semantic_version,\n                },\n                'completed': completed,\n            }\n            async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:\n                resp = await client.post(url, json=payload)\n                if resp.status_code == 200:\n                    self.clear_pending_survey()\n                    return True\n        except Exception as e:\n            self.ap.logger.warning(f'Failed to submit survey response: {e}')\n        return False\n\n    async def dismiss_survey(self, survey_id: str) -> bool:\n        \"\"\"Dismiss a survey.\"\"\"\n        if not self._is_space_configured():\n            return False\n        try:\n            url = f'{self._space_url}/api/v1/survey/dismiss'\n            payload = {\n                'survey_id': survey_id,\n                'instance_id': constants.instance_id,\n            }\n            async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:\n                resp = await client.post(url, json=payload)\n                if resp.status_code == 200:\n                    self.clear_pending_survey()\n                    return True\n        except Exception as e:\n            self.ap.logger.warning(f'Failed to dismiss survey: {e}')\n        return False\n"
  },
  {
    "path": "src/langbot/pkg/telemetry/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/telemetry/telemetry.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport httpx\nfrom ..core import app as core_app\n\n\nclass TelemetryManager:\n    \"\"\"TelemetryManager handles sending telemetry for a given application instance.\n\n    Usage:\n        telemetry = TelemetryManager(ap)\n        await telemetry.send({ ... })\n    \"\"\"\n\n    send_tasks: list[asyncio.Task] = []\n\n    def __init__(self, ap: core_app.Application):\n        self.ap = ap\n\n        self.telemetry_config = {}\n\n    async def initialize(self):\n        self.telemetry_config = self.ap.instance_config.data.get('space', {})\n\n    async def start_send_task(self, payload: dict):\n        task = asyncio.create_task(self.send(payload))\n        self.send_tasks.append(task)\n\n    async def send(self, payload: dict):\n        \"\"\"Send telemetry payload to configured telemetry server (non-blocking).\n\n        Expects ap.instance_config.data.telemetry to have:\n          - enabled: bool\n          - server: str (base URL, e.g. https://space.example.com)\n          - timeout_seconds: optional int, overall request timeout (default 10)\n\n        Posts to {server.rstrip('/')}/api/v1/telemetry as JSON. Failures are logged but do not raise.\n        \"\"\"\n\n        try:\n            cfg = self.telemetry_config\n            if not cfg:\n                return\n            if cfg.get('disable_telemetry', False):\n                return\n            server = cfg.get('url', '')\n            if not server:\n                return\n\n            # Normalize URL\n            url = server.rstrip('/') + '/api/v1/telemetry'\n\n            try:\n                # Sanitize payload so string fields are strings and not nulls\n                sanitized = dict(payload)\n                if 'query_id' in sanitized:\n                    try:\n                        sanitized['query_id'] = '' if sanitized['query_id'] is None else str(sanitized['query_id'])\n                    except Exception:\n                        sanitized['query_id'] = str(sanitized.get('query_id', ''))\n\n                for sfield in ('adapter', 'runner', 'runner_category', 'model_name', 'version', 'error', 'timestamp'):\n                    v = sanitized.get(sfield)\n                    sanitized[sfield] = '' if v is None else str(v)\n\n                if 'duration_ms' in sanitized:\n                    try:\n                        sanitized['duration_ms'] = (\n                            int(sanitized['duration_ms']) if sanitized['duration_ms'] is not None else 0\n                        )\n                    except Exception:\n                        sanitized['duration_ms'] = 0\n\n                async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:\n                    try:\n                        # Use asyncio.wait_for to ensure we always bound the total time\n                        resp = await asyncio.wait_for(client.post(url, json=sanitized), timeout=10 + 1)\n\n                        if resp.status_code >= 400:\n                            self.ap.logger.warning(\n                                f'Telemetry post to {url} returned status {resp.status_code} - {resp.text}'\n                            )\n                        else:\n                            # Detect application-level errors inside HTTP 200 responses\n                            app_err = False\n                            try:\n                                j = resp.json()\n                                if isinstance(j, dict) and j.get('code') is not None and int(j.get('code')) >= 400:\n                                    app_err = True\n                                    self.ap.logger.warning(\n                                        f'Telemetry post to {url} returned application error code {j.get(\"code\")} - {j.get(\"msg\")}'\n                                    )\n                            except Exception:\n                                pass\n\n                            if app_err:\n                                self.ap.logger.warning(\n                                    f'Telemetry post to {url} returned app-level error - response: {resp.text[:200]}'\n                                )\n                            else:\n                                self.ap.logger.debug(\n                                    f'Telemetry posted to {url}, status {resp.status_code} - response: {resp.text[:200]}'\n                                )\n                    except asyncio.TimeoutError:\n                        self.ap.logger.warning(f'Telemetry post to {url} timed out')\n                    except Exception as e:\n                        self.ap.logger.warning(f'Failed to post telemetry to {url}: {e}', exc_info=True)\n            except Exception as e:\n                try:\n                    self.ap.logger.warning(\n                        f'Failed to create HTTP client for telemetry or sanitize payload: {e}', exc_info=True\n                    )\n                except Exception:\n                    pass\n        except Exception as e:\n            # Never raise from telemetry; surface as warning for visibility\n            try:\n                self.ap.logger.warning(f'Unexpected telemetry error: {e}', exc_info=True)\n            except Exception:\n                pass\n"
  },
  {
    "path": "src/langbot/pkg/utils/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/utils/constants.py",
    "content": "import langbot\n\nsemantic_version = f'v{langbot.__version__}'\n\nrequired_database_version = 24\n\"\"\"Tag the version of the database schema, used to check if the database needs to be migrated\"\"\"\n\ndebug_mode = False\n\nedition = 'community'\n\ninstance_id = ''\n"
  },
  {
    "path": "src/langbot/pkg/utils/funcschema.py",
    "content": "import re\nimport inspect\nimport typing\n\n\ndef get_func_schema(function: typing.Callable) -> dict:\n    \"\"\"\n    Return the data schema of a function.\n    {\n        \"function\": function,\n        \"description\": \"function description\",\n        \"parameters\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"parameter_a\": {\n                    \"type\": \"str\",\n                    \"description\": \"parameter_a description\"\n                },\n                \"parameter_b\": {\n                    \"type\": \"int\",\n                    \"description\": \"parameter_b description\"\n                },\n                \"parameter_c\": {\n                    \"type\": \"str\",\n                    \"description\": \"parameter_c description\",\n                    \"enum\": [\"a\", \"b\", \"c\"]\n                },\n            },\n            \"required\": [\"parameter_a\", \"parameter_b\"]\n        }\n    }\n    \"\"\"\n    func_doc = function.__doc__\n    # Google Style Docstring\n    if func_doc is None:\n        raise Exception('Function {} has no docstring.'.format(function.__name__))\n    func_doc = func_doc.strip().replace('    ', '').replace('\\t', '')\n    # extract doc of args from docstring\n    doc_spt = func_doc.split('\\n\\n')\n    desc = doc_spt[0]\n    args = doc_spt[1] if len(doc_spt) > 1 else ''\n    # returns = doc_spt[2] if len(doc_spt) > 2 else \"\"\n\n    # extract args\n    # delete the first line of args\n    arg_lines = args.split('\\n')[1:]\n    # arg_doc_list = re.findall(r'(\\w+)(\\((\\w+)\\))?:\\s*(.*)', args)\n    args_doc = {}\n    for arg_line in arg_lines:\n        doc_tuple = re.findall(r'(\\w+)(\\(([\\w\\[\\]]+)\\))?:\\s*(.*)', arg_line)\n        if len(doc_tuple) == 0:\n            continue\n        args_doc[doc_tuple[0][0]] = doc_tuple[0][3]\n\n    # extract returns\n    # return_doc_list = re.findall(r'(\\w+):\\s*(.*)', returns)\n\n    params = enumerate(inspect.signature(function).parameters.values())\n    parameters = {\n        'type': 'object',\n        'required': [],\n        'properties': {},\n    }\n\n    for i, param in params:\n        # 排除 self, query\n        if param.name in ['self', 'query']:\n            continue\n\n        param_type = param.annotation.__name__\n\n        type_name_mapping = {\n            'str': 'string',\n            'int': 'integer',\n            'float': 'number',\n            'bool': 'boolean',\n            'list': 'array',\n            'dict': 'object',\n        }\n\n        if param_type in type_name_mapping:\n            param_type = type_name_mapping[param_type]\n\n        parameters['properties'][param.name] = {\n            'type': param_type,\n            'description': args_doc[param.name],\n        }\n\n        # add schema for array\n        if param_type == 'array':\n            # extract type of array, the int of list[int]\n            # use re\n            array_type_tuple = re.findall(r'list\\[(\\w+)\\]', str(param.annotation))\n\n            array_type = 'string'\n\n            if len(array_type_tuple) > 0:\n                array_type = array_type_tuple[0]\n\n            if array_type in type_name_mapping:\n                array_type = type_name_mapping[array_type]\n\n            parameters['properties'][param.name]['items'] = {\n                'type': array_type,\n            }\n\n        if param.default is inspect.Parameter.empty:\n            parameters['required'].append(param.name)\n\n    return {\n        'function': function,\n        'description': desc,\n        'parameters': parameters,\n    }\n"
  },
  {
    "path": "src/langbot/pkg/utils/httpclient.py",
    "content": "\"\"\"Shared aiohttp.ClientSession to avoid repeated SSL context creation.\n\nEach call to `aiohttp.ClientSession()` creates a new `TCPConnector` which in turn\ncreates a new `ssl.SSLContext` and loads all system root certificates. This is\nextremely expensive in both CPU and memory (~270MB total allocations observed via\nmemray profiling).\n\nThis module provides a shared session pool so that all HTTP client code in LangBot\nreuses the same underlying SSL context and connection pool.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport aiohttp\n\n_sessions: dict[str, aiohttp.ClientSession] = {}\n\n\ndef get_session(*, trust_env: bool = False) -> aiohttp.ClientSession:\n    \"\"\"Get or create a shared aiohttp.ClientSession.\n\n    Args:\n        trust_env: Whether to trust environment variables for proxy settings.\n\n    Returns:\n        A shared aiohttp.ClientSession instance.\n    \"\"\"\n    key = f'trust_env={trust_env}'\n\n    session = _sessions.get(key)\n    if session is None or session.closed:\n        session = aiohttp.ClientSession(trust_env=trust_env)\n        _sessions[key] = session\n\n    return session\n\n\nasync def close_all():\n    \"\"\"Close all shared sessions. Call on application shutdown.\"\"\"\n    for session in _sessions.values():\n        if not session.closed:\n            await session.close()\n    _sessions.clear()\n"
  },
  {
    "path": "src/langbot/pkg/utils/image.py",
    "content": "import base64\nimport typing\nimport io\nfrom urllib.parse import urlparse, parse_qs\nimport ssl\n\nimport aiohttp\n\nfrom langbot.pkg.utils import httpclient\nimport PIL.Image\nimport httpx\n\nimport asyncio\n\n\nasync def get_gewechat_image_base64(\n    gewechat_url: str,\n    gewechat_file_url: str,\n    app_id: str,\n    xml_content: str,\n    token: str,\n    image_type: int = 2,\n) -> typing.Tuple[str, str]:\n    \"\"\"从gewechat服务器获取图片并转换为base64格式\n\n    Args:\n        gewechat_url (str): gewechat服务器地址（用于获取图片URL）\n        gewechat_file_url (str): gewechat文件下载服务地址\n        app_id (str): gewechat应用ID\n        xml_content (str): 图片的XML内容\n        token (str): Gewechat API Token\n        image_type (int, optional): 图片类型. Defaults to 2.\n\n    Returns:\n        typing.Tuple[str, str]: (base64编码, 图片格式)\n\n    Raises:\n        aiohttp.ClientTimeout: 请求超时（15秒）或连接超时（2秒）\n        Exception: 其他错误\n    \"\"\"\n    headers = {'X-GEWE-TOKEN': token, 'Content-Type': 'application/json'}\n\n    # 设置超时\n    timeout = aiohttp.ClientTimeout(\n        total=15.0,  # 总超时时间15秒\n        connect=2.0,  # 连接超时2秒\n        sock_connect=2.0,  # socket连接超时2秒\n        sock_read=15.0,  # socket读取超时15秒\n    )\n\n    try:\n        session = httpclient.get_session()\n        # 获取图片下载链接\n        try:\n            async with session.post(\n                f'{gewechat_url}/v2/api/message/downloadImage',\n                headers=headers,\n                json={'appId': app_id, 'type': image_type, 'xml': xml_content},\n                timeout=timeout,\n            ) as response:\n                if response.status != 200:\n                    # print(response)\n                    raise Exception(f'获取gewechat图片下载失败: {await response.text()}')\n\n                resp_data = await response.json()\n                if resp_data.get('ret') != 200:\n                    raise Exception(f'获取gewechat图片下载链接失败: {resp_data}')\n\n                file_url = resp_data['data']['fileUrl']\n        except asyncio.TimeoutError:\n            raise Exception('获取图片下载链接超时')\n        except aiohttp.ClientError as e:\n            raise Exception(f'获取图片下载链接网络错误: {str(e)}')\n\n        # 解析原始URL并替换端口\n        base_url = gewechat_file_url\n        download_url = f'{base_url}/download/{file_url}'\n\n        # 下载图片\n        try:\n            async with session.get(download_url) as img_response:\n                if img_response.status != 200:\n                    raise Exception(f'下载图片失败: {await img_response.text()}, URL: {download_url}')\n\n                image_data = await img_response.read()\n\n                content_type = img_response.headers.get('Content-Type', '')\n                if content_type:\n                    image_format = content_type.split('/')[-1]\n                else:\n                    image_format = file_url.split('.')[-1]\n\n                base64_str = base64.b64encode(image_data).decode('utf-8')\n\n                return base64_str, image_format\n        except asyncio.TimeoutError:\n            raise Exception(f'下载图片超时, URL: {download_url}')\n        except aiohttp.ClientError as e:\n            raise Exception(f'下载图片网络错误: {str(e)}, URL: {download_url}')\n    except Exception as e:\n        raise Exception(f'获取图片失败: {str(e)}') from e\n\n\nasync def get_wecom_image_base64(pic_url: str) -> tuple[str, str]:\n    \"\"\"\n    下载企业微信图片并转换为 base64\n    :param pic_url: 企业微信图片URL\n    :return: (base64_str, image_format)\n    \"\"\"\n    session = httpclient.get_session()\n    async with session.get(pic_url) as response:\n        if response.status != 200:\n            raise Exception(f'Failed to download image: {response.status}')\n\n        # 读取图片数据\n        image_data = await response.read()\n\n        # 获取图片格式\n        content_type = response.headers.get('Content-Type', '')\n        image_format = content_type.split('/')[-1]  # 例如 'image/jpeg' -> 'jpeg'\n\n        # 转换为 base64\n        import base64\n\n        image_base64 = base64.b64encode(image_data).decode('utf-8')\n\n        return image_base64, image_format\n\n\nasync def get_qq_official_image_base64(pic_url: str, content_type: str) -> tuple[str, str]:\n    \"\"\"\n    下载QQ官方图片，\n    并且转换为base64格式\n    \"\"\"\n    async with httpx.AsyncClient() as client:\n        response = await client.get(pic_url)\n        response.raise_for_status()  # 确保请求成功\n        image_data = response.content\n        base64_data = base64.b64encode(image_data).decode('utf-8')\n\n        return f'data:{content_type};base64,{base64_data}'\n\n\ndef get_qq_image_downloadable_url(image_url: str) -> tuple[str, dict]:\n    \"\"\"获取QQ图片的下载链接\"\"\"\n    parsed = urlparse(image_url)\n    query = parse_qs(parsed.query)\n    return f'http://{parsed.netloc}{parsed.path}', query\n\n\nasync def get_qq_image_bytes(image_url: str, query: dict = {}) -> tuple[bytes, str]:\n    \"\"\"[弃用]获取QQ图片的bytes\"\"\"\n    image_url, query_in_url = get_qq_image_downloadable_url(image_url)\n    query = {**query, **query_in_url}\n    ssl_context = ssl.create_default_context()\n    ssl_context.check_hostname = False\n    ssl_context.verify_mode = ssl.CERT_NONE\n    session = httpclient.get_session()\n    async with session.get(image_url, params=query, ssl=ssl_context, timeout=aiohttp.ClientTimeout(total=30.0)) as resp:\n        resp.raise_for_status()\n        file_bytes = await resp.read()\n        content_type = resp.headers.get('Content-Type')\n        if not content_type:\n            image_format = 'jpeg'\n        elif not content_type.startswith('image/'):\n            pil_img = PIL.Image.open(io.BytesIO(file_bytes))\n            image_format = pil_img.format.lower()\n        else:\n            image_format = content_type.split('/')[-1]\n        return file_bytes, image_format\n\n\nasync def qq_image_url_to_base64(image_url: str) -> typing.Tuple[str, str]:\n    \"\"\"[弃用]将QQ图片URL转为base64，并返回图片格式\n\n    Args:\n        image_url (str): QQ图片URL\n\n    Returns:\n        typing.Tuple[str, str]: base64编码和图片格式\n    \"\"\"\n    image_url, query = get_qq_image_downloadable_url(image_url)\n\n    # Flatten the query dictionary\n    query = {k: v[0] for k, v in query.items()}\n\n    file_bytes, image_format = await get_qq_image_bytes(image_url, query)\n\n    base64_str = base64.b64encode(file_bytes).decode()\n\n    return base64_str, image_format\n\n\nasync def extract_b64_and_format(image_base64_data: str) -> typing.Tuple[str, str]:\n    \"\"\"提取base64编码和图片格式\n\n    data:image/jpeg;base64,xxx\n    提取出base64编码和图片格式\n    \"\"\"\n    base64_str = image_base64_data.split(',')[-1]\n    image_format = image_base64_data.split(':')[-1].split(';')[0].split('/')[-1]\n    return base64_str, image_format\n\n\nasync def get_slack_image_to_base64(pic_url: str, bot_token: str):\n    headers = {'Authorization': f'Bearer {bot_token}'}\n    try:\n        session = httpclient.get_session()\n        async with session.get(pic_url, headers=headers) as resp:\n            mime_type = resp.headers.get('Content-Type', 'application/octet-stream')\n            file_bytes = await resp.read()\n            base64_str = base64.b64encode(file_bytes).decode('utf-8')\n        return f'data:{mime_type};base64,{base64_str}'\n    except Exception as e:\n        raise (e)\n"
  },
  {
    "path": "src/langbot/pkg/utils/importutil.py",
    "content": "import importlib\nimport importlib.resources\nimport os\nimport typing\n\n\ndef import_modules_in_pkg(pkg: typing.Any) -> None:\n    \"\"\"\n    导入一个包内的所有模块\n    Args:\n        pkg: 要导入的包对象\n    \"\"\"\n    pkg_path = os.path.dirname(pkg.__file__)\n    import_dir(pkg_path)\n\n\ndef import_modules_in_pkgs(pkgs: typing.List) -> None:\n    for pkg in pkgs:\n        import_modules_in_pkg(pkg)\n\n\ndef import_dot_style_dir(dot_sep_path: str):\n    sec = dot_sep_path.split('.')\n\n    return import_dir(os.path.join(*sec))\n\n\ndef import_dir(path: str, path_prefix: str = 'langbot.'):\n    for file in os.listdir(path):\n        if file.endswith('.py') and file != '__init__.py':\n            full_path = os.path.join(path, file)\n            rel_path = full_path.replace(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '')\n            rel_path = rel_path[1:]\n            rel_path = rel_path.replace('/', '.')[:-3]\n            rel_path = rel_path.replace('\\\\', '.')\n            importlib.import_module(f'{path_prefix}{rel_path}')\n\n\ndef read_resource_file(resource_path: str) -> str:\n    with importlib.resources.files('langbot').joinpath(resource_path).open('r', encoding='utf-8') as f:\n        return f.read()\n\n\ndef read_resource_file_bytes(resource_path: str) -> bytes:\n    return importlib.resources.files('langbot').joinpath(resource_path).read_bytes()\n\n\ndef list_resource_files(resource_path: str) -> list[str]:\n    return [f.name for f in importlib.resources.files('langbot').joinpath(resource_path).iterdir()]\n"
  },
  {
    "path": "src/langbot/pkg/utils/logcache.py",
    "content": "from __future__ import annotations\n\n\nLOG_PAGE_SIZE = 20\nMAX_CACHED_PAGES = 10\n\n\nclass LogPage:\n    \"\"\"日志页\"\"\"\n\n    number: int\n    \"\"\"页码\"\"\"\n\n    logs: list[str]\n\n    def __init__(self, number: int):\n        self.number = number\n        self.logs = []\n\n    def add_log(self, log: str) -> bool:\n        \"\"\"添加日志\n\n        Returns:\n            bool: 是否已满\n        \"\"\"\n        self.logs.append(log)\n        return len(self.logs) >= LOG_PAGE_SIZE\n\n\nclass LogCache:\n    \"\"\"由于 logger 是同步的，但实例中的数据库操作是异步的；\n    同时，持久化的日志信息已经写入文件了，故做一个缓存来为前端提供日志查询服务\"\"\"\n\n    log_pages: list[LogPage] = []\n    \"\"\"从前到后，越新的日志页越靠后\"\"\"\n\n    def __init__(self):\n        self.log_pages = []\n        self.log_pages.append(LogPage(number=0))\n\n    def add_log(self, log: str):\n        \"\"\"添加日志\"\"\"\n        if self.log_pages[-1].add_log(log):\n            self.log_pages.append(LogPage(number=self.log_pages[-1].number + 1))\n\n            if len(self.log_pages) > MAX_CACHED_PAGES:\n                self.log_pages.pop(0)\n\n    def get_log_by_pointer(\n        self,\n        start_page_number: int,\n        start_offset: int,\n    ) -> tuple[str, int, int]:\n        \"\"\"获取指定页码和偏移量的日志\"\"\"\n        final_logs_str = ''\n\n        for page in self.log_pages:\n            if page.number == start_page_number:\n                final_logs_str += '\\n'.join(page.logs[start_offset:])\n            elif page.number > start_page_number:\n                final_logs_str += '\\n'.join(page.logs)\n\n        return final_logs_str, page.number, len(page.logs)\n"
  },
  {
    "path": "src/langbot/pkg/utils/paths.py",
    "content": "\"\"\"Utility functions for finding package resources\"\"\"\n\nimport os\nfrom pathlib import Path\n\n\n_is_source_install = None\n\n\ndef _check_if_source_install() -> bool:\n    \"\"\"\n    Check if we're running from source directory or an installed package.\n    Cached to avoid repeated file I/O.\n    \"\"\"\n    global _is_source_install\n\n    if _is_source_install is not None:\n        return _is_source_install\n\n    # Check if main.py exists in current directory with LangBot marker\n    if os.path.exists('main.py'):\n        try:\n            with open('main.py', 'r', encoding='utf-8') as f:\n                # Only read first 500 chars to check for marker\n                content = f.read(500)\n                if 'LangBot/main.py' in content:\n                    _is_source_install = True\n                    return True\n        except (IOError, OSError, UnicodeDecodeError):\n            # If we can't read the file, assume not a source install\n            pass\n\n    _is_source_install = False\n    return False\n\n\ndef get_frontend_path() -> str:\n    \"\"\"\n    Get the path to the frontend build files.\n\n    Returns the path to web/out directory, handling both:\n    - Development mode: running from source directory\n    - Package mode: installed via pip/uvx\n    \"\"\"\n    # First, check if we're running from source directory\n    if _check_if_source_install() and os.path.exists('web/out'):\n        return 'web/out'\n\n    # Second, check current directory for web/out (in case user is in source dir)\n    if os.path.exists('web/out'):\n        return 'web/out'\n\n    # Third, find it relative to the package installation\n    # Get the directory where this file is located\n    # paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root\n    pkg_dir = Path(__file__).parent.parent.parent\n    frontend_path = pkg_dir / 'web' / 'out'\n    if frontend_path.exists():\n        return str(frontend_path)\n\n    # Return the default path (will be checked by caller)\n    return 'web/out'\n\n\ndef get_resource_path(resource: str) -> str:\n    \"\"\"\n    Get the path to a resource file.\n\n    Args:\n        resource: Relative path to resource (e.g., 'templates/config.yaml')\n\n    Returns:\n        Absolute path to the resource\n    \"\"\"\n    # First, check if resource exists in current directory (source install)\n    if _check_if_source_install() and os.path.exists(resource):\n        return resource\n\n    # Second, check current directory anyway\n    if os.path.exists(resource):\n        return resource\n\n    # Third, find it relative to package directory\n    # Get the directory where this file is located\n    # paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root\n    pkg_dir = Path(__file__).parent.parent.parent\n    resource_path = pkg_dir / resource\n    if resource_path.exists():\n        return str(resource_path)\n\n    # Return the original path\n    return resource\n"
  },
  {
    "path": "src/langbot/pkg/utils/pkgmgr.py",
    "content": "from pip._internal import main as pipmain\n\n\ndef install(package):\n    pipmain(['install', package])\n\n\ndef install_upgrade(package):\n    pipmain(\n        [\n            'install',\n            '--upgrade',\n            package,\n            '-i',\n            'https://pypi.tuna.tsinghua.edu.cn/simple',\n            '--trusted-host',\n            'pypi.tuna.tsinghua.edu.cn',\n        ]\n    )\n\n\ndef run_pip(params: list):\n    pipmain(params)\n\n\ndef install_requirements(file, extra_params: list = []):\n    pipmain(\n        [\n            'install',\n            '-r',\n            file,\n            '-i',\n            'https://pypi.tuna.tsinghua.edu.cn/simple',\n            '--trusted-host',\n            'pypi.tuna.tsinghua.edu.cn',\n        ]\n        + extra_params\n    )\n"
  },
  {
    "path": "src/langbot/pkg/utils/platform.py",
    "content": "import os\nimport sys\n\n\ndef get_platform() -> str:\n    \"\"\"获取当前平台\"\"\"\n    # 检查是不是在 docker 里\n\n    DOCKER_ENV = os.environ.get('DOCKER_ENV', 'false')\n\n    if os.path.exists('/.dockerenv') or DOCKER_ENV == 'true':\n        return 'docker'\n\n    return sys.platform\n\n\nstandalone_runtime = False\n\n\ndef use_websocket_to_connect_plugin_runtime() -> bool:\n    \"\"\"是否使用 websocket 连接插件运行时\"\"\"\n    return standalone_runtime\n"
  },
  {
    "path": "src/langbot/pkg/utils/proxy.py",
    "content": "from __future__ import annotations\n\nimport os\n\nfrom ..core import app\n\n\nclass ProxyManager:\n    \"\"\"代理管理器\"\"\"\n\n    ap: app.Application\n\n    forward_proxies: dict[str, str]\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n        self.forward_proxies = {}\n\n    async def initialize(self):\n        self.forward_proxies = {\n            'http://': os.getenv('HTTP_PROXY') or os.getenv('http_proxy'),\n            'https://': os.getenv('HTTPS_PROXY') or os.getenv('https_proxy'),\n        }\n\n        if 'http' in self.ap.instance_config.data['proxy'] and self.ap.instance_config.data['proxy']['http']:\n            self.forward_proxies['http://'] = self.ap.instance_config.data['proxy']['http']\n        if 'https' in self.ap.instance_config.data['proxy'] and self.ap.instance_config.data['proxy']['https']:\n            self.forward_proxies['https://'] = self.ap.instance_config.data['proxy']['https']\n\n        # 设置到环境变量\n        os.environ['HTTP_PROXY'] = self.forward_proxies['http://'] or ''\n        os.environ['HTTPS_PROXY'] = self.forward_proxies['https://'] or ''\n\n    def get_forward_proxies(self) -> dict:\n        return self.forward_proxies.copy()\n"
  },
  {
    "path": "src/langbot/pkg/utils/runner.py",
    "content": "from __future__ import annotations\n\nfrom urllib.parse import urlparse\n\n\nclass RunnerCategory:\n    LOCAL = 'local'\n    CLOUD = 'cloud'\n    UNKNOWN = 'unknown'\n\n\nCLOUD_DOMAINS = [\n    '.n8n.cloud',\n    '.n8n.io',\n    'api.dify.ai',\n    'cloud.dify.ai',\n    '.coze.com',\n    '.coze.cn',\n    'cloud.langflow.ai',\n    '.langflow.org',\n]\n\nLOCAL_PATTERNS = [\n    'localhost',\n    '127.0.0.1',\n    '0.0.0.0',\n    '192.168.',\n    '10.',\n    '172.16.',\n    '172.17.',\n    '172.18.',\n    '172.19.',\n    '172.20.',\n    '172.21.',\n    '172.22.',\n    '172.23.',\n    '172.24.',\n    '172.25.',\n    '172.26.',\n    '172.27.',\n    '172.28.',\n    '172.29.',\n    '172.30.',\n    '172.31.',\n]\n\n\ndef get_runner_category(runner_name: str, runner_url: str) -> str:\n    if not runner_url:\n        return RunnerCategory.UNKNOWN\n\n    try:\n        parsed_url = urlparse(runner_url)\n        host = parsed_url.hostname.lower() if parsed_url.hostname else ''\n    except Exception:\n        return RunnerCategory.UNKNOWN\n\n    for pattern in LOCAL_PATTERNS:\n        if host.startswith(pattern):\n            return RunnerCategory.LOCAL\n\n    for domain in CLOUD_DOMAINS:\n        if host.endswith(domain):\n            return RunnerCategory.CLOUD\n\n    return RunnerCategory.CLOUD\n\n\ndef get_runner_info(runner_name: str, runner_url: str) -> dict:\n    return {\n        'name': runner_name,\n        'url': runner_url,\n        'category': get_runner_category(runner_name, runner_url),\n    }\n\n\ndef is_cloud_runner(runner_name: str, runner_url: str) -> bool:\n    return get_runner_category(runner_name, runner_url) == RunnerCategory.CLOUD\n\n\ndef is_local_runner(runner_name: str, runner_url: str) -> bool:\n    return get_runner_category(runner_name, runner_url) == RunnerCategory.LOCAL\n\n\ndef extract_runner_url(runner_name: str, runner, pipeline_config: dict | None) -> str | None:\n    if not runner or not hasattr(runner, 'pipeline_config'):\n        return None\n\n    ai_config = pipeline_config.get('ai', {}) if pipeline_config else {}\n\n    if runner_name == 'dify-service-api':\n        return ai_config.get('dify-service-api', {}).get('base-url')\n    elif runner_name == 'n8n-service-api':\n        return ai_config.get('n8n-service-api', {}).get('webhook-url')\n    elif runner_name == 'coze-api':\n        return ai_config.get('coze-api', {}).get('api-base')\n    elif runner_name == 'langflow-api':\n        return ai_config.get('langflow-api', {}).get('base-url')\n\n    return None\n\n\ndef get_runner_category_from_runner(runner_name: str, runner, pipeline_config: dict | None) -> str:\n    runner_url = extract_runner_url(runner_name, runner, pipeline_config)\n    return get_runner_category(runner_name, runner_url)\n"
  },
  {
    "path": "src/langbot/pkg/utils/version.py",
    "content": "from __future__ import annotations\n\nimport os\nimport typing\nimport logging\n\nimport requests\n\nfrom ..core import app\nfrom . import constants\n\n\nclass VersionManager:\n    \"\"\"版本管理器\"\"\"\n\n    ap: app.Application\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        pass\n\n    def get_current_version(self) -> str:\n        current_tag = constants.semantic_version\n\n        return current_tag\n\n    async def get_release_list(self) -> list:\n        \"\"\"获取发行列表\"\"\"\n        try:\n            rls_list_resp = requests.get(\n                url='https://api.github.com/repos/langbot-app/LangBot/releases',\n                proxies=self.ap.proxy_mgr.get_forward_proxies(),\n                timeout=5,\n            )\n            rls_list_resp.raise_for_status()  # 检查请求是否成功\n            rls_list = rls_list_resp.json()\n            return rls_list\n        except Exception as e:\n            self.ap.logger.warning(f'获取发行列表失败: {e}')\n            pass\n        return []\n\n    async def update_all(self):\n        \"\"\"检查更新并下载源码\"\"\"\n\n        current_tag = self.get_current_version()\n\n        rls_list = await self.get_release_list()\n\n        latest_rls = {}\n        rls_notes = []\n        latest_tag_name = ''\n        for rls in rls_list:\n            rls_notes.append(rls['name'])  # 使用发行名称作为note\n            if latest_tag_name == '':\n                latest_tag_name = rls['tag_name']\n\n            if rls['tag_name'] == current_tag:\n                break\n\n            if latest_rls == {}:\n                latest_rls = rls\n        self.ap.logger.info('更新日志: {}'.format(rls_notes))\n\n        if latest_rls == {} and not self.is_newer(latest_tag_name, current_tag):  # 没有新版本\n            return False\n\n        # 下载最新版本的zip到temp目录\n        self.ap.logger.info('开始下载最新版本: {}'.format(latest_rls['zipball_url']))\n\n        zip_url = latest_rls['zipball_url']\n        zip_resp = requests.get(url=zip_url, proxies=self.ap.proxy_mgr.get_forward_proxies())\n        zip_data = zip_resp.content\n\n        # 检查temp/updater目录\n        if not os.path.exists('temp'):\n            os.mkdir('temp')\n        if not os.path.exists('temp/updater'):\n            os.mkdir('temp/updater')\n        with open('temp/updater/{}.zip'.format(latest_rls['tag_name']), 'wb') as f:\n            f.write(zip_data)\n\n        self.ap.logger.info('下载最新版本完成: {}'.format('temp/updater/{}.zip'.format(latest_rls['tag_name'])))\n\n        # 解压zip到temp/updater/<tag_name>/\n        import zipfile\n\n        # 检查目标文件夹\n        if os.path.exists('temp/updater/{}'.format(latest_rls['tag_name'])):\n            import shutil\n\n            shutil.rmtree('temp/updater/{}'.format(latest_rls['tag_name']))\n        os.mkdir('temp/updater/{}'.format(latest_rls['tag_name']))\n        with zipfile.ZipFile('temp/updater/{}.zip'.format(latest_rls['tag_name']), 'r') as zip_ref:\n            zip_ref.extractall('temp/updater/{}'.format(latest_rls['tag_name']))\n\n        # 覆盖源码\n        source_root = ''\n        # 找到temp/updater/<tag_name>/中的第一个子目录路径\n        for root, dirs, files in os.walk('temp/updater/{}'.format(latest_rls['tag_name'])):\n            if root != 'temp/updater/{}'.format(latest_rls['tag_name']):\n                source_root = root\n                break\n\n        # 覆盖源码\n        import shutil\n\n        for root, dirs, files in os.walk(source_root):\n            # 覆盖所有子文件子目录\n            for file in files:\n                src = os.path.join(root, file)\n                dst = src.replace(source_root, '.')\n                if os.path.exists(dst):\n                    os.remove(dst)\n\n                # 检查目标文件夹是否存在\n                if not os.path.exists(os.path.dirname(dst)):\n                    os.makedirs(os.path.dirname(dst))\n                # 检查目标文件是否存在\n                if not os.path.exists(dst):\n                    # 创建目标文件\n                    open(dst, 'w').close()\n\n                shutil.copy(src, dst)\n\n        # 把current_tag写入文件\n        current_tag = latest_rls['tag_name']\n        with open('current_tag', 'w') as f:\n            f.write(current_tag)\n\n        # TODO statistics\n\n    async def is_new_version_available(self) -> bool:\n        \"\"\"检查是否有新版本\"\"\"\n        # 从github获取release列表\n        rls_list = await self.get_release_list()\n        if rls_list is None:\n            return False\n\n        # 获取当前版本\n        current_tag = self.get_current_version()\n\n        # 检查是否有新版本\n        latest_tag_name = ''\n        for rls in rls_list:\n            if latest_tag_name == '':\n                latest_tag_name = rls['tag_name']\n                break\n\n        return self.is_newer(latest_tag_name, current_tag)\n\n    def is_newer(self, new_tag: str, old_tag: str):\n        \"\"\"判断版本是否更新，忽略第四位版本和第一位版本\"\"\"\n        if new_tag == old_tag:\n            return False\n\n        new_tag = new_tag.split('.')\n        old_tag = old_tag.split('.')\n\n        # 判断主版本是否相同\n        if new_tag[0] != old_tag[0]:\n            return False\n\n        if len(new_tag) < 4:\n            return True\n\n        # 合成前三段，判断是否相同\n        new_tag = '.'.join(new_tag[:3])\n        old_tag = '.'.join(old_tag[:3])\n\n        return new_tag != old_tag\n\n    def compare_version_str(v0: str, v1: str) -> int:\n        \"\"\"比较两个版本号\"\"\"\n\n        # 删除版本号前的v\n        if v0.startswith('v'):\n            v0 = v0[1:]\n        if v1.startswith('v'):\n            v1 = v1[1:]\n\n        v0: list = v0.split('.')\n        v1: list = v1.split('.')\n\n        # 如果两个版本号节数不同，把短的后面用0补齐\n        if len(v0) < len(v1):\n            v0.extend(['0'] * (len(v1) - len(v0)))\n        elif len(v0) > len(v1):\n            v1.extend(['0'] * (len(v0) - len(v1)))\n\n        # 从高位向低位比较\n        for i in range(len(v0)):\n            if int(v0[i]) > int(v1[i]):\n                return 1\n            elif int(v0[i]) < int(v1[i]):\n                return -1\n\n        return 0\n\n    async def show_version_update(self) -> typing.Tuple[str, int]:\n        try:\n            if await self.ap.ver_mgr.is_new_version_available():\n                return (\n                    'New version available:\\n有新版本可用，根据文档更新: \\nhttps://docs.langbot.app/zh/deploy/update.html',\n                    logging.INFO,\n                )\n\n        except Exception as e:\n            return f'Error checking version update: {e}', logging.WARNING\n"
  },
  {
    "path": "src/langbot/pkg/vector/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/pkg/vector/filter_utils.py",
    "content": "\"\"\"Shared utilities for metadata filter handling across VDB backends.\n\nCanonical filter format (Chroma-style ``where`` syntax):\n\n    {\"file_id\": \"abc\"}                      # implicit $eq\n    {\"file_id\": {\"$eq\": \"abc\"}}             # explicit $eq\n    {\"created_at\": {\"$gte\": 1700000000}}    # comparison\n    {\"file_type\": {\"$in\": [\"pdf\", \"docx\"]}} # in-list\n\nMultiple top-level keys are AND-ed.  Supported operators:\n``$eq``, ``$ne``, ``$gt``, ``$gte``, ``$lt``, ``$lte``, ``$in``, ``$nin``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom typing import Any\n\nSUPPORTED_OPS = frozenset({'$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin'})\n\nlogger = logging.getLogger(__name__)\n\n\ndef normalize_filter(\n    raw: dict[str, Any] | None,\n) -> list[tuple[str, str, Any]]:\n    \"\"\"Parse a canonical filter dict into ``[(field, op, value)]`` triples.\n\n    Returns an empty list when *raw* is ``None`` or empty.\n\n    Raises ``ValueError`` on unsupported operators or malformed entries.\n    \"\"\"\n    if not raw:\n        return []\n\n    triples: list[tuple[str, str, Any]] = []\n    for field, condition in raw.items():\n        if isinstance(condition, dict):\n            for op, value in condition.items():\n                if op not in SUPPORTED_OPS:\n                    raise ValueError(f'Unsupported filter operator: {op}')\n                triples.append((field, op, value))\n        else:\n            # Bare value -> implicit $eq\n            triples.append((field, '$eq', condition))\n    return triples\n\n\ndef strip_unsupported_fields(\n    triples: list[tuple[str, str, Any]],\n    supported_fields: set[str],\n    field_aliases: dict[str, str] | None = None,\n) -> list[tuple[str, str, Any]]:\n    \"\"\"Return only triples whose field is in *supported_fields*.\n\n    If *field_aliases* is provided, aliased field names are mapped to the\n    canonical backend name before the support check.  For example,\n    ``{'uuid': 'chunk_uuid'}`` allows callers to use ``uuid`` which is\n    transparently rewritten to ``chunk_uuid``.\n\n    Dropped fields are logged at WARNING level so the caller knows they were\n    silently ignored (useful for Milvus / pgvector which only store a fixed\n    schema).\n    \"\"\"\n    aliases = field_aliases or {}\n    kept: list[tuple[str, str, Any]] = []\n    for field, op, value in triples:\n        resolved = aliases.get(field, field)\n        if resolved in supported_fields:\n            kept.append((resolved, op, value))\n        else:\n            logger.warning(\n                'Filter field %r is not supported by this backend and will be ignored (supported: %s)',\n                field,\n                ', '.join(sorted(supported_fields)),\n            )\n    return kept\n"
  },
  {
    "path": "src/langbot/pkg/vector/mgr.py",
    "content": "from __future__ import annotations\n\nfrom ..core import app\nfrom .vdb import VectorDatabase, SearchType\nfrom .vdbs.chroma import ChromaVectorDatabase\nfrom .vdbs.qdrant import QdrantVectorDatabase\nfrom .vdbs.seekdb import SeekDBVectorDatabase\nfrom .vdbs.milvus import MilvusVectorDatabase\nfrom .vdbs.pgvector_db import PgVectorDatabase\n\n\nclass VectorDBManager:\n    ap: app.Application\n    vector_db: VectorDatabase = None\n\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n\n    async def initialize(self):\n        kb_config = self.ap.instance_config.data.get('vdb')\n        if kb_config:\n            vdb_type = kb_config.get('use')\n\n            if vdb_type == 'chroma':\n                self.vector_db = ChromaVectorDatabase(self.ap)\n                self.ap.logger.info('Initialized Chroma vector database backend.')\n\n            elif vdb_type == 'qdrant':\n                self.vector_db = QdrantVectorDatabase(self.ap)\n                self.ap.logger.info('Initialized Qdrant vector database backend.')\n            elif vdb_type == 'seekdb':\n                self.vector_db = SeekDBVectorDatabase(self.ap)\n                self.ap.logger.info('Initialized SeekDB vector database backend.')\n\n            elif vdb_type == 'milvus':\n                # Get Milvus configuration\n                milvus_config = kb_config.get('milvus', {})\n                uri = milvus_config.get('uri', './data/milvus.db')\n                token = milvus_config.get('token')\n                db_name = milvus_config.get('db_name', 'default')\n                self.vector_db = MilvusVectorDatabase(self.ap, uri=uri, token=token, db_name=db_name)\n                self.ap.logger.info('Initialized Milvus vector database backend.')\n\n            elif vdb_type == 'pgvector':\n                # Get pgvector configuration\n                pgvector_config = kb_config.get('pgvector', {})\n                connection_string = pgvector_config.get('connection_string')\n                if connection_string:\n                    self.vector_db = PgVectorDatabase(self.ap, connection_string=connection_string)\n                else:\n                    # Use individual parameters\n                    host = pgvector_config.get('host', 'localhost')\n                    port = pgvector_config.get('port', 5432)\n                    database = pgvector_config.get('database', 'langbot')\n                    user = pgvector_config.get('user', 'postgres')\n                    password = pgvector_config.get('password', 'postgres')\n                    self.vector_db = PgVectorDatabase(\n                        self.ap, host=host, port=port, database=database, user=user, password=password\n                    )\n                self.ap.logger.info('Initialized pgvector database backend.')\n\n            else:\n                self.vector_db = ChromaVectorDatabase(self.ap)\n                self.ap.logger.warning('No valid vector database backend configured, defaulting to Chroma.')\n        else:\n            self.vector_db = ChromaVectorDatabase(self.ap)\n            self.ap.logger.warning('No vector database backend configured, defaulting to Chroma.')\n\n    def get_supported_search_types(self) -> list[str]:\n        \"\"\"Return the search types supported by the current VDB backend.\"\"\"\n        if self.vector_db is None:\n            return [SearchType.VECTOR.value]\n        return [st.value for st in self.vector_db.supported_search_types()]\n\n    async def upsert(\n        self,\n        collection_name: str,\n        vectors: list[list[float]],\n        ids: list[str],\n        metadata: list[dict] | None = None,\n        documents: list[str] | None = None,\n    ):\n        \"\"\"Proxy: Upsert vectors\"\"\"\n        await self.vector_db.add_embeddings(\n            collection=collection_name,\n            ids=ids,\n            embeddings_list=vectors,\n            metadatas=metadata or [{} for _ in vectors],\n            documents=documents,\n        )\n\n    async def search(\n        self,\n        collection_name: str,\n        query_vector: list[float],\n        limit: int,\n        filter: dict | None = None,\n        search_type: str = 'vector',\n        query_text: str = '',\n    ) -> list[dict]:\n        \"\"\"Proxy: Search vectors.\n\n        Returns a list of dicts with keys: 'id', 'distance', 'metadata'.\n        The underlying VectorDatabase.search returns Chroma-style format:\n        { 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] }\n        \"\"\"\n        results = await self.vector_db.search(\n            collection=collection_name,\n            query_embedding=query_vector,\n            k=limit,\n            search_type=search_type,\n            query_text=query_text,\n            filter=filter,\n        )\n\n        if not results or 'ids' not in results or not results['ids']:\n            return []\n\n        # Flatten nested lists (Chroma returns batch-style: list of lists)\n        raw_ids = results['ids']\n        raw_dists = results.get('distances', [])\n        raw_metas = results.get('metadatas', [])\n\n        r_ids = raw_ids[0] if raw_ids and isinstance(raw_ids[0], list) else raw_ids\n        r_dists = raw_dists[0] if raw_dists and isinstance(raw_dists[0], list) else raw_dists\n        r_metas = raw_metas[0] if raw_metas and isinstance(raw_metas[0], list) else raw_metas\n\n        parsed_results = []\n        for i, id_val in enumerate(r_ids):\n            parsed_results.append(\n                {\n                    'id': id_val,\n                    'distance': r_dists[i] if r_dists and i < len(r_dists) else 0.0,\n                    'metadata': r_metas[i] if r_metas and i < len(r_metas) else {},\n                }\n            )\n\n        return parsed_results\n\n    async def delete_by_file_id(self, collection_name: str, file_ids: list[str]):\n        \"\"\"Proxy: Delete vectors by file_id (metadata-level identifier).\n\n        This delegates to VectorDatabase.delete_by_file_id which removes\n        all vectors associated with the given file IDs.\n        \"\"\"\n        for file_id in file_ids:\n            await self.vector_db.delete_by_file_id(collection_name, file_id)\n\n    async def delete_collection(self, collection_name: str):\n        \"\"\"Proxy: Delete an entire collection.\"\"\"\n        await self.vector_db.delete_collection(collection_name)\n\n    async def delete_by_filter(self, collection_name: str, filter: dict) -> int:\n        \"\"\"Proxy: Delete vectors by metadata filter.\n\n        Returns:\n            Number of deleted vectors (best-effort; some backends return 0).\n        \"\"\"\n        return await self.vector_db.delete_by_filter(collection_name, filter)\n\n    async def list_by_filter(\n        self,\n        collection_name: str,\n        filter: dict | None = None,\n        limit: int = 20,\n        offset: int = 0,\n    ) -> tuple[list[dict], int]:\n        \"\"\"Proxy: List vectors by metadata filter with pagination.\n\n        Returns:\n            Tuple of (items, total).\n        \"\"\"\n        return await self.vector_db.list_by_filter(collection_name, filter, limit, offset)\n"
  },
  {
    "path": "src/langbot/pkg/vector/vdb.py",
    "content": "from __future__ import annotations\nimport abc\nimport enum\nfrom typing import Any, Dict\nimport numpy as np\n\n\nclass SearchType(str, enum.Enum):\n    \"\"\"Supported search types for vector databases.\"\"\"\n\n    VECTOR = 'vector'\n    FULL_TEXT = 'full_text'\n    HYBRID = 'hybrid'\n\n\nclass VectorDatabase(abc.ABC):\n    @classmethod\n    def supported_search_types(cls) -> list[SearchType]:\n        \"\"\"Return the search types supported by this VDB backend.\n\n        Default: vector search only. Override in subclasses that support\n        full-text or hybrid search.\n        \"\"\"\n        return [SearchType.VECTOR]\n\n    @abc.abstractmethod\n    async def add_embeddings(\n        self,\n        collection: str,\n        ids: list[str],\n        embeddings_list: list[list[float]],\n        metadatas: list[dict[str, Any]],\n        documents: list[str] | None = None,\n    ) -> None:\n        \"\"\"Add vector data to the specified collection.\n\n        Args:\n            collection: Collection name.\n            ids: Unique IDs for each vector.\n            embeddings_list: List of embedding vectors.\n            metadatas: List of metadata dicts.\n            documents: Optional raw text documents. Required for full-text\n                and hybrid search in backends that support them.\n        \"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def search(\n        self,\n        collection: str,\n        query_embedding: np.ndarray,\n        k: int = 5,\n        search_type: str = 'vector',\n        query_text: str = '',\n        filter: dict[str, Any] | None = None,\n    ) -> Dict[str, Any]:\n        \"\"\"Search for the most similar vectors in the specified collection.\n\n        Args:\n            collection: Collection name.\n            query_embedding: Query vector for similarity search.\n            k: Number of results to return.\n            search_type: One of 'vector', 'full_text', 'hybrid'.\n            query_text: Raw query text, used for full_text and hybrid search.\n            filter: Optional metadata filters using Chroma-style ``where``\n                syntax.  Multiple top-level keys are AND-ed.  Supported\n                operators: ``$eq``, ``$ne``, ``$gt``, ``$gte``, ``$lt``,\n                ``$lte``, ``$in``, ``$nin``.  Example::\n\n                    {\"file_id\": \"abc\"}\n                    {\"created_at\": {\"$gte\": 1700000000}}\n                    {\"file_type\": {\"$in\": [\"pdf\", \"docx\"]}}\n        \"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def delete_by_file_id(self, collection: str, file_id: str) -> None:\n        \"\"\"Delete vectors from the specified collection by file_id.\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int:\n        \"\"\"Delete vectors matching the given metadata filter.\n\n        Args:\n            collection: Collection name.\n            filter: Metadata filter dict in canonical format (see ``search``).\n\n        Returns:\n            Number of deleted vectors (best-effort; backends that cannot\n            report an exact count may return 0).\n        \"\"\"\n        pass\n\n    async def list_by_filter(\n        self,\n        collection: str,\n        filter: dict[str, Any] | None = None,\n        limit: int = 20,\n        offset: int = 0,\n    ) -> tuple[list[dict[str, Any]], int]:\n        \"\"\"List vectors matching the given metadata filter with pagination.\n\n        Args:\n            collection: Collection name.\n            filter: Optional metadata filter dict in canonical format.\n            limit: Maximum number of items to return.\n            offset: Number of items to skip.\n\n        Returns:\n            Tuple of (items, total) where items is a list of dicts with\n            keys 'id', 'document', 'metadata', and total is the best-effort\n            count of all matching vectors (-1 if unknown).\n        \"\"\"\n        return [], -1\n\n    @abc.abstractmethod\n    async def get_or_create_collection(self, collection: str):\n        \"\"\"Get or create collection.\"\"\"\n        pass\n\n    @abc.abstractmethod\n    async def delete_collection(self, collection: str):\n        \"\"\"Delete collection.\"\"\"\n        pass\n"
  },
  {
    "path": "src/langbot/pkg/vector/vdbs/__init__.py",
    "content": "\"\"\"Vector database implementations for LangBot.\"\"\"\n\nfrom .chroma import ChromaVectorDatabase\nfrom .qdrant import QdrantVectorDatabase\nfrom .seekdb import SeekDBVectorDatabase\n\n__all__ = ['ChromaVectorDatabase', 'QdrantVectorDatabase', 'SeekDBVectorDatabase']\n"
  },
  {
    "path": "src/langbot/pkg/vector/vdbs/chroma.py",
    "content": "from __future__ import annotations\nimport asyncio\nfrom typing import Any\nfrom chromadb import PersistentClient\nfrom langbot.pkg.vector.vdb import VectorDatabase, SearchType\nfrom langbot.pkg.core import app\nimport chromadb\nimport chromadb.errors\n\n# RRF smoothing constant (standard value from the literature)\n_RRF_K = 60\n\n\nclass ChromaVectorDatabase(VectorDatabase):\n    def __init__(self, ap: app.Application, base_path: str = './data/chroma'):\n        self.ap = ap\n        self.client = PersistentClient(path=base_path)\n        self._collections = {}\n\n    @classmethod\n    def supported_search_types(cls) -> list[SearchType]:\n        return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID]\n\n    async def get_or_create_collection(self, collection: str) -> chromadb.Collection:\n        if collection not in self._collections:\n            self._collections[collection] = await asyncio.to_thread(\n                self.client.get_or_create_collection, name=collection\n            )\n            self.ap.logger.info(f\"Chroma collection '{collection}' accessed/created.\")\n        return self._collections[collection]\n\n    async def add_embeddings(\n        self,\n        collection: str,\n        ids: list[str],\n        embeddings_list: list[list[float]],\n        metadatas: list[dict[str, Any]],\n        documents: list[str] | None = None,\n    ) -> None:\n        col = await self.get_or_create_collection(collection)\n        kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas)\n        if documents is not None:\n            kwargs['documents'] = documents\n        await asyncio.to_thread(col.upsert, **kwargs)\n        self.ap.logger.info(f\"Upserted {len(ids)} embeddings to Chroma collection '{collection}'.\")\n\n    async def search(\n        self,\n        collection: str,\n        query_embedding: list[float],\n        k: int = 5,\n        search_type: str = 'vector',\n        query_text: str = '',\n        filter: dict[str, Any] | None = None,\n    ) -> dict[str, Any]:\n        col = await self.get_or_create_collection(collection)\n\n        if search_type == SearchType.FULL_TEXT:\n            return await self._full_text_search(col, collection, k, query_text, filter)\n        elif search_type == SearchType.HYBRID:\n            return await self._hybrid_search(col, collection, query_embedding, k, query_text, filter)\n\n        # Default: vector search\n        return await self._vector_search(col, collection, query_embedding, k, filter)\n\n    async def _vector_search(\n        self,\n        col: chromadb.Collection,\n        collection: str,\n        query_embedding: list[float],\n        k: int,\n        filter: dict[str, Any] | None,\n    ) -> dict[str, Any]:\n        query_kwargs: dict[str, Any] = dict(\n            query_embeddings=query_embedding,\n            n_results=k,\n            include=['metadatas', 'distances', 'documents'],\n        )\n        if filter:\n            query_kwargs['where'] = filter\n        results = await asyncio.to_thread(col.query, **query_kwargs)\n        self.ap.logger.info(\n            f\"Chroma vector search in '{collection}' returned {len(results.get('ids', [[]])[0])} results.\"\n        )\n        return results\n\n    async def _full_text_search(\n        self,\n        col: chromadb.Collection,\n        collection: str,\n        k: int,\n        query_text: str,\n        filter: dict[str, Any] | None,\n    ) -> dict[str, Any]:\n        if not query_text:\n            return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}\n\n        get_kwargs: dict[str, Any] = dict(\n            where_document={'$contains': query_text},\n            include=['metadatas', 'documents'],\n            limit=k,\n        )\n        if filter:\n            get_kwargs['where'] = filter\n        results = await asyncio.to_thread(col.get, **get_kwargs)\n\n        # col.get returns flat lists; wrap into column-major format.\n        # Distances are all 0.0 because Chroma's local $contains is a boolean\n        # filter with no relevance scoring.  Chroma's BM25 sparse embedding\n        # function (ChromaBm25EmbeddingFunction) can generate scored sparse\n        # vectors, but sparse vector *indexing* is only available on Chroma\n        # Cloud, not locally.  For ranked results, use hybrid mode or apply a\n        # reranker in a downstream stage.\n        ids = results.get('ids', [])\n        metadatas = results.get('metadatas', []) or [None] * len(ids)\n        documents = results.get('documents', []) or [None] * len(ids)\n        distances = [0.0] * len(ids)\n\n        self.ap.logger.info(f\"Chroma full-text search in '{collection}' returned {len(ids)} results.\")\n        return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances], 'documents': [documents]}\n\n    async def _hybrid_search(\n        self,\n        col: chromadb.Collection,\n        collection: str,\n        query_embedding: list[float],\n        k: int,\n        query_text: str,\n        filter: dict[str, Any] | None,\n    ) -> dict[str, Any]:\n        # Fall back to pure vector search when no text is provided\n        if not query_text:\n            return await self._vector_search(col, collection, query_embedding, k, filter)\n\n        # Run vector search and full-text search in parallel\n        vector_task = self._vector_search(col, collection, query_embedding, k, filter)\n        text_task = self._full_text_search(col, collection, k, query_text, filter)\n        vector_results, text_results = await asyncio.gather(vector_task, text_task)\n\n        vector_ids = vector_results.get('ids', [[]])[0]\n        text_ids = text_results.get('ids', [[]])[0]\n\n        if not vector_ids and not text_ids:\n            return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}\n\n        # RRF fusion\n        fused = self._rrf_fuse([vector_ids, text_ids], k)\n        if not fused:\n            return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}\n\n        fused_ids = [doc_id for doc_id, _ in fused]\n\n        # Fetch full metadata and documents for fused results\n        fetched = await asyncio.to_thread(col.get, ids=fused_ids, include=['metadatas', 'documents'])\n\n        # col.get returns results in arbitrary order; re-order to match fused ranking\n        fetched_map: dict[str, tuple] = {}\n        for i, fid in enumerate(fetched.get('ids', [])):\n            meta = (fetched.get('metadatas') or [None] * len(fetched['ids']))[i]\n            doc = (fetched.get('documents') or [None] * len(fetched['ids']))[i]\n            fetched_map[fid] = (meta, doc)\n\n        ordered_ids = []\n        ordered_metas = []\n        ordered_docs = []\n        ordered_dists = []\n\n        # Normalize RRF scores to 0~1 distances via min-max scaling.\n        # Raw RRF scores are tiny (e.g. 0.016~0.033 with k=60) so a naive\n        # ``1 - score`` would compress all distances into a narrow 0.96~0.98\n        # band with almost no discriminative power.  Min-max normalization\n        # spreads them across the full 0~1 range (0.0 = best match).\n        max_score = fused[0][1]\n        min_score = fused[-1][1]\n        score_range = max_score - min_score\n\n        for doc_id, score in fused:\n            if doc_id in fetched_map:\n                meta, doc = fetched_map[doc_id]\n                ordered_ids.append(doc_id)\n                ordered_metas.append(meta)\n                ordered_docs.append(doc)\n                if score_range > 0:\n                    ordered_dists.append(1.0 - (score - min_score) / score_range)\n                else:\n                    ordered_dists.append(0.0)\n\n        self.ap.logger.info(\n            f\"Chroma hybrid search in '{collection}' returned {len(ordered_ids)} results \"\n            f'(vector={len(vector_ids)}, text={len(text_ids)}).'\n        )\n        return {\n            'ids': [ordered_ids],\n            'metadatas': [ordered_metas],\n            'distances': [ordered_dists],\n            'documents': [ordered_docs],\n        }\n\n    @staticmethod\n    def _rrf_fuse(result_lists: list[list[str]], k: int) -> list[tuple[str, float]]:\n        \"\"\"Reciprocal Rank Fusion over multiple ranked ID lists.\n\n        Returns a list of (doc_id, rrf_score) sorted by descending score,\n        truncated to *k* entries.\n        \"\"\"\n        scores: dict[str, float] = {}\n        for ranked_ids in result_lists:\n            for rank, doc_id in enumerate(ranked_ids):\n                scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_K + rank + 1)\n        sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)\n        return sorted_results[:k]\n\n    async def delete_by_file_id(self, collection: str, file_id: str) -> None:\n        col = await self.get_or_create_collection(collection)\n        await asyncio.to_thread(col.delete, where={'file_id': file_id})\n        self.ap.logger.info(f\"Deleted embeddings from Chroma collection '{collection}' with file_id: {file_id}\")\n\n    async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int:\n        col = await self.get_or_create_collection(collection)\n        await asyncio.to_thread(col.delete, where=filter)\n        self.ap.logger.info(f\"Deleted embeddings from Chroma collection '{collection}' by filter\")\n        return 0  # Chroma delete does not return a count\n\n    async def list_by_filter(\n        self,\n        collection: str,\n        filter: dict[str, Any] | None = None,\n        limit: int = 20,\n        offset: int = 0,\n    ) -> tuple[list[dict[str, Any]], int]:\n        col = await self.get_or_create_collection(collection)\n        get_kwargs: dict[str, Any] = dict(\n            include=['metadatas', 'documents'],\n            limit=limit,\n            offset=offset,\n        )\n        if filter:\n            get_kwargs['where'] = filter\n        results = await asyncio.to_thread(col.get, **get_kwargs)\n\n        ids = results.get('ids', [])\n        metadatas = results.get('metadatas', []) or [None] * len(ids)\n        documents = results.get('documents', []) or [None] * len(ids)\n\n        items = []\n        for i, vid in enumerate(ids):\n            items.append(\n                {\n                    'id': vid,\n                    'document': documents[i] if i < len(documents) else None,\n                    'metadata': metadatas[i] if i < len(metadatas) else {},\n                }\n            )\n\n        # Chroma col.count() gives total in collection; filtered count not available\n        total = await asyncio.to_thread(col.count) if not filter else -1\n        return items, total\n\n    async def delete_collection(self, collection: str):\n        if collection in self._collections:\n            del self._collections[collection]\n\n        try:\n            await asyncio.to_thread(self.client.delete_collection, name=collection)\n        except chromadb.errors.NotFoundError:\n            self.ap.logger.warning(f\"Chroma collection '{collection}' not found.\")\n            return\n        self.ap.logger.info(f\"Chroma collection '{collection}' deleted.\")\n"
  },
  {
    "path": "src/langbot/pkg/vector/vdbs/milvus.py",
    "content": "from __future__ import annotations\nimport asyncio\nfrom typing import Any, Dict\nfrom pymilvus import MilvusClient, DataType, CollectionSchema, FieldSchema\nfrom pymilvus.milvus_client.index import IndexParams\nfrom langbot.pkg.vector.vdb import VectorDatabase\nfrom langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields\nfrom langbot.pkg.core import app\n\n# Milvus schema only stores these metadata fields; filter on other fields is\n# silently dropped with a warning.\n_MILVUS_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}\n\n# Callers use canonical metadata key 'uuid' but Milvus stores it as 'chunk_uuid'.\n_MILVUS_FIELD_ALIASES = {'uuid': 'chunk_uuid'}\n\n\ndef _build_milvus_expr(filter_dict: dict[str, Any]) -> str:\n    \"\"\"Translate canonical filter dict into a Milvus boolean expression string.\"\"\"\n    triples = normalize_filter(filter_dict)\n    triples = strip_unsupported_fields(triples, _MILVUS_SUPPORTED_FIELDS, _MILVUS_FIELD_ALIASES)\n    if not triples:\n        return ''\n\n    parts: list[str] = []\n    for field, op, value in triples:\n        if op == '$eq':\n            parts.append(f'{field} == {_milvus_literal(value)}')\n        elif op == '$ne':\n            parts.append(f'{field} != {_milvus_literal(value)}')\n        elif op == '$gt':\n            parts.append(f'{field} > {_milvus_literal(value)}')\n        elif op == '$gte':\n            parts.append(f'{field} >= {_milvus_literal(value)}')\n        elif op == '$lt':\n            parts.append(f'{field} < {_milvus_literal(value)}')\n        elif op == '$lte':\n            parts.append(f'{field} <= {_milvus_literal(value)}')\n        elif op == '$in':\n            items = ', '.join(_milvus_literal(v) for v in value)\n            parts.append(f'{field} in [{items}]')\n        elif op == '$nin':\n            items = ', '.join(_milvus_literal(v) for v in value)\n            parts.append(f'{field} not in [{items}]')\n    return ' and '.join(parts)\n\n\ndef _milvus_literal(value: Any) -> str:\n    \"\"\"Format a Python value as a Milvus expression literal.\"\"\"\n    if isinstance(value, str):\n        escaped = value.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')\n        return f'\"{escaped}\"'\n    return str(value)\n\n\nclass MilvusVectorDatabase(VectorDatabase):\n    \"\"\"Milvus vector database implementation\"\"\"\n\n    def __init__(self, ap: app.Application, uri: str = 'milvus.db', token: str = None, db_name: str = None):\n        \"\"\"Initialize Milvus vector database\n\n        Args:\n            ap: Application instance\n            uri: Milvus connection URI. For local file: \"milvus.db\"\n                 For remote server: \"http://localhost:19530\"\n            token: Optional authentication token for remote connections\n        \"\"\"\n        self.ap = ap\n        self.uri = uri\n        self.token = token\n        self.db_name = db_name\n        self.client = None\n        self._collections: set[str] = set()\n        self._initialize_client()\n\n    def _initialize_client(self):\n        \"\"\"Initialize Milvus client connection\"\"\"\n        try:\n            if self.token:\n                self.client = MilvusClient(uri=self.uri, token=self.token, db_name=self.db_name)\n            else:\n                self.client = MilvusClient(uri=self.uri, db_name=self.db_name)\n            self.ap.logger.info(f'Connected to Milvus at {self.uri}')\n        except Exception as e:\n            self.ap.logger.error(f'Failed to connect to Milvus: {e}')\n            raise\n\n    @staticmethod\n    def _normalize_collection_name(collection: str) -> str:\n        \"\"\"Normalize collection name to comply with Milvus naming requirements.\n\n        Milvus requirements:\n        - First character must be an underscore or letter\n        - Can only contain numbers, letters and underscores\n\n        Args:\n            collection: Original collection name (e.g., UUID with hyphens)\n\n        Returns:\n            Normalized collection name that complies with Milvus requirements\n        \"\"\"\n        # Replace hyphens with underscores\n        normalized = collection.replace('-', '_')\n\n        # If first character is not a letter or underscore, prepend 'kb_'\n        if normalized and not (normalized[0].isalpha() or normalized[0] == '_'):\n            normalized = 'kb_' + normalized\n\n        return normalized\n\n    async def _ensure_vector_index(self, collection: str) -> None:\n        \"\"\"Ensure the vector field has an index.\n\n        Args:\n            collection: Normalized collection name\n        \"\"\"\n        index_params = IndexParams()\n        index_params.add_index(\n            field_name='vector',\n            index_type='AUTOINDEX',\n            metric_type='COSINE',\n        )\n        await asyncio.to_thread(self.client.create_index, collection_name=collection, index_params=index_params)\n\n    async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None):\n        \"\"\"Internal method to get or create a Milvus collection with proper configuration.\n\n        Args:\n            collection: Collection name (corresponds to knowledge base UUID)\n            vector_size: Dimension of the vectors (if None, defaults to 1536)\n        \"\"\"\n        # Normalize collection name for Milvus compatibility\n        collection = self._normalize_collection_name(collection)\n\n        if collection in self._collections:\n            return collection\n\n        # Check if collection exists\n        has_collection = await asyncio.to_thread(self.client.has_collection, collection_name=collection)\n\n        if not has_collection:\n            # Default dimension if not specified (for backward compatibility)\n            if vector_size is None:\n                vector_size = 1536\n\n            fields = [\n                FieldSchema(name='id', dtype=DataType.VARCHAR, is_primary=True, max_length=255),\n                FieldSchema(name='vector', dtype=DataType.FLOAT_VECTOR, dim=vector_size),\n                FieldSchema(name='text', dtype=DataType.VARCHAR, max_length=65535),\n                FieldSchema(name='file_id', dtype=DataType.VARCHAR, max_length=255),\n                FieldSchema(name='chunk_uuid', dtype=DataType.VARCHAR, max_length=255),\n            ]\n\n            schema = CollectionSchema(fields=fields, description='LangBot knowledge base vectors')\n\n            await asyncio.to_thread(\n                self.client.create_collection,\n                collection_name=collection,\n                schema=schema,\n                metric_type='COSINE',\n            )\n\n            await self._ensure_vector_index(collection)\n            self.ap.logger.info(\n                f\"Created Milvus collection '{collection}' with dimension={vector_size}, index=AUTOINDEX\"\n            )\n        else:\n            # Ensure index exists for existing collection\n            await self._ensure_index_if_missing(collection)\n            self.ap.logger.info(f\"Milvus collection '{collection}' already exists\")\n\n        self._collections.add(collection)\n        return collection\n\n    async def _ensure_index_if_missing(self, collection: str) -> None:\n        \"\"\"Check if index exists for collection and create if missing.\n\n        Args:\n            collection: Normalized collection name\n        \"\"\"\n        try:\n            indexes = await asyncio.to_thread(self.client.list_indexes, collection_name=collection)\n            if 'vector' not in indexes:\n                await self._ensure_vector_index(collection)\n                self.ap.logger.info(f\"Created index for existing Milvus collection '{collection}'\")\n        except Exception as e:\n            self.ap.logger.warning(f\"Could not verify/create index for collection '{collection}': {e}\")\n\n    async def get_or_create_collection(self, collection: str):\n        \"\"\"Get or create a Milvus collection (without vector size - will use default).\n\n        Args:\n            collection: Collection name (corresponds to knowledge base UUID)\n        \"\"\"\n        collection = self._normalize_collection_name(collection)\n        return await self._get_or_create_collection_internal(collection)\n\n    async def add_embeddings(\n        self,\n        collection: str,\n        ids: list[str],\n        embeddings_list: list[list[float]],\n        metadatas: list[dict[str, Any]],\n        documents: list[str] | None = None,\n    ) -> None:\n        \"\"\"Add vector embeddings to Milvus collection\n\n        Args:\n            collection: Collection name\n            ids: List of unique IDs for each vector\n            embeddings_list: List of embedding vectors\n            metadatas: List of metadata dictionaries for each vector\n        \"\"\"\n        collection = self._normalize_collection_name(collection)\n\n        if not embeddings_list:\n            return\n\n        # Ensure collection exists with correct dimension\n        vector_size = len(embeddings_list[0])\n        await self._get_or_create_collection_internal(collection, vector_size)\n\n        # Prepare data in Milvus format\n        data = []\n        for i, vector_id in enumerate(ids):\n            entry = {\n                'id': vector_id,\n                'vector': embeddings_list[i],\n            }\n            # Add metadata fields\n            if metadatas and i < len(metadatas):\n                metadata = metadatas[i]\n                # Add common metadata fields\n                if 'text' in metadata:\n                    entry['text'] = metadata['text']\n                if 'file_id' in metadata:\n                    entry['file_id'] = metadata['file_id']\n                if 'uuid' in metadata:\n                    entry['chunk_uuid'] = metadata['uuid']\n            data.append(entry)\n\n        # Insert data into Milvus\n        await asyncio.to_thread(self.client.insert, collection_name=collection, data=data)\n\n        # Load collection for searching (Milvus requires this)\n        await asyncio.to_thread(self.client.load_collection, collection_name=collection)\n\n        self.ap.logger.info(f\"Added {len(ids)} embeddings to Milvus collection '{collection}'\")\n\n    async def search(\n        self,\n        collection: str,\n        query_embedding: list[float],\n        k: int = 5,\n        search_type: str = 'vector',\n        query_text: str = '',\n        filter: dict[str, Any] | None = None,\n    ) -> Dict[str, Any]:\n        \"\"\"Search for similar vectors in Milvus collection\n\n        Args:\n            collection: Collection name\n            query_embedding: Query vector\n            k: Number of top results to return\n\n        Returns:\n            Dictionary with search results in Chroma-compatible format\n        \"\"\"\n        collection = self._normalize_collection_name(collection)\n        await self.get_or_create_collection(collection)\n\n        # Perform search\n        search_params = {'metric_type': 'COSINE', 'params': {}}\n\n        search_kwargs: dict[str, Any] = dict(\n            collection_name=collection,\n            data=[query_embedding],\n            limit=k,\n            search_params=search_params,\n            output_fields=['text', 'file_id', 'chunk_uuid'],\n        )\n        if filter:\n            expr = _build_milvus_expr(filter)\n            if expr:\n                search_kwargs['filter'] = expr\n\n        results = await asyncio.to_thread(self.client.search, **search_kwargs)\n\n        # Convert results to Chroma-compatible format\n        # Milvus returns: [[ {id, distance, entity: {...}} ]]\n        ids = []\n        distances = []\n        metadatas = []\n\n        if results and len(results) > 0:\n            for hit in results[0]:\n                ids.append(hit.get('id', ''))\n                distances.append(hit.get('distance', 0.0))\n\n                # Build metadata from entity fields\n                entity = hit.get('entity', {})\n                metadata = {}\n                if 'text' in entity:\n                    metadata['text'] = entity['text']\n                if 'file_id' in entity:\n                    metadata['file_id'] = entity['file_id']\n                if 'chunk_uuid' in entity:\n                    metadata['uuid'] = entity['chunk_uuid']\n                metadatas.append(metadata)\n\n        # Return in Chroma-compatible format (nested lists)\n        result = {'ids': [ids], 'distances': [distances], 'metadatas': [metadatas]}\n\n        self.ap.logger.info(f\"Milvus search in '{collection}' returned {len(ids)} results\")\n        return result\n\n    async def delete_by_file_id(self, collection: str, file_id: str) -> None:\n        \"\"\"Delete vectors from collection by file_id\n\n        Args:\n            collection: Collection name\n            file_id: File ID to filter deletion\n        \"\"\"\n        collection = self._normalize_collection_name(collection)\n        await self.get_or_create_collection(collection)\n\n        # Delete entities matching the file_id\n        await asyncio.to_thread(self.client.delete, collection_name=collection, filter=f'file_id == \"{file_id}\"')\n        self.ap.logger.info(f\"Deleted embeddings from Milvus collection '{collection}' with file_id: {file_id}\")\n\n    async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int:\n        collection = self._normalize_collection_name(collection)\n        await self.get_or_create_collection(collection)\n\n        expr = _build_milvus_expr(filter)\n        if not expr:\n            self.ap.logger.warning(\n                f\"Milvus delete_by_filter on '{collection}': filter produced empty expression, skipping\"\n            )\n            return 0\n\n        await asyncio.to_thread(self.client.delete, collection_name=collection, filter=expr)\n        self.ap.logger.info(f\"Deleted embeddings from Milvus collection '{collection}' by filter\")\n        return 0  # Milvus delete does not return a count\n\n    async def list_by_filter(\n        self,\n        collection: str,\n        filter: dict[str, Any] | None = None,\n        limit: int = 20,\n        offset: int = 0,\n    ) -> tuple[list[dict[str, Any]], int]:\n        collection = self._normalize_collection_name(collection)\n        await self.get_or_create_collection(collection)\n\n        query_kwargs: dict[str, Any] = dict(\n            collection_name=collection,\n            output_fields=['text', 'file_id', 'chunk_uuid'],\n            limit=limit,\n            offset=offset,\n        )\n        if filter:\n            expr = _build_milvus_expr(filter)\n            if expr:\n                query_kwargs['filter'] = expr\n\n        results = await asyncio.to_thread(self.client.query, **query_kwargs)\n\n        items = []\n        for row in results:\n            items.append(\n                {\n                    'id': row.get('id', ''),\n                    'document': row.get('text'),\n                    'metadata': {\n                        'text': row.get('text', ''),\n                        'file_id': row.get('file_id', ''),\n                        'uuid': row.get('chunk_uuid', ''),\n                    },\n                }\n            )\n\n        # Milvus query with count(*)\n        total = -1\n        try:\n            count_kwargs: dict[str, Any] = dict(\n                collection_name=collection,\n                output_fields=['count(*)'],\n            )\n            if filter:\n                expr = _build_milvus_expr(filter)\n                if expr:\n                    count_kwargs['filter'] = expr\n            count_result = await asyncio.to_thread(self.client.query, **count_kwargs)\n            if count_result:\n                total = count_result[0].get('count(*)', -1)\n        except Exception:\n            pass\n\n        return items, total\n\n    async def delete_collection(self, collection: str):\n        \"\"\"Delete a Milvus collection\n\n        Args:\n            collection: Collection name to delete\n        \"\"\"\n        collection = self._normalize_collection_name(collection)\n\n        self._collections.discard(collection)\n\n        # Check if collection exists before attempting deletion\n        has_collection = await asyncio.to_thread(self.client.has_collection, collection_name=collection)\n\n        if has_collection:\n            await asyncio.to_thread(self.client.drop_collection, collection_name=collection)\n            self.ap.logger.info(f\"Deleted Milvus collection '{collection}'\")\n        else:\n            self.ap.logger.warning(f\"Milvus collection '{collection}' not found\")\n"
  },
  {
    "path": "src/langbot/pkg/vector/vdbs/pgvector_db.py",
    "content": "from __future__ import annotations\nfrom typing import Any, Dict\nfrom sqlalchemy import create_engine, text, Column, String, Text\nfrom sqlalchemy.orm import declarative_base\nfrom sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker\nfrom pgvector.sqlalchemy import Vector\nfrom langbot.pkg.vector.vdb import VectorDatabase\nfrom langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields\nfrom langbot.pkg.core import app\n\nBase = declarative_base()\n\n# pgvector schema only stores these metadata fields.\n_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}\n\n# Callers use canonical metadata key 'uuid' but pgvector stores it as 'chunk_uuid'.\n_PG_FIELD_ALIASES = {'uuid': 'chunk_uuid'}\n\n# Map schema field names to SQLAlchemy columns (resolved lazily from PgVectorEntry).\n_PG_COLUMN_MAP = {\n    'text': 'text',\n    'file_id': 'file_id',\n    'chunk_uuid': 'chunk_uuid',\n}\n\n\nclass PgVectorEntry(Base):\n    \"\"\"SQLAlchemy model for pgvector entries\"\"\"\n\n    __tablename__ = 'langbot_vectors'\n\n    id = Column(String, primary_key=True)\n    collection = Column(String, index=True, nullable=False)\n    embedding = Column(Vector(1536))  # Default dimension, will be created dynamically\n    text = Column(Text)\n    file_id = Column(String, index=True)\n    chunk_uuid = Column(String)\n\n\ndef _build_pg_conditions(filter_dict: dict[str, Any]) -> list:\n    \"\"\"Translate canonical filter dict into a list of SQLAlchemy conditions.\"\"\"\n    triples = normalize_filter(filter_dict)\n    triples = strip_unsupported_fields(triples, _PG_SUPPORTED_FIELDS, _PG_FIELD_ALIASES)\n\n    conditions = []\n    for field, op, value in triples:\n        col = getattr(PgVectorEntry, _PG_COLUMN_MAP[field])\n        if op == '$eq':\n            conditions.append(col == value)\n        elif op == '$ne':\n            conditions.append(col != value)\n        elif op == '$gt':\n            conditions.append(col > value)\n        elif op == '$gte':\n            conditions.append(col >= value)\n        elif op == '$lt':\n            conditions.append(col < value)\n        elif op == '$lte':\n            conditions.append(col <= value)\n        elif op == '$in':\n            conditions.append(col.in_(value))\n        elif op == '$nin':\n            conditions.append(col.notin_(value))\n    return conditions\n\n\nclass PgVectorDatabase(VectorDatabase):\n    \"\"\"PostgreSQL with pgvector extension database implementation\"\"\"\n\n    def __init__(\n        self,\n        ap: app.Application,\n        connection_string: str = None,\n        host: str = 'localhost',\n        port: int = 5432,\n        database: str = 'langbot',\n        user: str = 'postgres',\n        password: str = 'postgres',\n    ):\n        \"\"\"Initialize pgvector database\n\n        Args:\n            ap: Application instance\n            connection_string: Full PostgreSQL connection string (overrides other params)\n            host: PostgreSQL host\n            port: PostgreSQL port\n            database: Database name\n            user: Database user\n            password: Database password\n        \"\"\"\n        self.ap = ap\n\n        # Build connection string if not provided\n        if connection_string:\n            self.connection_string = connection_string\n        else:\n            self.connection_string = f'postgresql+psycopg://{user}:{password}@{host}:{port}/{database}'\n\n        self.async_connection_string = self.connection_string.replace('postgresql://', 'postgresql+asyncpg://').replace(\n            'postgresql+psycopg://', 'postgresql+asyncpg://'\n        )\n\n        self.engine = None\n        self.async_engine = None\n        self.SessionLocal = None\n        self.AsyncSessionLocal = None\n        self._collections = set()\n        self._initialize_db()\n\n    def _initialize_db(self):\n        \"\"\"Initialize database connection and create tables\"\"\"\n        try:\n            # Create async engine for async operations\n            self.async_engine = create_async_engine(self.async_connection_string, echo=False, pool_pre_ping=True)\n            self.AsyncSessionLocal = async_sessionmaker(self.async_engine, class_=AsyncSession, expire_on_commit=False)\n\n            # Create sync engine for table creation\n            sync_connection_string = self.connection_string.replace('postgresql+asyncpg://', 'postgresql+psycopg://')\n            self.engine = create_engine(sync_connection_string, echo=False)\n\n            # Create pgvector extension and tables\n            with self.engine.connect() as conn:\n                # Enable pgvector extension\n                conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector'))\n                conn.commit()\n\n            # Create tables\n            Base.metadata.create_all(self.engine)\n\n            self.ap.logger.info('Connected to PostgreSQL with pgvector')\n        except Exception as e:\n            self.ap.logger.error(f'Failed to connect to PostgreSQL: {e}')\n            raise\n\n    async def get_or_create_collection(self, collection: str):\n        \"\"\"Get or create a collection (logical grouping in pgvector)\n\n        Args:\n            collection: Collection name (knowledge base UUID)\n        \"\"\"\n        # In pgvector, collections are logical - we just track them\n        if collection not in self._collections:\n            self._collections.add(collection)\n            self.ap.logger.info(f\"Registered pgvector collection '{collection}'\")\n        return collection\n\n    async def add_embeddings(\n        self,\n        collection: str,\n        ids: list[str],\n        embeddings_list: list[list[float]],\n        metadatas: list[dict[str, Any]],\n        documents: list[str] | None = None,\n    ) -> None:\n        \"\"\"Add vector embeddings to pgvector\n\n        Args:\n            collection: Collection name\n            ids: List of unique IDs for each vector\n            embeddings_list: List of embedding vectors\n            metadatas: List of metadata dictionaries\n        \"\"\"\n        await self.get_or_create_collection(collection)\n\n        async with self.AsyncSessionLocal() as session:\n            try:\n                for i, vector_id in enumerate(ids):\n                    metadata = metadatas[i] if i < len(metadatas) else {}\n\n                    entry = PgVectorEntry(\n                        id=vector_id,\n                        collection=collection,\n                        embedding=embeddings_list[i],\n                        text=metadata.get('text', ''),\n                        file_id=metadata.get('file_id', ''),\n                        chunk_uuid=metadata.get('uuid', ''),\n                    )\n                    session.add(entry)\n\n                await session.commit()\n                self.ap.logger.info(f\"Added {len(ids)} embeddings to pgvector collection '{collection}'\")\n            except Exception as e:\n                await session.rollback()\n                self.ap.logger.error(f'Error adding embeddings to pgvector: {e}')\n                raise\n\n    async def search(\n        self,\n        collection: str,\n        query_embedding: list[float],\n        k: int = 5,\n        search_type: str = 'vector',\n        query_text: str = '',\n        filter: dict[str, Any] | None = None,\n    ) -> Dict[str, Any]:\n        \"\"\"Search for similar vectors using cosine distance\n\n        Args:\n            collection: Collection name\n            query_embedding: Query vector\n            k: Number of top results to return\n\n        Returns:\n            Dictionary with search results in Chroma-compatible format\n        \"\"\"\n        await self.get_or_create_collection(collection)\n\n        async with self.AsyncSessionLocal() as session:\n            try:\n                # Use cosine distance for similarity search\n                from sqlalchemy import select\n\n                # Query for similar vectors\n                stmt = (\n                    select(\n                        PgVectorEntry.id,\n                        PgVectorEntry.text,\n                        PgVectorEntry.file_id,\n                        PgVectorEntry.chunk_uuid,\n                        PgVectorEntry.embedding.cosine_distance(query_embedding).label('distance'),\n                    )\n                    .filter(PgVectorEntry.collection == collection)\n                    .order_by(PgVectorEntry.embedding.cosine_distance(query_embedding))\n                    .limit(k)\n                )\n\n                if filter:\n                    for cond in _build_pg_conditions(filter):\n                        stmt = stmt.filter(cond)\n\n                result = await session.execute(stmt)\n                rows = result.fetchall()\n\n                # Convert to Chroma-compatible format\n                ids = []\n                distances = []\n                metadatas = []\n\n                for row in rows:\n                    ids.append(row.id)\n                    distances.append(float(row.distance))\n                    metadatas.append(\n                        {'text': row.text or '', 'file_id': row.file_id or '', 'uuid': row.chunk_uuid or ''}\n                    )\n\n                result_dict = {'ids': [ids], 'distances': [distances], 'metadatas': [metadatas]}\n\n                self.ap.logger.info(f\"pgvector search in '{collection}' returned {len(ids)} results\")\n                return result_dict\n\n            except Exception as e:\n                self.ap.logger.error(f'Error searching pgvector: {e}')\n                raise\n\n    async def delete_by_file_id(self, collection: str, file_id: str) -> None:\n        \"\"\"Delete vectors by file_id\n\n        Args:\n            collection: Collection name\n            file_id: File ID to filter deletion\n        \"\"\"\n        await self.get_or_create_collection(collection)\n\n        async with self.AsyncSessionLocal() as session:\n            try:\n                from sqlalchemy import delete\n\n                stmt = delete(PgVectorEntry).where(\n                    PgVectorEntry.collection == collection, PgVectorEntry.file_id == file_id\n                )\n                await session.execute(stmt)\n                await session.commit()\n\n                self.ap.logger.info(\n                    f\"Deleted embeddings from pgvector collection '{collection}' with file_id: {file_id}\"\n                )\n            except Exception as e:\n                await session.rollback()\n                self.ap.logger.error(f'Error deleting from pgvector: {e}')\n                raise\n\n    async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int:\n        \"\"\"Delete vectors matching a metadata filter.\n\n        Args:\n            collection: Collection name\n            filter: Canonical metadata filter dict\n        \"\"\"\n        conditions = _build_pg_conditions(filter)\n        if not conditions:\n            self.ap.logger.warning(\n                f\"pgvector delete_by_filter on '{collection}': filter produced no conditions, skipping\"\n            )\n            return 0\n\n        await self.get_or_create_collection(collection)\n\n        async with self.AsyncSessionLocal() as session:\n            try:\n                from sqlalchemy import delete\n\n                stmt = delete(PgVectorEntry).where(PgVectorEntry.collection == collection)\n                for cond in conditions:\n                    stmt = stmt.where(cond)\n                result = await session.execute(stmt)\n                await session.commit()\n                deleted = result.rowcount\n                self.ap.logger.info(f\"Deleted {deleted} embeddings from pgvector collection '{collection}' by filter\")\n                return deleted\n            except Exception as e:\n                await session.rollback()\n                self.ap.logger.error(f'Error deleting from pgvector by filter: {e}')\n                raise\n\n    async def list_by_filter(\n        self,\n        collection: str,\n        filter: dict[str, Any] | None = None,\n        limit: int = 20,\n        offset: int = 0,\n    ) -> tuple[list[dict[str, Any]], int]:\n        await self.get_or_create_collection(collection)\n\n        async with self.AsyncSessionLocal() as session:\n            try:\n                from sqlalchemy import select, func\n\n                stmt = (\n                    select(\n                        PgVectorEntry.id,\n                        PgVectorEntry.text,\n                        PgVectorEntry.file_id,\n                        PgVectorEntry.chunk_uuid,\n                    )\n                    .filter(PgVectorEntry.collection == collection)\n                    .offset(offset)\n                    .limit(limit)\n                )\n\n                count_stmt = (\n                    select(func.count()).select_from(PgVectorEntry).filter(PgVectorEntry.collection == collection)\n                )\n\n                if filter:\n                    for cond in _build_pg_conditions(filter):\n                        stmt = stmt.filter(cond)\n                        count_stmt = count_stmt.filter(cond)\n\n                result = await session.execute(stmt)\n                rows = result.fetchall()\n\n                count_result = await session.execute(count_stmt)\n                total = count_result.scalar() or 0\n\n                items = []\n                for row in rows:\n                    items.append(\n                        {\n                            'id': row.id,\n                            'document': row.text or '',\n                            'metadata': {\n                                'text': row.text or '',\n                                'file_id': row.file_id or '',\n                                'uuid': row.chunk_uuid or '',\n                            },\n                        }\n                    )\n\n                return items, total\n            except Exception as e:\n                self.ap.logger.error(f'Error listing from pgvector: {e}')\n                raise\n\n    async def delete_collection(self, collection: str):\n        \"\"\"Delete all vectors in a collection\n\n        Args:\n            collection: Collection name to delete\n        \"\"\"\n        if collection in self._collections:\n            self._collections.remove(collection)\n\n        async with self.AsyncSessionLocal() as session:\n            try:\n                from sqlalchemy import delete\n\n                stmt = delete(PgVectorEntry).where(PgVectorEntry.collection == collection)\n                await session.execute(stmt)\n                await session.commit()\n\n                self.ap.logger.info(f\"Deleted pgvector collection '{collection}'\")\n            except Exception as e:\n                await session.rollback()\n                self.ap.logger.error(f'Error deleting pgvector collection: {e}')\n                raise\n\n    async def close(self):\n        \"\"\"Close database connections\"\"\"\n        if self.async_engine:\n            await self.async_engine.dispose()\n        if self.engine:\n            self.engine.dispose()\n"
  },
  {
    "path": "src/langbot/pkg/vector/vdbs/qdrant.py",
    "content": "from __future__ import annotations\n\nfrom typing import Any, Dict, List\n\nfrom qdrant_client import AsyncQdrantClient, models\nfrom langbot.pkg.core import app\nfrom langbot.pkg.vector.vdb import VectorDatabase\nfrom langbot.pkg.vector.filter_utils import normalize_filter\n\n\ndef _build_qdrant_filter(filter_dict: dict[str, Any]) -> models.Filter:\n    \"\"\"Translate canonical filter dict into a Qdrant ``models.Filter``.\"\"\"\n    triples = normalize_filter(filter_dict)\n    must: list[models.Condition] = []\n    must_not: list[models.Condition] = []\n\n    for field, op, value in triples:\n        if op == '$eq':\n            must.append(models.FieldCondition(key=field, match=models.MatchValue(value=value)))\n        elif op == '$ne':\n            must_not.append(models.FieldCondition(key=field, match=models.MatchValue(value=value)))\n        elif op == '$in':\n            must.append(models.FieldCondition(key=field, match=models.MatchAny(any=value)))\n        elif op == '$nin':\n            must_not.append(models.FieldCondition(key=field, match=models.MatchAny(any=value)))\n        elif op in ('$gt', '$gte', '$lt', '$lte'):\n            range_kwargs: dict[str, Any] = {}\n            if op == '$gt':\n                range_kwargs['gt'] = value\n            elif op == '$gte':\n                range_kwargs['gte'] = value\n            elif op == '$lt':\n                range_kwargs['lt'] = value\n            elif op == '$lte':\n                range_kwargs['lte'] = value\n            must.append(models.FieldCondition(key=field, range=models.Range(**range_kwargs)))\n\n    return models.Filter(must=must or None, must_not=must_not or None)\n\n\nclass QdrantVectorDatabase(VectorDatabase):\n    def __init__(self, ap: app.Application):\n        self.ap = ap\n        url = self.ap.instance_config.data['vdb']['qdrant']['url']\n        host = self.ap.instance_config.data['vdb']['qdrant']['host']\n        port = self.ap.instance_config.data['vdb']['qdrant']['port']\n        api_key = self.ap.instance_config.data['vdb']['qdrant']['api_key']\n\n        if url:\n            self.client = AsyncQdrantClient(url=url, api_key=api_key)\n        else:\n            self.client = AsyncQdrantClient(host=host, port=int(port), api_key=api_key)\n\n        self._collections: set[str] = set()\n\n    async def _ensure_collection(self, collection: str, vector_size: int) -> None:\n        if collection in self._collections:\n            return\n\n        exists = await self.client.collection_exists(collection)\n        if exists:\n            self._collections.add(collection)\n            return\n\n        await self.client.create_collection(\n            collection_name=collection,\n            vectors_config=models.VectorParams(size=vector_size, distance=models.Distance.COSINE),\n        )\n        self._collections.add(collection)\n        self.ap.logger.info(f\"Qdrant collection '{collection}' created with dim={vector_size}.\")\n\n    async def get_or_create_collection(self, collection: str):\n        # Qdrant requires vector size to create a collection; no-op here.\n        pass\n\n    async def add_embeddings(\n        self,\n        collection: str,\n        ids: List[str],\n        embeddings_list: List[List[float]],\n        metadatas: List[Dict[str, Any]],\n        documents: List[str] | None = None,\n    ) -> None:\n        if not embeddings_list:\n            return\n\n        await self._ensure_collection(collection, len(embeddings_list[0]))\n\n        points = [\n            models.PointStruct(id=ids[i], vector=embeddings_list[i], payload=metadatas[i]) for i in range(len(ids))\n        ]\n        await self.client.upsert(collection_name=collection, points=points)\n        self.ap.logger.info(f\"Added {len(ids)} embeddings to Qdrant collection '{collection}'.\")\n\n    async def search(\n        self,\n        collection: str,\n        query_embedding: list[float],\n        k: int = 5,\n        search_type: str = 'vector',\n        query_text: str = '',\n        filter: dict[str, Any] | None = None,\n    ) -> dict[str, Any]:\n        exists = await self.client.collection_exists(collection)\n        if not exists:\n            return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}\n\n        query_kwargs: dict[str, Any] = dict(\n            collection_name=collection,\n            query=query_embedding,\n            limit=k,\n            with_payload=True,\n        )\n        if filter:\n            query_kwargs['query_filter'] = _build_qdrant_filter(filter)\n\n        hits = (await self.client.query_points(**query_kwargs)).points\n        ids = [str(hit.id) for hit in hits]\n        metadatas = [hit.payload or {} for hit in hits]\n        # Qdrant's score is similarity; convert to a pseudo-distance for consistency\n        distances = [1 - float(hit.score) if hit.score is not None else 1.0 for hit in hits]\n        results = {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances]}\n\n        self.ap.logger.info(f\"Qdrant search in '{collection}' returned {len(results.get('ids', [[]])[0])} results.\")\n        return results\n\n    async def delete_by_file_id(self, collection: str, file_id: str) -> None:\n        exists = await self.client.collection_exists(collection)\n        if not exists:\n            return\n\n        await self.client.delete(\n            collection_name=collection,\n            points_selector=models.Filter(\n                must=[models.FieldCondition(key='file_id', match=models.MatchValue(value=file_id))]\n            ),\n        )\n        self.ap.logger.info(f\"Deleted embeddings from Qdrant collection '{collection}' with file_id: {file_id}\")\n\n    async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int:\n        exists = await self.client.collection_exists(collection)\n        if not exists:\n            return 0\n\n        qdrant_filter = _build_qdrant_filter(filter)\n        await self.client.delete(\n            collection_name=collection,\n            points_selector=qdrant_filter,\n        )\n        self.ap.logger.info(f\"Deleted embeddings from Qdrant collection '{collection}' by filter\")\n        return 0  # Qdrant delete does not return a count\n\n    async def list_by_filter(\n        self,\n        collection: str,\n        filter: dict[str, Any] | None = None,\n        limit: int = 20,\n        offset: int = 0,\n    ) -> tuple[list[dict[str, Any]], int]:\n        exists = await self.client.collection_exists(collection)\n        if not exists:\n            return [], 0\n\n        qdrant_filter = _build_qdrant_filter(filter) if filter else None\n\n        # Qdrant scroll uses cursor-based pagination (offset = point ID),\n        # not numeric skip.  To support numeric offset we scroll through\n        # `offset + limit` items and discard the first `offset`.\n        remaining_to_skip = offset\n        remaining_to_collect = limit\n        cursor: int | str | None = None\n        collected: list[dict[str, Any]] = []\n\n        while remaining_to_skip > 0 or remaining_to_collect > 0:\n            batch_size = remaining_to_skip + remaining_to_collect if remaining_to_skip > 0 else remaining_to_collect\n            scroll_kwargs: dict[str, Any] = dict(\n                collection_name=collection,\n                limit=min(batch_size, 256),\n                with_payload=True if remaining_to_skip == 0 else False,\n                with_vectors=False,\n            )\n            if qdrant_filter:\n                scroll_kwargs['scroll_filter'] = qdrant_filter\n            if cursor is not None:\n                scroll_kwargs['offset'] = cursor\n\n            points, next_cursor = await self.client.scroll(**scroll_kwargs)\n            if not points:\n                break\n\n            for point in points:\n                if remaining_to_skip > 0:\n                    remaining_to_skip -= 1\n                    continue\n                if remaining_to_collect <= 0:\n                    break\n                # Re-fetch payload if we skipped it during the skip phase\n                payload = point.payload or {}\n                collected.append(\n                    {\n                        'id': str(point.id),\n                        'document': payload.get('text') or payload.get('document'),\n                        'metadata': payload,\n                    }\n                )\n                remaining_to_collect -= 1\n\n            if next_cursor is None:\n                break\n            cursor = next_cursor\n\n        # If we skipped without payload, re-fetch the collected items' payloads\n        # (only needed when offset > 0 and items were collected in a skip batch)\n        if offset > 0 and collected:\n            refetch_ids = [item['id'] for item in collected if not item.get('metadata')]\n            if refetch_ids:\n                fetched_points = await self.client.retrieve(\n                    collection_name=collection,\n                    ids=refetch_ids,\n                    with_payload=True,\n                    with_vectors=False,\n                )\n                payload_map = {str(p.id): p.payload or {} for p in fetched_points}\n                for item in collected:\n                    if item['id'] in payload_map:\n                        payload = payload_map[item['id']]\n                        item['metadata'] = payload\n                        item['document'] = payload.get('text') or payload.get('document')\n\n        # Use count() for accurate total (supports filter)\n        total = -1\n        try:\n            count_result = await self.client.count(\n                collection_name=collection,\n                count_filter=qdrant_filter,\n                exact=True,\n            )\n            total = count_result.count\n        except Exception:\n            pass\n\n        return collected, total\n\n    async def delete_collection(self, collection: str):\n        try:\n            await self.client.delete_collection(collection)\n            self._collections.discard(collection)\n            self.ap.logger.info(f\"Qdrant collection '{collection}' deleted.\")\n        except Exception:\n            self.ap.logger.warning(f\"Qdrant collection '{collection}' not found.\")\n"
  },
  {
    "path": "src/langbot/pkg/vector/vdbs/seekdb.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom typing import Any, Dict, List\n\n\nfrom langbot.pkg.core import app\nfrom langbot.pkg.vector.vdb import VectorDatabase, SearchType\n\ntry:\n    import pyseekdb\n    from pyseekdb import HNSWConfiguration\n\n    SEEKDB_AVAILABLE = True\nexcept ImportError:\n    SEEKDB_AVAILABLE = False\n\nSEEKDB_EMBEDDING_MODEL_UUID = 'seekdb-builtin-embedding'\nSEEKDB_EMBEDDING_REQUESTER = 'seekdb-embedding'\n\n\nclass SeekDBVectorDatabase(VectorDatabase):\n    \"\"\"SeekDB vector database adapter for LangBot.\n\n    SeekDB is an AI-native search database by OceanBase that unifies\n    relational, vector, text, JSON and GIS in a single engine.\n\n    Supports embedded mode, remote server mode, and full-text/hybrid search.\n    \"\"\"\n\n    @classmethod\n    def supported_search_types(cls) -> list[SearchType]:\n        return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID]\n\n    def __init__(self, ap: app.Application):\n        if not SEEKDB_AVAILABLE:\n            raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')\n\n        self.ap = ap\n        config = self.ap.instance_config.data['vdb']['seekdb']\n\n        # Determine connection mode based on config\n        mode = config.get('mode', 'embedded')  # 'embedded' or 'server'\n\n        if mode == 'embedded':\n            # Embedded mode: local database\n            path = config.get('path', './data/seekdb')\n            database = config.get('database', 'langbot')\n\n            # Use AdminClient for database management operations\n            admin_client = pyseekdb.AdminClient(path=path)\n            # Check if database exists using public API\n            existing_dbs = [db.name for db in admin_client.list_databases()]\n            if database not in existing_dbs:\n                # Use public API to create database\n                admin_client.create_database(database)\n                self.ap.logger.info(f\"Created SeekDB database '{database}'\")\n\n            self.client = pyseekdb.Client(path=path, database=database)\n            self.ap.logger.info(f\"Initialized SeekDB in embedded mode at '{path}', database '{database}'\")\n        elif mode == 'server':\n            # Server mode: remote SeekDB or OceanBase server\n            host = config.get('host', 'localhost')\n            port = config.get('port', 2881)\n            database = config.get('database', 'langbot')\n            user = config.get('user', 'root')\n            password = config.get('password', '')\n            tenant = config.get('tenant', None)  # Optional, for OceanBase\n\n            connection_params = {\n                'host': host,\n                'port': int(port),\n                'database': database,\n                'user': user,\n                'password': password,\n            }\n\n            if tenant:\n                connection_params['tenant'] = tenant\n\n            self.client = pyseekdb.Client(**connection_params)\n            self.ap.logger.info(\n                f\"Initialized SeekDB in server mode: {host}:{port}, database '{database}'\"\n                + (f\", tenant '{tenant}'\" if tenant else '')\n            )\n        else:\n            raise ValueError(f\"Invalid SeekDB mode: {mode}. Must be 'embedded' or 'server'\")\n\n        self._collections: Dict[str, Any] = {}\n        self._collection_configs: Dict[str, HNSWConfiguration] = {}\n\n        self._escape_table = str.maketrans(\n            {\n                '\\x00': '',\n                '\\\\': '\\\\\\\\',\n                \"'\": \"''\",  # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES)\n                '\"': '\\\\\"',\n                '\\n': '\\\\n',\n                '\\r': '\\\\r',\n                '\\t': '\\\\t',\n            }\n        )\n\n    async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:\n        \"\"\"Internal method to get or create a collection with proper configuration.\"\"\"\n        if collection in self._collections:\n            return self._collections[collection]\n\n        # Check if collection exists\n        if await asyncio.to_thread(self.client.has_collection, collection):\n            # Collection exists, get it\n            coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)\n            self._collections[collection] = coll\n            self.ap.logger.info(f\"SeekDB collection '{collection}' retrieved.\")\n            return coll\n\n        # Collection doesn't exist, create it\n        if vector_size is None:\n            raise ValueError(\n                f\"Cannot create SeekDB collection '{collection}' without knowing the vector dimension. \"\n                'Ensure add_embeddings is called before any standalone get_or_create_collection.'\n            )\n\n        # Create HNSW configuration\n        config = HNSWConfiguration(dimension=vector_size, distance='cosine')\n        self._collection_configs[collection] = config\n\n        # Create collection without embedding function (we manage embeddings externally)\n        coll = await asyncio.to_thread(\n            self.client.create_collection,\n            name=collection,\n            configuration=config,\n            embedding_function=None,  # Disable automatic embedding\n        )\n\n        self._collections[collection] = coll\n        self.ap.logger.info(f\"SeekDB collection '{collection}' created with dimension={vector_size}, distance='cosine'\")\n        return coll\n\n    def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"SeekDB metadata doesn't support \\\\ and \", insert will error 3104\"\"\"\n        return {\n            k: v.translate(self._escape_table)\n            if isinstance(v, str)\n            else v\n            if v is None or isinstance(v, (int, float, bool))\n            else str(v)\n            for k, v in meta.items()\n            if v is not None\n        }\n\n    async def get_or_create_collection(self, collection: str):\n        \"\"\"Get or create collection (without vector size - will use default).\"\"\"\n        return await self._get_or_create_collection_internal(collection)\n\n    async def add_embeddings(\n        self,\n        collection: str,\n        ids: List[str],\n        embeddings_list: List[List[float]],\n        metadatas: List[Dict[str, Any]],\n        documents: List[str] | None = None,\n    ) -> None:\n        \"\"\"Add vector embeddings to the specified collection.\n\n        Args:\n            collection: Collection name\n            ids: List of document IDs\n            embeddings_list: List of embedding vectors\n            metadatas: List of metadata dictionaries\n            documents: Optional raw text documents for full-text search support\n        \"\"\"\n        if not embeddings_list:\n            return\n\n        # Ensure collection exists with correct dimension\n        vector_size = len(embeddings_list[0])\n        coll = await self._get_or_create_collection_internal(collection, vector_size)\n\n        cleaned_metadatas = [self._clean_metadata(meta) for meta in metadatas]\n\n        kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)\n        if documents is not None:\n            kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents]\n        await asyncio.to_thread(coll.add, **kwargs)\n\n        self.ap.logger.info(f\"Added {len(ids)} embeddings to SeekDB collection '{collection}'\")\n\n    async def search(\n        self,\n        collection: str,\n        query_embedding: List[float],\n        k: int = 5,\n        search_type: str = 'vector',\n        query_text: str = '',\n        filter: Dict[str, Any] | None = None,\n    ) -> Dict[str, Any]:\n        \"\"\"Search for the most similar vectors in the specified collection.\n\n        SeekDB supports vector, full-text, and hybrid search modes.\n\n        Args:\n            collection: Collection name\n            query_embedding: Query vector (used for vector and hybrid modes)\n            k: Number of results to return\n            search_type: One of 'vector', 'full_text', 'hybrid'\n            query_text: Raw query text (used for full_text and hybrid modes)\n            filter: Optional metadata filters (Chroma-style ``where`` syntax).\n\n        Returns:\n            Dictionary with 'ids', 'metadatas', 'distances' keys\n        \"\"\"\n        # Check if collection exists\n        exists = await asyncio.to_thread(self.client.has_collection, collection)\n        if not exists:\n            return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}\n\n        # Get collection\n        if collection not in self._collections:\n            coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)\n            self._collections[collection] = coll\n        else:\n            coll = self._collections[collection]\n\n        # Route by search type.\n        # pyseekdb's query() always requires embeddings, so full-text and\n        # hybrid modes use hybrid_search() which supports text-only queries\n        # and returns the same nested-list format with distances.\n        if search_type == SearchType.FULL_TEXT:\n            if not query_text:\n                return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}\n\n            query_cfg: Dict[str, Any] = {\n                'where_document': {'$contains': query_text},\n                'n_results': k,\n            }\n            if filter:\n                query_cfg['where'] = filter\n\n            # TODO: pyseekdb hybrid_search with query-only (no knn) returns None\n            # for IDs due to column name mismatch (*/_id vs _id).\n            # See: https://github.com/oceanbase/pyseekdb/issues/171\n            results = await asyncio.to_thread(\n                coll.hybrid_search,\n                query=query_cfg,\n                knn=None,\n                n_results=k,\n                include=['documents', 'metadatas'],\n            )\n\n        elif search_type == SearchType.HYBRID:\n            if not query_text:\n                # Fall back to pure vector search when no text is provided\n                query_kwargs: Dict[str, Any] = {\n                    'n_results': k,\n                    'query_embeddings': query_embedding,\n                }\n                if filter:\n                    query_kwargs['where'] = filter\n                results = await asyncio.to_thread(coll.query, **query_kwargs)\n            else:\n                query_cfg = {\n                    'where_document': {'$contains': query_text},\n                    'n_results': k,\n                }\n                knn_cfg: Dict[str, Any] = {\n                    'query_embeddings': query_embedding,\n                    'n_results': k,\n                }\n                if filter:\n                    query_cfg['where'] = filter\n                    knn_cfg['where'] = filter\n\n                results = await asyncio.to_thread(\n                    coll.hybrid_search,\n                    query=query_cfg,\n                    knn=knn_cfg,\n                    rank={'rrf': {}},\n                    n_results=k,\n                    include=['documents', 'metadatas'],\n                )\n        else:\n            # Default: vector search via query()\n            query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}\n            if filter:\n                query_kwargs['where'] = filter\n            results = await asyncio.to_thread(coll.query, **query_kwargs)\n\n        self.ap.logger.info(\n            f\"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results\"\n        )\n\n        return results\n\n    async def delete_by_file_id(self, collection: str, file_id: str) -> None:\n        \"\"\"Delete vectors from the collection by file_id metadata.\n\n        Args:\n            collection: Collection name\n            file_id: File ID to delete\n        \"\"\"\n        # Check if collection exists\n        exists = await asyncio.to_thread(self.client.has_collection, collection)\n        if not exists:\n            self.ap.logger.warning(f\"SeekDB collection '{collection}' not found for deletion\")\n            return\n\n        # Get collection\n        if collection not in self._collections:\n            coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)\n            self._collections[collection] = coll\n        else:\n            coll = self._collections[collection]\n\n        # SeekDB's delete() expects a where clause for filtering\n        # Delete all records where metadata['file_id'] == file_id\n        await asyncio.to_thread(coll.delete, where={'file_id': file_id})\n\n        self.ap.logger.info(f\"Deleted embeddings from SeekDB collection '{collection}' with file_id: {file_id}\")\n\n    async def delete_by_filter(self, collection: str, filter: Dict[str, Any]) -> int:\n        \"\"\"Delete vectors from the collection by metadata filter.\n\n        Args:\n            collection: Collection name\n            filter: Chroma-style ``where`` filter dict\n        \"\"\"\n        exists = await asyncio.to_thread(self.client.has_collection, collection)\n        if not exists:\n            self.ap.logger.warning(f\"SeekDB collection '{collection}' not found for deletion\")\n            return 0\n\n        if collection not in self._collections:\n            coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)\n            self._collections[collection] = coll\n        else:\n            coll = self._collections[collection]\n\n        await asyncio.to_thread(coll.delete, where=filter)\n        self.ap.logger.info(f\"Deleted embeddings from SeekDB collection '{collection}' by filter\")\n        return 0  # SeekDB delete does not return a count\n\n    async def list_by_filter(\n        self,\n        collection: str,\n        filter: Dict[str, Any] | None = None,\n        limit: int = 20,\n        offset: int = 0,\n    ) -> tuple[list[Dict[str, Any]], int]:\n        exists = await asyncio.to_thread(self.client.has_collection, collection)\n        if not exists:\n            return [], 0\n\n        if collection not in self._collections:\n            coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)\n            self._collections[collection] = coll\n        else:\n            coll = self._collections[collection]\n\n        get_kwargs: Dict[str, Any] = dict(\n            include=['metadatas', 'documents'],\n            limit=limit,\n            offset=offset,\n        )\n        if filter:\n            get_kwargs['where'] = filter\n\n        results = await asyncio.to_thread(coll.get, **get_kwargs)\n\n        ids = results.get('ids', [])\n        metadatas = results.get('metadatas', []) or [None] * len(ids)\n        documents = results.get('documents', []) or [None] * len(ids)\n\n        items = []\n        for i, vid in enumerate(ids):\n            items.append(\n                {\n                    'id': vid,\n                    'document': documents[i] if i < len(documents) else None,\n                    'metadata': metadatas[i] if i < len(metadatas) else {},\n                }\n            )\n\n        total = await asyncio.to_thread(coll.count) if not filter else -1\n        return items, total\n\n    async def delete_collection(self, collection: str):\n        \"\"\"Delete the entire collection.\n\n        Args:\n            collection: Collection name\n        \"\"\"\n        # Remove from cache\n        if collection in self._collections:\n            del self._collections[collection]\n        if collection in self._collection_configs:\n            del self._collection_configs[collection]\n\n        # Check if collection exists\n        exists = await asyncio.to_thread(self.client.has_collection, collection)\n        if not exists:\n            self.ap.logger.warning(f\"SeekDB collection '{collection}' not found for deletion\")\n            return\n\n        # Delete collection\n        await asyncio.to_thread(self.client.delete_collection, collection)\n        self.ap.logger.info(f\"SeekDB collection '{collection}' deleted\")\n"
  },
  {
    "path": "src/langbot/templates/__init__.py",
    "content": ""
  },
  {
    "path": "src/langbot/templates/components.yaml",
    "content": "apiVersion: v1\nkind: Blueprint\nmetadata:\n  name: builtin-components\n  label:\n    en_US: Builtin Components\n    zh_Hans: 内置组件\nspec:\n  components:\n    MessagePlatformAdapter:\n      fromDirs:\n        - path: pkg/platform/sources/\n    LLMAPIRequester:\n      fromDirs:\n        - path: pkg/provider/modelmgr/requesters/\n"
  },
  {
    "path": "src/langbot/templates/config.yaml",
    "content": "admins: []\napi:\n    port: 5300\n    webhook_prefix: 'http://127.0.0.1:5300'\n    extra_webhook_prefix: ''\ncommand:\n    enable: true\n    prefix:\n    - '!'\n    - ！\n    privilege: {}\nconcurrency:\n    pipeline: 20\n    session: 1\nproxy:\n    http: ''\n    https: ''\nsystem:\n    instance_id: ''\n    edition: community\n    recovery_key: ''\n    allow_modify_login_info: true\n    limitation:\n        max_bots: -1\n        max_pipelines: -1\n        max_extensions: -1\n    jwt:\n        expire: 604800\n        secret: ''\ndatabase:\n    use: sqlite\n    sqlite:\n        path: 'data/langbot.db'\n    postgresql:\n        host: '127.0.0.1'\n        port: 5432\n        user: 'postgres'\n        password: 'postgres'\n        database: 'postgres'\nvdb:\n    use: chroma\n    qdrant:\n        url: ''\n        host: localhost\n        port: 6333\n        api_key: ''\n    seekdb:\n        mode: embedded  # 'embedded' or 'server'\n        # Embedded mode options:\n        path: './data/seekdb'\n        database: 'langbot'\n        # Server mode options (used when mode='server'):\n        host: 'localhost'\n        port: 2881\n        user: 'root'\n        password: ''\n        tenant: ''  # Optional, for OceanBase server\n    milvus:\n        uri: 'http://127.0.0.1:19530'\n        token: ''\n        db_name: ''\n    pgvector:\n        host: '127.0.0.1'\n        port: 5433\n        database: 'langbot'\n        user: 'postgres'\n        password: 'postgres'\nstorage:\n    use: local\n    s3:\n        endpoint_url: ''\n        access_key_id: ''\n        secret_access_key: ''\n        region: 'us-east-1'\n        bucket: 'langbot-storage'\nplugin:\n    enable: true\n    runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'\n    enable_marketplace: true\n    display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'\nspace:\n    # Space service URL for OAuth and API\n    url: 'https://space.langbot.app'\n    # Space API URL for model requests (MaaS)\n    models_gateway_api_url: 'https://api.langbot.cloud/v1'\n    # OAuth authorization page URL (user will be redirected here)\n    oauth_authorize_url: 'https://space.langbot.app/auth/authorize'\n    disable_models_service: false\n    disable_telemetry: false\n"
  },
  {
    "path": "src/langbot/templates/default-pipeline-config.json",
    "content": "{\n    \"trigger\": {\n        \"group-respond-rules\": {\n            \"at\": true,\n            \"prefix\": [\n                \"ai\"\n            ],\n            \"regexp\": [],\n            \"random\": 0.0\n        },\n        \"access-control\": {\n            \"mode\": \"blacklist\",\n            \"blacklist\": [],\n            \"whitelist\": []\n        },\n        \"ignore-rules\": {\n            \"prefix\": [],\n            \"regexp\": []\n        },\n        \"message-aggregation\": {\n            \"enabled\": false,\n            \"delay\": 1.5\n        },\n        \"misc\": {\n            \"combine-quote-message\": true\n        }\n    },\n    \"safety\": {\n        \"content-filter\": {\n            \"scope\": \"all\",\n            \"check-sensitive-words\": true\n        },\n        \"rate-limit\": {\n            \"window-length\": 60,\n            \"limitation\": 60,\n            \"strategy\": \"drop\"\n        }\n    },\n    \"ai\": {\n        \"runner\": {\n            \"runner\": \"local-agent\"\n        },\n        \"local-agent\": {\n            \"model\": {\n                \"primary\": \"\",\n                \"fallbacks\": []\n            },\n            \"max-round\": 10,\n            \"prompt\": [\n                {\n                    \"role\": \"system\",\n                    \"content\": \"You are a helpful assistant.\"\n                }\n            ],\n            \"knowledge-bases\": []\n        },\n        \"dify-service-api\": {\n            \"base-url\": \"https://api.dify.ai/v1\",\n            \"app-type\": \"chat\",\n            \"api-key\": \"your-api-key\",\n            \"timeout\": 30\n        },\n        \"dashscope-app-api\": {\n            \"app-type\": \"agent\",\n            \"api-key\": \"your-api-key\",\n            \"app-id\": \"your-app-id\",\n            \"references-quote\": \"参考资料来自:\"\n        },\n        \"n8n-service-api\": {\n            \"webhook-url\": \"http://your-n8n-webhook-url\",\n            \"auth-type\": \"none\",\n            \"basic-username\": \"\",\n            \"basic-password\": \"\",\n            \"jwt-secret\": \"\",\n            \"jwt-algorithm\": \"HS256\",\n            \"header-name\": \"\",\n            \"header-value\": \"\",\n            \"timeout\": 120,\n            \"output-key\": \"response\"\n        },\n        \"langflow-api\": {\n            \"base-url\": \"http://localhost:7860\",\n            \"api-key\": \"your-api-key\",\n            \"flow-id\": \"your-flow-id\",\n            \"input-type\": \"chat\",\n            \"output-type\": \"chat\",\n            \"tweaks\": \"{}\"\n        }\n    },\n    \"output\": {\n        \"long-text-processing\": {\n            \"threshold\": 1000,\n            \"strategy\": \"none\",\n            \"font-path\": \"\"\n        },\n        \"force-delay\": {\n            \"min\": 0,\n            \"max\": 0\n        },\n        \"misc\": {\n            \"exception-handling\": \"show-hint\",\n            \"failure-hint\": \"Request failed.\",\n            \"at-sender\": true,\n            \"quote-origin\": true,\n            \"track-function-calls\": false,\n            \"remove-think\": false\n        }\n    }\n}\n"
  },
  {
    "path": "src/langbot/templates/legacy/command.json",
    "content": "{\n    \"privilege\": {},\n    \"command-prefix\": [\n        \"!\",\n        \"！\"\n    ]\n}"
  },
  {
    "path": "src/langbot/templates/legacy/pipeline.json",
    "content": "{\n    \"access-control\":{\n        \"mode\": \"blacklist\",\n        \"blacklist\": [],\n        \"whitelist\": []\n    },\n    \"respond-rules\": {\n        \"default\": {\n            \"at\": true,\n            \"prefix\": [\n                \"/ai\", \"!ai\", \"！ai\", \"ai\"\n            ],\n            \"regexp\": [],\n            \"random\": 0.0\n        }\n    },\n    \"income-msg-check\": true,\n    \"ignore-rules\": {\n        \"prefix\": [],\n        \"regexp\": []\n    },\n    \"check-sensitive-words\": true,\n    \"baidu-cloud-examine\": {\n        \"enable\": false,\n        \"api-key\": \"\",\n        \"api-secret\": \"\"\n    },\n    \"rate-limit\": {\n        \"strategy\": \"drop\",\n        \"algo\": \"fixwin\",\n        \"fixwin\": {\n            \"default\": {\n                \"window-size\": 60,\n                \"limit\": 60\n            }\n        }\n    },\n    \"msg-truncate\": {\n        \"method\": \"round\",\n        \"round\": {\n            \"max-round\": 10\n        }\n    }\n}"
  },
  {
    "path": "src/langbot/templates/legacy/platform.json",
    "content": "{\n    \"platform-adapters\": [\n        {\n            \"adapter\": \"nakuru\",\n            \"enable\": false,\n            \"host\": \"127.0.0.1\",\n            \"ws_port\": 8080,\n            \"http_port\": 5700,\n            \"token\": \"\"\n        },\n        {\n            \"adapter\": \"aiocqhttp\",\n            \"enable\": true,\n            \"host\": \"0.0.0.0\",\n            \"port\": 2280,\n            \"access-token\": \"\"\n        },\n        {\n            \"adapter\": \"qq-botpy\",\n            \"enable\": false,\n            \"appid\": \"\",\n            \"secret\": \"\",\n            \"intents\": [\n                \"public_guild_messages\",\n                \"direct_message\"\n            ]\n        },\n        {\n            \"adapter\": \"qqofficial\",\n            \"enable\": false,\n            \"appid\": \"1234567890\",\n            \"secret\": \"xxxxxxx\",\n            \"port\": 2284,\n            \"token\": \"abcdefg\"\n        },\n        {\n            \"adapter\": \"wecom\",\n            \"enable\": false,\n            \"host\": \"0.0.0.0\",\n            \"port\": 2290,\n            \"corpid\": \"\",\n            \"secret\": \"\",\n            \"token\": \"\",\n            \"EncodingAESKey\": \"\",\n            \"contacts_secret\": \"\"\n        },\n        {\n            \"adapter\": \"lark\",\n            \"enable\": false,\n            \"app_id\": \"cli_abcdefgh\",\n            \"app_secret\": \"XXXXXXXXXX\",\n            \"bot_name\": \"LangBot\",\n            \"enable-webhook\": false,\n            \"port\": 2285,\n            \"encrypt-key\": \"xxxxxxxxx\"\n        },\n        {\n            \"adapter\": \"discord\",\n            \"enable\": false,\n            \"client_id\": \"1234567890\",\n            \"token\": \"XXXXXXXXXX\"\n        },\n        {\n            \"adapter\": \"gewechat\",\n            \"enable\": false,\n            \"gewechat_url\": \"http://your-gewechat-server:2531\",\n            \"gewechat_file_url\": \"http://your-gewechat-server:2532\",\n            \"port\": 2286,\n            \"callback_url\": \"http://your-callback-url:2286/gewechat/callback\",\n            \"app_id\": \"\",\n            \"token\": \"\"\n        },\n        {\n            \"adapter\": \"officialaccount\",\n            \"enable\": false,\n            \"token\": \"\",\n            \"EncodingAESKey\": \"\",\n            \"AppID\": \"\",\n            \"AppSecret\": \"\",\n            \"Mode\": \"drop\",\n            \"LoadingMessage\": \"AI正在思考中，请发送任意内容获取回复。\",\n            \"host\": \"0.0.0.0\",\n            \"port\": 2287\n        },\n        {\n            \"adapter\": \"dingtalk\",\n            \"enable\": false,\n            \"client_id\": \"\",\n            \"client_secret\": \"\",\n            \"robot_code\": \"\",\n            \"robot_name\": \"\",\n            \"markdown_card\": false\n        },\n        {\n            \"adapter\": \"telegram\",\n            \"enable\": false,\n            \"token\": \"\",\n            \"markdown_card\": false\n        },\n        {\n            \"adapter\": \"slack\",\n            \"enable\": false,\n            \"bot_token\": \"\",\n            \"signing_secret\": \"\",\n            \"port\": 2288\n        },\n        {\n            \"adapter\": \"wecomcs\",\n            \"enable\": false,\n            \"port\": 2289,\n            \"corpid\": \"\",\n            \"secret\": \"\",\n            \"token\": \"\",\n            \"EncodingAESKey\": \"\"\n        }\n    ],\n    \"track-function-calls\": true,\n    \"quote-origin\": false,\n    \"at-sender\": false,\n    \"force-delay\": {\n        \"min\": 0,\n        \"max\": 0\n    },\n    \"long-text-process\": {\n        \"threshold\": 2560,\n        \"strategy\": \"forward\",\n        \"font-path\": \"\"\n    },\n    \"hide-exception-info\": true\n}"
  },
  {
    "path": "src/langbot/templates/legacy/provider.json",
    "content": "{\n    \"enable-chat\": true,\n    \"enable-vision\": true,\n    \"keys\": {\n        \"openai\": [\n            \"sk-1234567890\"\n        ],\n        \"anthropic\": [\n            \"sk-1234567890\"\n        ],\n        \"moonshot\": [\n            \"sk-1234567890\"\n        ],\n        \"deepseek\": [\n            \"sk-1234567890\"\n        ],\n        \"gitee-ai\": [\n            \"XXXXX\"\n        ],\n        \"xai\": [\n            \"xai-1234567890\"\n        ],\n        \"zhipuai\": [\n            \"xxxxxxx\"\n        ],\n        \"siliconflow\": [\n            \"xxxxxxx\"\n        ],\n        \"bailian\": [\n            \"sk-xxxxxxx\"\n        ],\n        \"volcark\": [\n            \"xxxxxxxx\"\n        ],\n        \"modelscope\": [\n            \"xxxxxxxx\"\n        ],\n        \"ppio\": [\n            \"xxxxxxxx\"\n        ]\n    },\n    \"requester\": {\n        \"openai-chat-completions\": {\n            \"base-url\": \"https://api.openai.com/v1\",\n            \"args\": {},\n            \"timeout\": 120\n        },\n        \"anthropic-messages\": {\n            \"base-url\": \"https://api.anthropic.com\",\n            \"args\": {\n                \"max_tokens\": 1024\n            },\n            \"timeout\": 120\n        },\n        \"moonshot-chat-completions\": {\n            \"base-url\": \"https://api.moonshot.cn/v1\",\n            \"args\": {},\n            \"timeout\": 120\n        },\n        \"deepseek-chat-completions\": {\n            \"base-url\": \"https://api.deepseek.com\",\n            \"args\": {},\n            \"timeout\": 120\n        },\n        \"ollama-chat\": {\n            \"base-url\": \"http://127.0.0.1:11434\",\n            \"args\": {},\n            \"timeout\": 600\n        },\n        \"gitee-ai-chat-completions\": {\n            \"base-url\": \"https://ai.gitee.com/v1\",\n            \"args\": {},\n            \"timeout\": 120\n        },\n        \"xai-chat-completions\": {\n            \"base-url\": \"https://api.x.ai/v1\",\n            \"args\": {},\n            \"timeout\": 120\n        },\n        \"zhipuai-chat-completions\": {\n            \"base-url\": \"https://open.bigmodel.cn/api/paas/v4\",\n            \"args\": {},\n            \"timeout\": 120\n        },\n        \"lmstudio-chat-completions\": {\n            \"base-url\": \"http://127.0.0.1:1234/v1\",\n            \"args\": {},\n            \"timeout\": 120\n        },\n        \"siliconflow-chat-completions\": {\n            \"base-url\": \"https://api.siliconflow.cn/v1\",\n            \"args\": {},\n            \"timeout\": 120\n        },\n        \"bailian-chat-completions\": {\n            \"args\": {},\n            \"base-url\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n            \"timeout\": 120\n        },\n        \"volcark-chat-completions\": {\n            \"args\": {},\n            \"base-url\": \"https://ark.cn-beijing.volces.com/api/v3\",\n            \"timeout\": 120\n        },\n        \"modelscope-chat-completions\": {\n            \"base-url\": \"https://api-inference.modelscope.cn/v1\",\n            \"args\": {},\n            \"timeout\": 120\n        },\n        \"ppio-chat-completions\": {\n            \"base-url\": \"https://api.ppinfra.com/v3/openai\",\n            \"args\": {},\n            \"timeout\": 120\n        }\n    },\n    \"model\": \"gpt-4o\",\n    \"prompt-mode\": \"normal\",\n    \"prompt\": {\n        \"default\": \"You are a helpful assistant.\"\n    },\n    \"runner\": \"local-agent\",\n    \"dify-service-api\": {\n        \"base-url\": \"https://api.dify.ai/v1\",\n        \"app-type\": \"chat\",\n        \"options\": {\n            \"convert-thinking-tips\": \"plain\"\n        },\n        \"chat\": {\n            \"api-key\": \"app-1234567890\",\n            \"timeout\": 120\n        },\n        \"agent\": {\n            \"api-key\": \"app-1234567890\",\n            \"timeout\": 120\n        },\n        \"workflow\": {\n            \"api-key\": \"app-1234567890\",\n            \"output-key\": \"summary\",\n            \"timeout\": 120\n        }\n    },\n    \"dashscope-app-api\": {\n        \"app-type\": \"agent\",\n        \"api-key\": \"sk-1234567890\",\n        \"agent\": {\n            \"app-id\": \"Your_app_id\",\n            \"references_quote\": \"参考资料来自:\"\n        },\n        \"workflow\": {\n            \"app-id\": \"Your_app_id\",\n            \"references_quote\": \"参考资料来自:\",\n            \"biz_params\": {  \n                \"city\": \"北京\",\n                \"date\": \"2023-08-10\"\n            }\n        }\n    },\n    \"mcp\": {\n        \"servers\": []\n    }\n}"
  },
  {
    "path": "src/langbot/templates/legacy/system.json",
    "content": "{\n    \"admin-sessions\": [],\n    \"network-proxies\": {\n        \"http\": null,\n        \"https\": null\n    },\n    \"report-usage\": true,\n    \"logging-level\": \"info\",\n    \"session-concurrency\": {\n        \"default\": 1\n    },\n    \"pipeline-concurrency\": 20,\n    \"qcg-center-url\": \"https://api.qchatgpt.rockchin.top/api/v2\",\n    \"help-message\": \"LangBot - 😎高稳定性、🧩支持插件、🌏实时联网的 ChatGPT QQ 机器人🤖\\n链接：https://q.rkcn.top\",\n    \"http-api\": {\n        \"enable\": true,\n        \"host\": \"0.0.0.0\",\n        \"port\": 5300,\n        \"jwt-expire\": 604800\n    },\n    \"persistence\": {\n        \"sqlite\": {\n            \"path\": \"data/langbot.db\"\n        },\n        \"use\": \"sqlite\"\n    }\n}"
  },
  {
    "path": "src/langbot/templates/metadata/pipeline/ai.yaml",
    "content": "name: ai\nlabel:\n  en_US: AI Feature\n  zh_Hans: AI 能力\nstages:\n  - name: runner\n    label:\n      en_US: Runner\n      zh_Hans: 运行方式\n    description:\n      en_US: Strategy to call AI to process messages\n      zh_Hans: 调用 AI 处理消息的方式\n    config:\n      - name: runner\n        label:\n          en_US: Runner\n          zh_Hans: 运行器\n        type: select\n        required: true\n        default: local-agent\n        options:\n          - name: local-agent\n            label:\n              en_US: Local Agent\n              zh_Hans: 内置 Agent\n          - name: tbox-app-api\n            label:\n              en_US: Tbox App API\n              zh_Hans: 蚂蚁百宝箱平台 API\n          - name: dify-service-api\n            label:\n              en_US: Dify Service API\n              zh_Hans: Dify 服务 API\n          - name: dashscope-app-api\n            label:\n              en_US: Aliyun Dashscope App API\n              zh_Hans: 阿里云百炼平台 API\n          - name: n8n-service-api\n            label:\n              en_US: n8n Workflow API\n              zh_Hans: n8n 工作流 API\n          - name: langflow-api\n            label:\n              en_US: Langflow API\n              zh_Hans: Langflow API\n          - name: coze-api\n            label:\n              en_US: Coze API\n              zh_Hans: 扣子 API\n  - name: local-agent\n    label:\n      en_US: Local Agent\n      zh_Hans: 内置 Agent\n    description:\n      en_US: Configure the embedded agent of the pipeline\n      zh_Hans: 配置内置 Agent\n    config:\n      - name: model\n        label:\n          en_US: Model\n          zh_Hans: 模型\n        type: model-fallback-selector\n        required: true\n        default:\n          primary: ''\n          fallbacks: []\n      - name: max-round\n        label:\n          en_US: Max Round\n          zh_Hans: 最大回合数\n        description:\n          en_US: The maximum number of previous messages that the agent can remember\n          zh_Hans: 最大前文消息回合数\n        type: integer\n        required: true\n        default: 10\n      - name: prompt\n        label:\n          en_US: Prompt\n          zh_Hans: 提示词\n        description:\n          en_US: The prompt of the agent\n          zh_Hans: 除非您了解消息结构，否则请只使用 system 单提示词\n        type: prompt-editor\n        required: true\n      - name: knowledge-bases\n        label:\n          en_US: Knowledge Bases\n          zh_Hans: 知识库\n        description:\n          en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply\n          zh_Hans: 配置用于提升回复质量的知识库，若不选择，则直接使用大模型回复\n        type: knowledge-base-multi-selector\n        required: false\n        default: []\n  - name: tbox-app-api\n    label:\n      en_US: Tbox App API\n      zh_Hans: 蚂蚁百宝箱平台 API\n    description:\n      en_US: Configure the Tbox App API of the pipeline\n      zh_Hans: 配置蚂蚁百宝箱平台 API\n    config:\n      - name: api-key\n        label:\n          en_US: API Key\n          zh_Hans: API 密钥\n        type: string\n        required: true\n      - name: app-id\n        label:\n          en_US: App ID\n          zh_Hans: 应用 ID\n        type: string\n        required: true\n  - name: dify-service-api\n    label:\n      en_US: Dify Service API\n      zh_Hans: Dify 服务 API\n    description:\n      en_US: Configure the Dify service API of the pipeline\n      zh_Hans: 配置 Dify 服务 API\n    config:\n      - name: base-url\n        label:\n          en_US: Base URL\n          zh_Hans: 基础 URL\n        type: string\n        required: true\n      - name: base-prompt\n        label:\n          en_US: Base PROMPT\n          zh_Hans: 基础提示词\n        description:\n          en_US: When Dify receives a message with empty input (only images), it will pass this default prompt into it.\n          zh_Hans: 当 Dify 接收到输入文字为空（仅图片）的消息时，传入该默认提示词\n        type: string\n        required: true\n        default: \"When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.\"\n      - name: app-type\n        label:\n          en_US: App Type\n          zh_Hans: 应用类型\n        type: select\n        required: true\n        default: chat\n        options:\n          - name: chat\n            label:\n              en_US: Chat\n              zh_Hans: 聊天（包括Chatflow）\n          - name: agent\n            label:\n              en_US: Agent\n              zh_Hans: Agent\n          - name: workflow\n            label:\n              en_US: Workflow\n              zh_Hans: 工作流\n      - name: api-key\n        label:\n          en_US: API Key\n          zh_Hans: API 密钥\n        type: string\n        required: true\n  - name: dashscope-app-api\n    label:\n      en_US: Aliyun Dashscope App API\n      zh_Hans: 阿里云百炼平台 API\n    description:\n      en_US: Configure the Aliyun Dashscope App API of the pipeline\n      zh_Hans: 配置阿里云百炼平台 API\n    config:\n      - name: app-type\n        label:\n          en_US: App Type\n          zh_Hans: 应用类型\n        type: select\n        required: true\n        default: agent\n        options:\n          - name: agent\n            label:\n              en_US: Agent\n              zh_Hans: Agent\n          - name: workflow\n            label:\n              en_US: Workflow\n              zh_Hans: 工作流\n      - name: api-key\n        label:\n          en_US: API Key\n          zh_Hans: API 密钥\n        type: string\n        required: true\n      - name: app-id\n        label:\n          en_US: App ID\n          zh_Hans: 应用 ID\n        type: string\n        required: true\n      - name: references_quote\n        label:\n          en_US: References Quote\n          zh_Hans: 引用文本\n        description:\n          en_US: The text prompt when the references are included\n          zh_Hans: 包含引用资料时的文本提示\n        type: string\n        required: false\n        default: '参考资料来自:'\n  - name: n8n-service-api\n    label:\n      en_US: n8n Workflow API\n      zh_Hans: n8n 工作流 API\n    description:\n      en_US: Configure the n8n workflow API of the pipeline\n      zh_Hans: 配置 n8n 工作流 API\n    config:\n      - name: webhook-url\n        label:\n          en_US: Webhook URL\n          zh_Hans: Webhook URL\n        description:\n          en_US: The webhook URL of the n8n workflow\n          zh_Hans: n8n 工作流的 webhook URL\n        type: string\n        required: true\n      - name: auth-type\n        label:\n          en_US: Authentication Type\n          zh_Hans: 认证类型\n        description:\n          en_US: The authentication type for the webhook call\n          zh_Hans: webhook 调用的认证类型\n        type: select\n        required: true\n        default: 'none'\n        options:\n          - name: 'none'\n            label:\n              en_US: None\n              zh_Hans: 无认证\n          - name: 'basic'\n            label:\n              en_US: Basic Auth\n              zh_Hans: 基本认证\n          - name: 'jwt'\n            label:\n              en_US: JWT\n              zh_Hans: JWT认证\n          - name: 'header'\n            label:\n              en_US: Header Auth\n              zh_Hans: 请求头认证\n      - name: basic-username\n        label:\n          en_US: Username\n          zh_Hans: 用户名\n        description:\n          en_US: The username for Basic Auth\n          zh_Hans: 基本认证的用户名\n        type: string\n        required: false\n        default: ''\n      - name: basic-password\n        label:\n          en_US: Password\n          zh_Hans: 密码\n        description:\n          en_US: The password for Basic Auth\n          zh_Hans: 基本认证的密码\n        type: string\n        required: false\n        default: ''\n      - name: jwt-secret\n        label:\n          en_US: Secret\n          zh_Hans: 密钥\n        description:\n          en_US: The secret for JWT authentication\n          zh_Hans: JWT认证的密钥\n        type: string\n        required: false\n        default: ''\n      - name: jwt-algorithm\n        label:\n          en_US: Algorithm\n          zh_Hans: 算法\n        description:\n          en_US: The algorithm for JWT authentication\n          zh_Hans: JWT认证的算法\n        type: string\n        required: false\n        default: 'HS256'\n      - name: header-name\n        label:\n          en_US: Header Name\n          zh_Hans: 请求头名称\n        description:\n          en_US: The header name for Header Auth\n          zh_Hans: 请求头认证的名称\n        type: string\n        required: false\n        default: ''\n      - name: header-value\n        label:\n          en_US: Header Value\n          zh_Hans: 请求头值\n        description:\n          en_US: The header value for Header Auth\n          zh_Hans: 请求头认证的值\n        type: string\n        required: false\n        default: ''\n      - name: timeout\n        label:\n          en_US: Timeout\n          zh_Hans: 超时时间\n        description:\n          en_US: The timeout in seconds for the webhook call\n          zh_Hans: webhook 调用的超时时间（秒）\n        type: integer\n        required: false\n        default: 120\n      - name: output-key\n        label:\n          en_US: Output Key\n          zh_Hans: 输出键名\n        description:\n          en_US: The key name of the output in the webhook response\n          zh_Hans: webhook 响应中输出内容的键名\n        type: string\n        required: false\n        default: 'response'\n  - name: langflow-api\n    label:\n      en_US: Langflow API\n      zh_Hans: Langflow API\n    description:\n      en_US: Configure the Langflow API of the pipeline, call the Langflow flow through the `Simplified Run Flow` interface\n      zh_Hans: 配置 Langflow API，通过 `Simplified Run Flow` 接口调用 Langflow 的流程\n    config:\n      - name: base-url\n        label:\n          en_US: Base URL\n          zh_Hans: 基础 URL\n        description:\n          en_US: The base URL of the Langflow server\n          zh_Hans: Langflow 服务器的基础 URL\n        type: string\n        required: true\n      - name: api-key\n        label:\n          en_US: API Key\n          zh_Hans: API 密钥\n        description:\n          en_US: The API key for the Langflow server\n          zh_Hans: Langflow 服务器的 API 密钥\n        type: string\n        required: true\n      - name: flow-id\n        label:\n          en_US: Flow ID\n          zh_Hans: 流程 ID\n        description:\n          en_US: The ID of the flow to run\n          zh_Hans: 要运行的流程 ID\n        type: string\n        required: true\n      - name: input-type\n        label:\n          en_US: Input Type\n          zh_Hans: 输入类型\n        description:\n          en_US: The input type for the flow\n          zh_Hans: 流程的输入类型\n        type: string\n        required: false\n        default: 'chat'\n      - name: output-type\n        label:\n          en_US: Output Type\n          zh_Hans: 输出类型\n        description:\n          en_US: The output type for the flow\n          zh_Hans: 流程的输出类型\n        type: string\n        required: false\n        default: 'chat'\n      - name: tweaks\n        label:\n          en_US: Tweaks\n          zh_Hans: 调整参数\n        description:\n          en_US: Optional tweaks to apply to the flow\n          zh_Hans: 可选的流程调整参数\n        type: json\n        required: false\n        default: '{}'\n  - name: coze-api\n    label:\n      en_US: coze API\n      zh_Hans: 扣子 API\n    description:\n      en_US: Configure the Coze API of the pipeline\n      zh_Hans: 配置Coze API\n    config:\n      - name: api-key\n        label:\n          en_US: API Key\n          zh_Hans: API 密钥\n        description:\n          en_US: The API key for the Coze server\n          zh_Hans: Coze服务器的 API 密钥\n        type: string\n        required: true\n      - name: bot-id\n        label:\n          en_US: Bot ID\n          zh_Hans: 机器人 ID\n        description:\n          en_US: The ID of the bot to run\n          zh_Hans: 要运行的机器人 ID\n        type: string\n        required: true\n      - name: api-base\n        label:\n          en_US: API Base URL\n          zh_Hans: API 基础 URL\n        description:\n          en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).\n          zh_Hans: Coze API 的基础 URL，请使用 https://api.coze.com 用于全球 Coze 版（coze.com）\n        type: string\n        default: \"https://api.coze.cn\"\n      - name: auto-save-history\n        label:\n          en_US: Auto Save History\n          zh_Hans: 自动保存历史\n        description:\n          en_US: Whether to automatically save conversation history\n          zh_Hans: 是否自动保存对话历史\n        type: boolean\n        default: true\n      - name: timeout\n        label:\n          en_US: Request Timeout\n          zh_Hans: 请求超时\n        description:\n          en_US: Timeout in seconds for API requests\n          zh_Hans: API 请求超时时间（秒）\n        type: number\n        default: 120"
  },
  {
    "path": "src/langbot/templates/metadata/pipeline/output.yaml",
    "content": "name: output\nlabel:\n  en_US: Output Processing\n  zh_Hans: 输出处理\nstages:\n  - name: long-text-processing\n    label:\n      en_US: Long Text Processing\n      zh_Hans: 长文本处理\n    config:\n      - name: threshold\n        label:\n          en_US: Threshold\n          zh_Hans: 阈值\n        description:\n          en_US: The threshold of the long text\n          zh_Hans: 超过此长度的文本将被处理\n        type: integer\n        required: true\n        default: 1000\n      - name: strategy\n        label:\n          en_US: Strategy\n          zh_Hans: 策略\n        description:\n          en_US: The strategy of the long text\n          zh_Hans: 长文本的处理策略\n        type: select\n        required: true\n        default: none\n        options:\n          - name: forward\n            label:\n              en_US: Forward Message Component\n              zh_Hans: 转换为转发消息组件（部分平台不支持）\n          - name: image\n            label:\n              en_US: Convert to Image\n              zh_Hans: 转换为图片\n          - name: none\n            label:\n              en_US: None\n              zh_Hans: 不处理\n      - name: font-path\n        label:\n          en_US: Font Path\n          zh_Hans: 字体路径\n        description:\n          en_US: The path of the font to be used when converting to image\n          zh_Hans: 选用转换为图片时，所使用的字体路径\n        type: string\n        required: false\n        default: ''\n  - name: force-delay\n    label:\n      en_US: Force Delay\n      zh_Hans: 强制延迟\n    description:\n      en_US: Force the output to be delayed for a while\n      zh_Hans: 强制延迟一段时间后再回复给用户\n    config:\n      - name: min\n        label:\n          en_US: Min Seconds\n          zh_Hans: 最小秒数\n        type: integer\n        required: true\n        default: 0\n      - name: max\n        label:\n          en_US: Max Seconds\n          zh_Hans: 最大秒数\n        type: integer\n        required: true\n        default: 0\n  - name: misc\n    label:\n      en_US: Misc\n      zh_Hans: 杂项\n    config:\n      - name: exception-handling\n        label:\n          en_US: Exception Handling Strategy\n          zh_Hans: 异常处理策略\n        description:\n          en_US: Controls how error messages are displayed to the user when an AI request fails\n          zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式\n        type: select\n        required: true\n        default: show-hint\n        options:\n          - name: show-error\n            label:\n              en_US: Show Full Error\n              zh_Hans: 显示完整报错信息\n          - name: show-hint\n            label:\n              en_US: Show Failure Hint\n              zh_Hans: 仅文字提示\n          - name: hide\n            label:\n              en_US: Hide All\n              zh_Hans: 不显示任何异常信息\n      - name: failure-hint\n        label:\n          en_US: Failure Hint Text\n          zh_Hans: 失败提示文本\n        description:\n          en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to \"Show Failure Hint\"\n          zh_Hans: 请求失败时显示的提示文本，仅在异常处理策略设置为\"仅文字提示\"时生效\n        type: string\n        required: false\n        default: 'Request failed.'\n      - name: at-sender\n        label:\n          en_US: At Sender\n          zh_Hans: 在群聊回复中@发送者\n        type: boolean\n        required: true\n        default: true\n      - name: quote-origin\n        label:\n          en_US: Quote Origin Message\n          zh_Hans: 引用原消息\n        type: boolean\n        required: true\n        default: false\n      - name: track-function-calls\n        label:\n          en_US: Track Function Calls\n          zh_Hans: 跟踪函数调用\n        description:\n          en_US: If enabled, the function calls will be tracked and output to the user\n          zh_Hans: 启用后，Agent 每次调用工具时都会输出一个提示给用户\n        type: boolean\n        required: true\n        default: false\n      - name: remove-think\n        label:\n          en_US: Remove CoT\n          zh_Hans: 删除思维链\n        description:\n          en_US: 'If enabled, LangBot will remove the LLM thought content in response. Note: When using streaming response, removing CoT may cause the first token to wait for a long time.'\n          zh_Hans: '如果启用，将自动删除大模型回复中的模型思考内容。注意：当您使用流式响应时，删除思维链可能会导致首个 Token 的等待时间过长'\n        type: boolean\n        required: true\n        default: false\n\n"
  },
  {
    "path": "src/langbot/templates/metadata/pipeline/safety.yaml",
    "content": "name: safety\nlabel:\n  en_US: Safety Control\n  zh_Hans: 安全控制\nstages:\n  - name: content-filter\n    label:\n      en_US: Content Filter\n      zh_Hans: 内容过滤\n    config:\n      - name: scope\n        label:\n          en_US: Scope\n          zh_Hans: 检查范围\n        type: select\n        required: true\n        default: all\n        options:\n          - name: all\n            label:\n              en_US: All\n              zh_Hans: 全部\n          - name: income-msg\n            label:\n              en_US: Income Message\n              zh_Hans: 传入消息（用户消息）\n          - name: output-msg\n            label:\n              en_US: Output Message\n              zh_Hans: 传出消息（机器人消息）\n      - name: check-sensitive-words\n        label:\n          en_US: Check Sensitive Words\n          zh_Hans: 检查敏感词\n        description:\n          en_US: Sensitive words can be configured in data/metadata/sensitive-words.json\n          zh_Hans: 敏感词内容可以在 data/metadata/sensitive-words.json 中配置\n        type: boolean\n        required: true\n        default: false\n  - name: rate-limit\n    label:\n      en_US: Rate Limit\n      zh_Hans: 速率限制\n    config:\n      - name: window-length\n        label:\n          en_US: Window Length\n          zh_Hans: 窗口长度（秒）\n        type: integer\n        required: true\n        default: 60\n      - name: limitation\n        label:\n          en_US: Limitation\n          zh_Hans: 限制次数\n        type: integer\n        required: true\n        default: 60\n      - name: strategy\n        label:\n          en_US: Strategy\n          zh_Hans: 策略\n        type: select\n        required: true\n        default: drop\n        options:\n          - name: drop\n            label:\n              en_US: Drop\n              zh_Hans: 丢弃\n          - name: wait\n            label:\n              en_US: Wait\n              zh_Hans: 等待"
  },
  {
    "path": "src/langbot/templates/metadata/pipeline/trigger.yaml",
    "content": "name: trigger\nlabel:\n  en_US: Trigger\n  zh_Hans: 触发条件\nstages:\n  - name: group-respond-rules\n    label:\n      en_US: Group Respond Rule\n      zh_Hans: 群响应规则\n    description:\n      en_US: The respond rule of the messages in the groups\n      zh_Hans: 群内消息的响应规则\n    config:\n      - name: at\n        label:\n          en_US: At\n          zh_Hans: '@'\n        description:\n          en_US: Whether to trigger when the message mentions the bot\n          zh_Hans: 是否在消息@机器人时触发\n        type: boolean\n        required: true\n        default: false\n      - name: prefix\n        label:\n          en_US: Prefix\n          zh_Hans: 前缀\n        description:\n          en_US: Messages with these prefixes will be responded (the prefixes will be removed automatically when sending to AI)\n          zh_Hans: 具有这些前缀的消息将被响应（发送给 AI 时会自动去除对应前缀）\n        type: array[string]\n        required: true\n        default: []\n      - name: regexp\n        label:\n          en_US: Regexp\n          zh_Hans: 正则表达式\n        description:\n          en_US: Messages with these regular expressions will be responded\n          zh_Hans: 符合这些正则表达式的消息将被响应\n        type: array[string]\n        required: true\n        default: []\n      - name: random\n        label:\n          en_US: Random\n          zh_Hans: 随机\n        description:\n          en_US: 'Probability of automatically responding to messages that are not matched by other rules. Range: 0.0-1.0 (0%-100%).'\n          zh_Hans: '自动响应其他规则未匹配的消息的概率。范围：0.0-1.0 (0%-100%)。'\n        type: float\n        required: false\n        default: 0\n  - name: access-control\n    label:\n      en_US: Access Control\n      zh_Hans: 访问控制\n    config:\n      - name: mode\n        label:\n          en_US: Mode\n          zh_Hans: 模式\n        description:\n          en_US: The mode of the access control\n          zh_Hans: 访问控制模式\n        type: select\n        required: true\n        default: blacklist\n        options:\n          - name: blacklist\n            label:\n              en_US: Blacklist\n              zh_Hans: 黑名单\n          - name: whitelist\n            label:\n              en_US: Whitelist\n              zh_Hans: 白名单\n      - name: blacklist\n        label:\n          en_US: Blacklist\n          zh_Hans: 黑名单\n        description:\n          en_US: Sessions in the blacklist will be ignored, the format is `{launcher_type}_{launcher_id}`（remove quotes）, for example `person_123` matches private chat, `group_456` matches group chat, `person_*` matches all private chats, `group_*` matches all group chats, `*_123` matches private and group chats with user ID 123\n          zh_Hans: 黑名单中的会话将被忽略；会话格式：`{launcher_type}_{launcher_id}`（删除引号），例如 `person_123` 匹配私聊会话，`group_456` 匹配群聊会话；`person_*` 匹配所有私聊会话，`group_*` 匹配所有群聊会话；`*_123` 匹配用户 ID 为 123 的私聊和群聊消息\n        type: array[string]\n        required: true\n        default: []\n      - name: whitelist\n        label:\n          en_US: Whitelist\n          zh_Hans: 白名单\n        description:\n          en_US: Only respond to sessions in the whitelist, the format is `{launcher_type}_{launcher_id}`（remove quotes）, for example `person_123` matches private chat, `group_456` matches group chat, `person_*` matches all private chats, `group_*` matches all group chats, `*_123` matches private and group chats with user ID 123\n          zh_Hans: 仅响应白名单中的会话；会话格式：`{launcher_type}_{launcher_id}`（删除引号），例如 `person_123` 匹配私聊会话，`group_456` 匹配群聊会话；`person_*` 匹配所有私聊会话，`group_*` 匹配所有群聊会话；`*_123` 匹配用户 ID 为 123 的私聊和群聊消息\n        type: array[string]\n        required: true\n        default: []\n  - name: ignore-rules\n    label:\n      en_US: Ignore Rules\n      zh_Hans: 消息忽略规则\n    description:\n      en_US: Ignore rules that apply to both group and private messages\n      zh_Hans: 对群聊、私聊消息均适用的忽略规则（优先级高于群响应规则）\n    config:\n      - name: prefix\n        label:\n          en_US: Prefix\n          zh_Hans: 前缀\n        description:\n          en_US: Messages with these prefixes will be ignored\n          zh_Hans: 包含这些前缀的消息将被忽略\n        type: array[string]\n        required: true\n        default: []\n      - name: regexp\n        label:\n          en_US: Regexp\n          zh_Hans: 正则表达式\n        description:\n          en_US: Messages with these regular expressions will be ignored\n          zh_Hans: 符合这些正则表达式的消息将被忽略\n        type: array[string]\n        required: true\n        default: []\n  - name: message-aggregation\n    label:\n      en_US: Message Aggregation\n      zh_Hans: 消息聚合\n    description:\n      en_US: When a user sends multiple messages consecutively, wait for a period and merge them into one before processing\n      zh_Hans: 当用户连续发送多条消息时，等待一段时间后合并为一条消息再处理（防抖）\n    config:\n      - name: enabled\n        label:\n          en_US: Enable Message Aggregation\n          zh_Hans: 启用消息聚合\n        description:\n          en_US: If enabled, consecutive messages from the same user will be merged after a delay\n          zh_Hans: 如果启用，同一用户连续发送的消息将在延迟后合并处理\n        type: boolean\n        required: true\n        default: false\n      - name: delay\n        label:\n          en_US: Aggregation Delay (seconds)\n          zh_Hans: 聚合延迟（秒）\n        description:\n          en_US: 'Wait time before merging messages. Range: 1.0-10.0 seconds.'\n          zh_Hans: '合并消息前的等待时间。范围：1.0-10.0 秒。'\n        type: float\n        required: true\n        default: 1.5\n  - name: misc\n    label:\n      en_US: Misc\n      zh_Hans: 杂项\n    config:\n      - name: combine-quote-message\n        label:\n          en_US: Combine Quote Message\n          zh_Hans: 合并引用消息\n        description:\n          en_US: If enabled, the bot will combine the quote message with the user's message\n          zh_Hans: 如果启用，将合并引用消息与用户发送的消息\n        type: boolean\n        required: true\n        default: true\n"
  },
  {
    "path": "src/langbot/templates/metadata/sensitive-words.json",
    "content": "{\n    \"说明\": \"mask将替换敏感词中的每一个字，若mask_word值不为空，则将敏感词整个替换为mask_word的值\",\n    \"mask\": \"*\",\n    \"mask_word\": \"\",\n    \"words\": []\n}"
  },
  {
    "path": "tests/README.md",
    "content": "# LangBot Test Suite\n\nThis directory contains the test suite for LangBot, with a focus on comprehensive unit testing of pipeline stages.\n\n## Important Note\n\nDue to circular import dependencies in the pipeline module structure, the test files use **lazy imports** via `importlib.import_module()` instead of direct imports. This ensures tests can run without triggering circular import errors.\n\n## Structure\n\n```\ntests/\n├── pipeline/                      # Pipeline stage tests\n│   ├── conftest.py               # Shared fixtures and test infrastructure\n│   ├── test_simple.py            # Basic infrastructure tests (always pass)\n│   ├── test_bansess.py           # BanSessionCheckStage tests\n│   ├── test_ratelimit.py         # RateLimit stage tests\n│   ├── test_preproc.py           # PreProcessor stage tests\n│   ├── test_respback.py          # SendResponseBackStage tests\n│   ├── test_resprule.py          # GroupRespondRuleCheckStage tests\n│   ├── test_pipelinemgr.py       # PipelineManager tests\n│   └── test_stages_integration.py # Integration tests\n└── README.md                      # This file\n```\n\n## Test Architecture\n\n### Fixtures (`conftest.py`)\n\nThe test suite uses a centralized fixture system that provides:\n\n- **MockApplication**: Comprehensive mock of the Application object with all dependencies\n- **Mock objects**: Pre-configured mocks for Session, Conversation, Model, Adapter\n- **Sample data**: Ready-to-use Query objects, message chains, and configurations\n- **Helper functions**: Utilities for creating results and common assertions\n\n### Design Principles\n\n1. **Isolation**: Each test is independent and doesn't rely on external systems\n2. **Mocking**: All external dependencies are mocked to ensure fast, reliable tests\n3. **Coverage**: Tests cover happy paths, edge cases, and error conditions\n4. **Extensibility**: Easy to add new tests by reusing existing fixtures\n\n## Running Tests\n\n### Using the test runner script (recommended)\n```bash\nbash run_tests.sh\n```\n\nThis script automatically:\n- Activates the virtual environment\n- Installs test dependencies if needed\n- Runs tests with coverage\n- Generates HTML coverage report\n\n### Manual test execution\n\n#### Run all tests\n```bash\npytest tests/pipeline/\n```\n\n#### Run only simple tests (no imports, always pass)\n```bash\npytest tests/pipeline/test_simple.py -v\n```\n\n#### Run specific test file\n```bash\npytest tests/pipeline/test_bansess.py -v\n```\n\n#### Run with coverage\n```bash\npytest tests/pipeline/ --cov=pkg/pipeline --cov-report=html\n```\n\n#### Run specific test\n```bash\npytest tests/pipeline/test_bansess.py::test_bansess_whitelist_allow -v\n```\n\n### Known Issues\n\nSome tests may encounter circular import errors. This is a known issue with the current module structure. The test infrastructure is designed to work around this using lazy imports, but if you encounter issues:\n\n1. Make sure you're running from the project root directory\n2. Ensure the virtual environment is activated\n3. Try running `test_simple.py` first to verify the test infrastructure works\n\n## CI/CD Integration\n\nTests are automatically run on:\n- Pull request opened\n- Pull request marked ready for review\n- Push to PR branch\n- Push to master/develop branches\n\nThe workflow runs tests on Python 3.10, 3.11, and 3.12 to ensure compatibility.\n\n## Adding New Tests\n\n### 1. For a new pipeline stage\n\nCreate a new test file `test_<stage_name>.py`:\n\n```python\n\"\"\"\n<StageName> stage unit tests\n\"\"\"\n\nimport pytest\nfrom pkg.pipeline.<module>.<stage> import <StageClass>\nfrom pkg.pipeline import entities as pipeline_entities\n\n\n@pytest.mark.asyncio\nasync def test_stage_basic_flow(mock_app, sample_query):\n    \"\"\"Test basic flow\"\"\"\n    stage = <StageClass>(mock_app)\n    await stage.initialize({})\n\n    result = await stage.process(sample_query, '<StageName>')\n\n    assert result.result_type == pipeline_entities.ResultType.CONTINUE\n```\n\n### 2. For additional fixtures\n\nAdd new fixtures to `conftest.py`:\n\n```python\n@pytest.fixture\ndef my_custom_fixture():\n    \"\"\"Description of fixture\"\"\"\n    return create_test_data()\n```\n\n### 3. For test data\n\nUse the helper functions in `conftest.py`:\n\n```python\nfrom tests.pipeline.conftest import create_stage_result, assert_result_continue\n\nresult = create_stage_result(\n    result_type=pipeline_entities.ResultType.CONTINUE,\n    query=sample_query\n)\n\nassert_result_continue(result)\n```\n\n## Best Practices\n\n1. **Test naming**: Use descriptive names that explain what's being tested\n2. **Arrange-Act-Assert**: Structure tests clearly with setup, execution, and verification\n3. **One assertion per test**: Focus each test on a single behavior\n4. **Mock appropriately**: Mock external dependencies, not the code under test\n5. **Use fixtures**: Reuse common test data through fixtures\n6. **Document tests**: Add docstrings explaining what each test validates\n\n## Troubleshooting\n\n### Import errors\nMake sure you've installed the package in development mode:\n```bash\nuv pip install -e .\n```\n\n### Async test failures\nEnsure you're using `@pytest.mark.asyncio` decorator for async tests.\n\n### Mock not working\nCheck that you're mocking at the right level and using `AsyncMock` for async functions.\n\n## Future Enhancements\n\n- [ ] Add integration tests for full pipeline execution\n- [ ] Add performance benchmarks\n- [ ] Add mutation testing for better coverage quality\n- [ ] Add property-based testing with Hypothesis\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/unit_tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/unit_tests/config/__init__.py",
    "content": "# Config unit tests\n"
  },
  {
    "path": "tests/unit_tests/config/test_env_override.py",
    "content": "\"\"\"\nTests for environment variable override functionality in YAML config\n\"\"\"\n\nimport os\nimport pytest\nfrom typing import Any\n\n\ndef _apply_env_overrides_to_config(cfg: dict) -> dict:\n    \"\"\"Apply environment variable overrides to data/config.yaml\n\n    Environment variables should be uppercase and use __ (double underscore)\n    to represent nested keys. For example:\n    - CONCURRENCY__PIPELINE overrides concurrency.pipeline\n    - PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url\n\n    Arrays and dict types are ignored.\n\n    Args:\n        cfg: Configuration dictionary\n\n    Returns:\n        Updated configuration dictionary\n    \"\"\"\n\n    def convert_value(value: str, original_value: Any) -> Any:\n        \"\"\"Convert string value to appropriate type based on original value\n\n        Args:\n            value: String value from environment variable\n            original_value: Original value to infer type from\n\n        Returns:\n            Converted value (falls back to string if conversion fails)\n        \"\"\"\n        if isinstance(original_value, bool):\n            return value.lower() in ('true', '1', 'yes', 'on')\n        elif isinstance(original_value, int):\n            try:\n                return int(value)\n            except ValueError:\n                # If conversion fails, keep as string (user error, but non-breaking)\n                return value\n        elif isinstance(original_value, float):\n            try:\n                return float(value)\n            except ValueError:\n                # If conversion fails, keep as string (user error, but non-breaking)\n                return value\n        else:\n            return value\n\n    # Process environment variables\n    for env_key, env_value in os.environ.items():\n        # Check if the environment variable is uppercase and contains __\n        if not env_key.isupper():\n            continue\n        if '__' not in env_key:\n            continue\n\n        # Convert environment variable name to config path\n        # e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']\n        keys = [key.lower() for key in env_key.split('__')]\n\n        # Navigate to the target value and validate the path\n        current = cfg\n\n        for i, key in enumerate(keys):\n            if not isinstance(current, dict) or key not in current:\n                break\n\n            if i == len(keys) - 1:\n                # At the final key - check if it's a scalar value\n                if isinstance(current[key], (dict, list)):\n                    # Skip dict and list types\n                    pass\n                else:\n                    # Valid scalar value - convert and set it\n                    converted_value = convert_value(env_value, current[key])\n                    current[key] = converted_value\n            else:\n                # Navigate deeper\n                current = current[key]\n\n    return cfg\n\n\nclass TestEnvOverrides:\n    \"\"\"Test environment variable override functionality\"\"\"\n\n    def test_simple_string_override(self):\n        \"\"\"Test overriding a simple string value\"\"\"\n        cfg = {'api': {'port': 5300}}\n\n        # Set environment variable\n        os.environ['API__PORT'] = '8080'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['api']['port'] == 8080\n\n        # Cleanup\n        del os.environ['API__PORT']\n\n    def test_nested_key_override(self):\n        \"\"\"Test overriding nested keys with __ delimiter\"\"\"\n        cfg = {'concurrency': {'pipeline': 20, 'session': 1}}\n\n        os.environ['CONCURRENCY__PIPELINE'] = '50'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['concurrency']['pipeline'] == 50\n        assert result['concurrency']['session'] == 1  # Unchanged\n\n        del os.environ['CONCURRENCY__PIPELINE']\n\n    def test_deep_nested_override(self):\n        \"\"\"Test overriding deeply nested keys\"\"\"\n        cfg = {'system': {'jwt': {'expire': 604800, 'secret': ''}}}\n\n        os.environ['SYSTEM__JWT__EXPIRE'] = '86400'\n        os.environ['SYSTEM__JWT__SECRET'] = 'my_secret_key'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['system']['jwt']['expire'] == 86400\n        assert result['system']['jwt']['secret'] == 'my_secret_key'\n\n        del os.environ['SYSTEM__JWT__EXPIRE']\n        del os.environ['SYSTEM__JWT__SECRET']\n\n    def test_underscore_in_key(self):\n        \"\"\"Test keys with underscores like runtime_ws_url\"\"\"\n        cfg = {'plugin': {'enable': True, 'runtime_ws_url': 'ws://localhost:5400/control/ws'}}\n\n        os.environ['PLUGIN__RUNTIME_WS_URL'] = 'ws://newhost:6000/ws'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['plugin']['runtime_ws_url'] == 'ws://newhost:6000/ws'\n\n        del os.environ['PLUGIN__RUNTIME_WS_URL']\n\n    def test_boolean_conversion(self):\n        \"\"\"Test boolean value conversion\"\"\"\n        cfg = {'plugin': {'enable': True, 'enable_marketplace': False}}\n\n        os.environ['PLUGIN__ENABLE'] = 'false'\n        os.environ['PLUGIN__ENABLE_MARKETPLACE'] = 'true'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['plugin']['enable'] is False\n        assert result['plugin']['enable_marketplace'] is True\n\n        del os.environ['PLUGIN__ENABLE']\n        del os.environ['PLUGIN__ENABLE_MARKETPLACE']\n\n    def test_ignore_dict_type(self):\n        \"\"\"Test that dict types are ignored\"\"\"\n        cfg = {'database': {'use': 'sqlite', 'sqlite': {'path': 'data/langbot.db'}}}\n\n        # Try to override a dict value - should be ignored\n        os.environ['DATABASE__SQLITE'] = 'new_value'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        # Should remain a dict, not overridden\n        assert isinstance(result['database']['sqlite'], dict)\n        assert result['database']['sqlite']['path'] == 'data/langbot.db'\n\n        del os.environ['DATABASE__SQLITE']\n\n    def test_ignore_list_type(self):\n        \"\"\"Test that list/array types are ignored\"\"\"\n        cfg = {'admins': ['admin1', 'admin2'], 'command': {'enable': True, 'prefix': ['!', '！']}}\n\n        # Try to override list values - should be ignored\n        os.environ['ADMINS'] = 'admin3'\n        os.environ['COMMAND__PREFIX'] = '?'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        # Should remain lists, not overridden\n        assert isinstance(result['admins'], list)\n        assert result['admins'] == ['admin1', 'admin2']\n        assert isinstance(result['command']['prefix'], list)\n        assert result['command']['prefix'] == ['!', '！']\n\n        del os.environ['ADMINS']\n        del os.environ['COMMAND__PREFIX']\n\n    def test_lowercase_env_var_ignored(self):\n        \"\"\"Test that lowercase environment variables are ignored\"\"\"\n        cfg = {'api': {'port': 5300}}\n\n        os.environ['api__port'] = '8080'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        # Should not be overridden\n        assert result['api']['port'] == 5300\n\n        del os.environ['api__port']\n\n    def test_no_double_underscore_ignored(self):\n        \"\"\"Test that env vars without __ are ignored\"\"\"\n        cfg = {'api': {'port': 5300}}\n\n        os.environ['APIPORT'] = '8080'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        # Should not be overridden\n        assert result['api']['port'] == 5300\n\n        del os.environ['APIPORT']\n\n    def test_nonexistent_key_ignored(self):\n        \"\"\"Test that env vars for non-existent keys are ignored\"\"\"\n        cfg = {'api': {'port': 5300}}\n\n        os.environ['API__NONEXISTENT'] = 'value'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        # Should not create new key\n        assert 'nonexistent' not in result['api']\n\n        del os.environ['API__NONEXISTENT']\n\n    def test_integer_conversion(self):\n        \"\"\"Test integer value conversion\"\"\"\n        cfg = {'concurrency': {'pipeline': 20}}\n\n        os.environ['CONCURRENCY__PIPELINE'] = '100'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['concurrency']['pipeline'] == 100\n        assert isinstance(result['concurrency']['pipeline'], int)\n\n        del os.environ['CONCURRENCY__PIPELINE']\n\n    def test_multiple_overrides(self):\n        \"\"\"Test multiple environment variable overrides at once\"\"\"\n        cfg = {'api': {'port': 5300}, 'concurrency': {'pipeline': 20, 'session': 1}, 'plugin': {'enable': False}}\n\n        os.environ['API__PORT'] = '8080'\n        os.environ['CONCURRENCY__PIPELINE'] = '50'\n        os.environ['PLUGIN__ENABLE'] = 'true'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['api']['port'] == 8080\n        assert result['concurrency']['pipeline'] == 50\n        assert result['plugin']['enable'] is True\n\n        del os.environ['API__PORT']\n        del os.environ['CONCURRENCY__PIPELINE']\n        del os.environ['PLUGIN__ENABLE']\n\n\nif __name__ == '__main__':\n    pytest.main([__file__, '-v'])\n"
  },
  {
    "path": "tests/unit_tests/config/test_webhook_display_prefix.py",
    "content": "\"\"\"\nTests for webhook_prefix configuration\n\"\"\"\n\nimport os\nimport pytest\nfrom typing import Any\n\n\ndef _apply_env_overrides_to_config(cfg: dict) -> dict:\n    \"\"\"Apply environment variable overrides to data/config.yaml\n\n    Environment variables should be uppercase and use __ (double underscore)\n    to represent nested keys. For example:\n    - CONCURRENCY__PIPELINE overrides concurrency.pipeline\n    - PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url\n\n    Arrays and dict types are ignored.\n\n    Args:\n        cfg: Configuration dictionary\n\n    Returns:\n        Updated configuration dictionary\n    \"\"\"\n\n    def convert_value(value: str, original_value: Any) -> Any:\n        \"\"\"Convert string value to appropriate type based on original value\n\n        Args:\n            value: String value from environment variable\n            original_value: Original value to infer type from\n\n        Returns:\n            Converted value (falls back to string if conversion fails)\n        \"\"\"\n        if isinstance(original_value, bool):\n            return value.lower() in ('true', '1', 'yes', 'on')\n        elif isinstance(original_value, int):\n            try:\n                return int(value)\n            except ValueError:\n                # If conversion fails, keep as string (user error, but non-breaking)\n                return value\n        elif isinstance(original_value, float):\n            try:\n                return float(value)\n            except ValueError:\n                # If conversion fails, keep as string (user error, but non-breaking)\n                return value\n        else:\n            return value\n\n    # Process environment variables\n    for env_key, env_value in os.environ.items():\n        # Check if the environment variable is uppercase and contains __\n        if not env_key.isupper():\n            continue\n        if '__' not in env_key:\n            continue\n\n        # Convert environment variable name to config path\n        # e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']\n        keys = [key.lower() for key in env_key.split('__')]\n\n        # Navigate to the target value and validate the path\n        current = cfg\n\n        for i, key in enumerate(keys):\n            if not isinstance(current, dict) or key not in current:\n                break\n\n            if i == len(keys) - 1:\n                # At the final key - check if it's a scalar value\n                if isinstance(current[key], (dict, list)):\n                    # Skip dict and list types\n                    pass\n                else:\n                    # Valid scalar value - convert and set it\n                    converted_value = convert_value(env_value, current[key])\n                    current[key] = converted_value\n            else:\n                # Navigate deeper\n                current = current[key]\n\n    return cfg\n\n\nclass TestWebhookDisplayPrefix:\n    \"\"\"Test webhook_prefix configuration functionality\"\"\"\n\n    def test_default_webhook_prefix(self):\n        \"\"\"Test that the default webhook display prefix is correctly set\"\"\"\n        cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}\n\n        # Should have the default value\n        assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'\n        assert cfg['api']['extra_webhook_prefix'] == ''\n\n    def test_webhook_prefix_env_override(self):\n        \"\"\"Test overriding webhook_prefix via environment variable\"\"\"\n        cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}\n\n        # Set environment variable\n        os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['api']['webhook_prefix'] == 'https://example.com:8080'\n\n        # Cleanup\n        del os.environ['API__WEBHOOK_PREFIX']\n\n    def test_webhook_prefix_with_custom_domain(self):\n        \"\"\"Test webhook_prefix with custom domain\"\"\"\n        cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}\n\n        # Set to a custom domain\n        os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['api']['webhook_prefix'] == 'https://bot.mycompany.com'\n\n        # Cleanup\n        del os.environ['API__WEBHOOK_PREFIX']\n\n    def test_webhook_prefix_with_subdirectory(self):\n        \"\"\"Test webhook_prefix with subdirectory path\"\"\"\n        cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}\n\n        # Set to a URL with subdirectory\n        os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['api']['webhook_prefix'] == 'https://example.com/langbot'\n\n        # Cleanup\n        del os.environ['API__WEBHOOK_PREFIX']\n\n    def test_extra_webhook_prefix_default_empty(self):\n        \"\"\"Test that extra_webhook_prefix defaults to empty string\"\"\"\n        cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}\n\n        bot_uuid = 'test-bot-uuid'\n        webhook_prefix = cfg['api'].get('webhook_prefix', 'http://127.0.0.1:5300')\n        extra_webhook_prefix = cfg['api'].get('extra_webhook_prefix', '')\n        webhook_url = f'/bots/{bot_uuid}'\n\n        assert f'{webhook_prefix}{webhook_url}' == 'http://127.0.0.1:5300/bots/test-bot-uuid'\n        # extra should be empty when not configured\n        assert extra_webhook_prefix == ''\n\n    def test_extra_webhook_prefix_env_override(self):\n        \"\"\"Test overriding extra_webhook_prefix via environment variable\"\"\"\n        cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}\n\n        os.environ['API__EXTRA_WEBHOOK_PREFIX'] = 'https://extra.example.com'\n\n        result = _apply_env_overrides_to_config(cfg)\n\n        assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com'\n\n        bot_uuid = 'test-bot-uuid'\n        extra_prefix = result['api']['extra_webhook_prefix']\n        webhook_url = f'/bots/{bot_uuid}'\n        assert f'{extra_prefix}{webhook_url}' == 'https://extra.example.com/bots/test-bot-uuid'\n\n        # Cleanup\n        del os.environ['API__EXTRA_WEBHOOK_PREFIX']\n\n\nif __name__ == '__main__':\n    pytest.main([__file__, '-v'])\n"
  },
  {
    "path": "tests/unit_tests/pipeline/__init__.py",
    "content": ""
  },
  {
    "path": "tests/unit_tests/pipeline/conftest.py",
    "content": "\"\"\"\nShared test fixtures and configuration\n\nThis file provides infrastructure for all pipeline tests, including:\n- Mock object factories\n- Test fixtures\n- Common test helper functions\n\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\nfrom unittest.mock import AsyncMock, Mock\n\nimport langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\nimport langbot_plugin.api.entities.builtin.platform.events as platform_events\nimport langbot_plugin.api.entities.builtin.platform.entities as platform_entities\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\n\nfrom langbot.pkg.pipeline import entities as pipeline_entities\n\n\nclass MockApplication:\n    \"\"\"Mock Application object providing all basic dependencies needed by stages\"\"\"\n\n    def __init__(self):\n        self.logger = self._create_mock_logger()\n        self.sess_mgr = self._create_mock_session_manager()\n        self.model_mgr = self._create_mock_model_manager()\n        self.tool_mgr = self._create_mock_tool_manager()\n        self.plugin_connector = self._create_mock_plugin_connector()\n        self.persistence_mgr = self._create_mock_persistence_manager()\n        self.query_pool = self._create_mock_query_pool()\n        self.instance_config = self._create_mock_instance_config()\n        self.task_mgr = self._create_mock_task_manager()\n\n    def _create_mock_logger(self):\n        logger = Mock()\n        logger.debug = Mock()\n        logger.info = Mock()\n        logger.error = Mock()\n        logger.warning = Mock()\n        return logger\n\n    def _create_mock_session_manager(self):\n        sess_mgr = AsyncMock()\n        sess_mgr.get_session = AsyncMock()\n        sess_mgr.get_conversation = AsyncMock()\n        return sess_mgr\n\n    def _create_mock_model_manager(self):\n        model_mgr = AsyncMock()\n        model_mgr.get_model_by_uuid = AsyncMock()\n        return model_mgr\n\n    def _create_mock_tool_manager(self):\n        tool_mgr = AsyncMock()\n        tool_mgr.get_all_tools = AsyncMock(return_value=[])\n        return tool_mgr\n\n    def _create_mock_plugin_connector(self):\n        plugin_connector = AsyncMock()\n        plugin_connector.emit_event = AsyncMock()\n        return plugin_connector\n\n    def _create_mock_persistence_manager(self):\n        persistence_mgr = AsyncMock()\n        persistence_mgr.execute_async = AsyncMock()\n        return persistence_mgr\n\n    def _create_mock_query_pool(self):\n        query_pool = Mock()\n        query_pool.cached_queries = {}\n        query_pool.queries = []\n        query_pool.condition = AsyncMock()\n        return query_pool\n\n    def _create_mock_instance_config(self):\n        instance_config = Mock()\n        instance_config.data = {\n            'command': {'prefix': ['/', '!'], 'enable': True},\n            'concurrency': {'pipeline': 10},\n        }\n        return instance_config\n\n    def _create_mock_task_manager(self):\n        task_mgr = Mock()\n        task_mgr.create_task = Mock()\n        return task_mgr\n\n\n@pytest.fixture\ndef mock_app():\n    \"\"\"Provides Mock Application instance\"\"\"\n    return MockApplication()\n\n\n@pytest.fixture\ndef mock_session():\n    \"\"\"Provides Mock Session object\"\"\"\n    session = Mock()\n    session.launcher_type = provider_session.LauncherTypes.PERSON\n    session.launcher_id = 12345\n    session._semaphore = AsyncMock()\n    session._semaphore.locked = Mock(return_value=False)\n    session._semaphore.acquire = AsyncMock()\n    session._semaphore.release = AsyncMock()\n    return session\n\n\n@pytest.fixture\ndef mock_conversation():\n    \"\"\"Provides Mock Conversation object\"\"\"\n    conversation = Mock()\n    conversation.uuid = 'test-conversation-uuid'\n\n    # Create mock prompt with copy method\n    mock_prompt = Mock()\n    mock_prompt.messages = []\n    mock_prompt.copy = Mock(return_value=Mock(messages=[]))\n    conversation.prompt = mock_prompt\n\n    # Create mock messages list with copy method\n    mock_messages = Mock()\n    mock_messages.copy = Mock(return_value=[])\n    conversation.messages = mock_messages\n\n    return conversation\n\n\n@pytest.fixture\ndef mock_model():\n    \"\"\"Provides Mock Model object\"\"\"\n    model = Mock()\n    model.model_entity = Mock()\n    model.model_entity.uuid = 'test-model-uuid'\n    model.model_entity.abilities = ['func_call', 'vision']\n    return model\n\n\n@pytest.fixture\ndef mock_adapter():\n    \"\"\"Provides Mock Adapter object\"\"\"\n    adapter = AsyncMock()\n    adapter.is_stream_output_supported = AsyncMock(return_value=False)\n    adapter.reply_message = AsyncMock()\n    adapter.reply_message_chunk = AsyncMock()\n    return adapter\n\n\n@pytest.fixture\ndef sample_message_chain():\n    \"\"\"Provides sample message chain\"\"\"\n    return platform_message.MessageChain(\n        [\n            platform_message.Plain(text='Hello, this is a test message'),\n        ]\n    )\n\n\n@pytest.fixture\ndef sample_message_event(sample_message_chain):\n    \"\"\"Provides sample message event (FriendMessage)\"\"\"\n    sender = platform_entities.Friend(\n        id=12345,\n        nickname='TestUser',\n        remark=None,\n    )\n    return platform_events.FriendMessage(\n        type='FriendMessage',\n        sender=sender,\n        message_chain=sample_message_chain,\n        time=1609459200,  # 2021-01-01 00:00:00\n    )\n\n\n@pytest.fixture\ndef sample_query(sample_message_chain, sample_message_event, mock_adapter):\n    \"\"\"Provides sample Query object - using model_construct to bypass validation\"\"\"\n    import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query\n\n    # Use model_construct to bypass Pydantic validation for test purposes\n    query = pipeline_query.Query.model_construct(\n        query_id='test-query-id',\n        launcher_type=provider_session.LauncherTypes.PERSON,\n        launcher_id=12345,\n        sender_id=12345,\n        message_chain=sample_message_chain,\n        message_event=sample_message_event,\n        adapter=mock_adapter,\n        pipeline_uuid='test-pipeline-uuid',\n        bot_uuid='test-bot-uuid',\n        pipeline_config={\n            'ai': {\n                'runner': {'runner': 'local-agent'},\n                'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},\n            },\n            'output': {'misc': {'at-sender': False, 'quote-origin': False}},\n            'trigger': {'misc': {'combine-quote-message': False}},\n        },\n        session=None,\n        prompt=None,\n        messages=[],\n        user_message=None,\n        use_funcs=[],\n        use_llm_model_uuid=None,\n        variables={},\n        resp_messages=[],\n        resp_message_chain=None,\n        current_stage_name=None,\n    )\n    return query\n\n\n@pytest.fixture\ndef sample_pipeline_config():\n    \"\"\"Provides sample pipeline configuration\"\"\"\n    return {\n        'ai': {\n            'runner': {'runner': 'local-agent'},\n            'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},\n        },\n        'output': {'misc': {'at-sender': False, 'quote-origin': False}},\n        'trigger': {'misc': {'combine-quote-message': False}},\n        'ratelimit': {'enable': True, 'algo': 'fixwin', 'window': 60, 'limit': 10},\n    }\n\n\ndef create_stage_result(\n    result_type: pipeline_entities.ResultType,\n    query: pipeline_query.Query,\n    user_notice: str = '',\n    console_notice: str = '',\n    debug_notice: str = '',\n    error_notice: str = '',\n) -> pipeline_entities.StageProcessResult:\n    \"\"\"Helper function to create stage process result\"\"\"\n    return pipeline_entities.StageProcessResult(\n        result_type=result_type,\n        new_query=query,\n        user_notice=user_notice,\n        console_notice=console_notice,\n        debug_notice=debug_notice,\n        error_notice=error_notice,\n    )\n\n\ndef assert_result_continue(result: pipeline_entities.StageProcessResult):\n    \"\"\"Assert result is CONTINUE type\"\"\"\n    assert result.result_type == pipeline_entities.ResultType.CONTINUE\n\n\ndef assert_result_interrupt(result: pipeline_entities.StageProcessResult):\n    \"\"\"Assert result is INTERRUPT type\"\"\"\n    assert result.result_type == pipeline_entities.ResultType.INTERRUPT\n"
  },
  {
    "path": "tests/unit_tests/pipeline/test_bansess.py",
    "content": "\"\"\"\nBanSessionCheckStage unit tests\n\nTests the actual BanSessionCheckStage implementation from pkg.pipeline.bansess\n\"\"\"\n\nimport pytest\nfrom importlib import import_module\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\n\n\ndef get_modules():\n    \"\"\"Lazy import to ensure proper initialization order\"\"\"\n    # Import pipelinemgr first to trigger proper stage registration\n    bansess = import_module('langbot.pkg.pipeline.bansess.bansess')\n    entities = import_module('langbot.pkg.pipeline.entities')\n    return bansess, entities\n\n\n@pytest.mark.asyncio\nasync def test_whitelist_allow(mock_app, sample_query):\n    \"\"\"Test whitelist allows matching session\"\"\"\n    bansess, entities = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.PERSON\n    sample_query.launcher_id = '12345'\n    sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['person_12345']}}}\n\n    stage = bansess.BanSessionCheckStage(mock_app)\n    await stage.initialize(sample_query.pipeline_config)\n\n    result = await stage.process(sample_query, 'BanSessionCheckStage')\n\n    assert result.result_type == entities.ResultType.CONTINUE\n    assert result.new_query == sample_query\n\n\n@pytest.mark.asyncio\nasync def test_whitelist_deny(mock_app, sample_query):\n    \"\"\"Test whitelist denies non-matching session\"\"\"\n    bansess, entities = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.PERSON\n    sample_query.launcher_id = '99999'\n    sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['person_12345']}}}\n\n    stage = bansess.BanSessionCheckStage(mock_app)\n    await stage.initialize(sample_query.pipeline_config)\n\n    result = await stage.process(sample_query, 'BanSessionCheckStage')\n\n    assert result.result_type == entities.ResultType.INTERRUPT\n\n\n@pytest.mark.asyncio\nasync def test_blacklist_allow(mock_app, sample_query):\n    \"\"\"Test blacklist allows non-matching session\"\"\"\n    bansess, entities = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.PERSON\n    sample_query.launcher_id = '12345'\n    sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'blacklist', 'blacklist': ['person_99999']}}}\n\n    stage = bansess.BanSessionCheckStage(mock_app)\n    await stage.initialize(sample_query.pipeline_config)\n\n    result = await stage.process(sample_query, 'BanSessionCheckStage')\n\n    assert result.result_type == entities.ResultType.CONTINUE\n\n\n@pytest.mark.asyncio\nasync def test_blacklist_deny(mock_app, sample_query):\n    \"\"\"Test blacklist denies matching session\"\"\"\n    bansess, entities = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.PERSON\n    sample_query.launcher_id = '12345'\n    sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'blacklist', 'blacklist': ['person_12345']}}}\n\n    stage = bansess.BanSessionCheckStage(mock_app)\n    await stage.initialize(sample_query.pipeline_config)\n\n    result = await stage.process(sample_query, 'BanSessionCheckStage')\n\n    assert result.result_type == entities.ResultType.INTERRUPT\n\n\n@pytest.mark.asyncio\nasync def test_wildcard_group(mock_app, sample_query):\n    \"\"\"Test group wildcard matching\"\"\"\n    bansess, entities = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.GROUP\n    sample_query.launcher_id = '12345'\n    sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['group_*']}}}\n\n    stage = bansess.BanSessionCheckStage(mock_app)\n    await stage.initialize(sample_query.pipeline_config)\n\n    result = await stage.process(sample_query, 'BanSessionCheckStage')\n\n    assert result.result_type == entities.ResultType.CONTINUE\n\n\n@pytest.mark.asyncio\nasync def test_wildcard_person(mock_app, sample_query):\n    \"\"\"Test person wildcard matching\"\"\"\n    bansess, entities = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.PERSON\n    sample_query.launcher_id = '12345'\n    sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['person_*']}}}\n\n    stage = bansess.BanSessionCheckStage(mock_app)\n    await stage.initialize(sample_query.pipeline_config)\n\n    result = await stage.process(sample_query, 'BanSessionCheckStage')\n\n    assert result.result_type == entities.ResultType.CONTINUE\n\n\n@pytest.mark.asyncio\nasync def test_user_id_wildcard(mock_app, sample_query):\n    \"\"\"Test user ID wildcard matching (*_id format)\"\"\"\n    bansess, entities = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.PERSON\n    sample_query.launcher_id = '12345'\n    sample_query.sender_id = '67890'\n    sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['*_67890']}}}\n\n    stage = bansess.BanSessionCheckStage(mock_app)\n    await stage.initialize(sample_query.pipeline_config)\n\n    result = await stage.process(sample_query, 'BanSessionCheckStage')\n\n    assert result.result_type == entities.ResultType.CONTINUE\n"
  },
  {
    "path": "tests/unit_tests/pipeline/test_config_coercion.py",
    "content": "\"\"\"Unit tests for config_coercion module\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom langbot.pkg.pipeline.config_coercion import _coerce_value, coerce_pipeline_config\n\n\nclass TestCoerceValue:\n    \"\"\"Tests for _coerce_value function\"\"\"\n\n    def test_none_passthrough(self):\n        assert _coerce_value(None, 'integer') is None\n        assert _coerce_value(None, 'boolean') is None\n\n    def test_string_to_integer(self):\n        assert _coerce_value('120', 'integer') == 120\n        assert _coerce_value('0', 'integer') == 0\n        assert _coerce_value('-5', 'integer') == -5\n\n    def test_integer_passthrough(self):\n        assert _coerce_value(42, 'integer') == 42\n\n    def test_string_to_float(self):\n        assert _coerce_value('3.14', 'number') == 3.14\n        assert _coerce_value('3.14', 'float') == 3.14\n\n    def test_int_to_float(self):\n        assert _coerce_value(3, 'number') == 3.0\n        assert isinstance(_coerce_value(3, 'number'), float)\n\n    def test_float_passthrough(self):\n        assert _coerce_value(3.14, 'float') == 3.14\n\n    def test_string_to_bool(self):\n        assert _coerce_value('true', 'boolean') is True\n        assert _coerce_value('True', 'boolean') is True\n        assert _coerce_value('false', 'boolean') is False\n        assert _coerce_value('False', 'boolean') is False\n\n    def test_bool_passthrough(self):\n        assert _coerce_value(True, 'boolean') is True\n        assert _coerce_value(False, 'boolean') is False\n\n    def test_invalid_bool_string_raises(self):\n        with pytest.raises(ValueError):\n            _coerce_value('notabool', 'boolean')\n\n    def test_unknown_type_passthrough(self):\n        assert _coerce_value('hello', 'string') == 'hello'\n        assert _coerce_value('hello', 'unknown') == 'hello'\n\n    def test_invalid_integer_raises(self):\n        with pytest.raises(ValueError):\n            _coerce_value('abc', 'integer')\n\n\nclass TestCoercePipelineConfig:\n    \"\"\"Tests for coerce_pipeline_config function\"\"\"\n\n    def _make_meta(self, section_name: str, stage_name: str, fields: list[dict]) -> dict:\n        return {\n            'name': section_name,\n            'stages': [{'name': stage_name, 'config': fields}],\n        }\n\n    def test_coerce_integer_in_config(self):\n        config = {'trigger': {'misc': {'timeout': '120'}}}\n        meta = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])\n        coerce_pipeline_config(config, meta)\n        assert config['trigger']['misc']['timeout'] == 120\n\n    def test_coerce_boolean_in_config(self):\n        config = {'output': {'misc': {'at-sender': 'true'}}}\n        meta = self._make_meta('output', 'misc', [{'name': 'at-sender', 'type': 'boolean'}])\n        coerce_pipeline_config(config, meta)\n        assert config['output']['misc']['at-sender'] is True\n\n    def test_missing_section_skipped(self):\n        config = {'ai': {}}\n        meta = self._make_meta('trigger', 'misc', [{'name': 'x', 'type': 'integer'}])\n        coerce_pipeline_config(config, meta)  # should not raise\n\n    def test_missing_field_skipped(self):\n        config = {'trigger': {'misc': {}}}\n        meta = self._make_meta('trigger', 'misc', [{'name': 'nonexistent', 'type': 'integer'}])\n        coerce_pipeline_config(config, meta)  # should not raise\n\n    def test_invalid_value_logs_warning(self, caplog):\n        config = {'trigger': {'misc': {'timeout': 'abc'}}}\n        meta = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])\n        import logging\n\n        with caplog.at_level(logging.WARNING):\n            coerce_pipeline_config(config, meta)\n        assert config['trigger']['misc']['timeout'] == 'abc'  # unchanged\n        assert 'Failed to coerce' in caplog.text\n\n    def test_empty_metadata(self):\n        config = {'trigger': {'misc': {'timeout': '120'}}}\n        coerce_pipeline_config(config)  # no metadata args, should not raise\n\n    def test_multiple_metadata(self):\n        config = {\n            'trigger': {'misc': {'timeout': '120'}},\n            'output': {'misc': {'at-sender': 'false'}},\n        }\n        meta_trigger = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])\n        meta_output = self._make_meta('output', 'misc', [{'name': 'at-sender', 'type': 'boolean'}])\n        coerce_pipeline_config(config, meta_trigger, meta_output)\n        assert config['trigger']['misc']['timeout'] == 120\n        assert config['output']['misc']['at-sender'] is False\n"
  },
  {
    "path": "tests/unit_tests/pipeline/test_pipelinemgr.py",
    "content": "\"\"\"\nPipelineManager unit tests\n\"\"\"\n\nimport pytest\nfrom unittest.mock import AsyncMock, Mock\nfrom importlib import import_module\n\n\ndef get_pipelinemgr_module():\n    return import_module('langbot.pkg.pipeline.pipelinemgr')\n\n\ndef get_stage_module():\n    return import_module('langbot.pkg.pipeline.stage')\n\n\ndef get_entities_module():\n    return import_module('langbot.pkg.pipeline.entities')\n\n\ndef get_persistence_pipeline_module():\n    return import_module('langbot.pkg.entity.persistence.pipeline')\n\n\n@pytest.mark.asyncio\nasync def test_pipeline_manager_initialize(mock_app):\n    \"\"\"Test pipeline manager initialization\"\"\"\n    pipelinemgr = get_pipelinemgr_module()\n\n    mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))\n\n    manager = pipelinemgr.PipelineManager(mock_app)\n    await manager.initialize()\n\n    assert manager.stage_dict is not None\n    assert len(manager.pipelines) == 0\n\n\n@pytest.mark.asyncio\nasync def test_load_pipeline(mock_app):\n    \"\"\"Test loading a single pipeline\"\"\"\n    pipelinemgr = get_pipelinemgr_module()\n    persistence_pipeline = get_persistence_pipeline_module()\n\n    mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))\n\n    manager = pipelinemgr.PipelineManager(mock_app)\n    await manager.initialize()\n\n    # Create test pipeline entity\n    pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)\n    pipeline_entity.uuid = 'test-uuid'\n    pipeline_entity.stages = []\n    pipeline_entity.config = {'test': 'config'}\n    pipeline_entity.extensions_preferences = {'plugins': []}\n\n    await manager.load_pipeline(pipeline_entity)\n\n    assert len(manager.pipelines) == 1\n    assert manager.pipelines[0].pipeline_entity.uuid == 'test-uuid'\n\n\n@pytest.mark.asyncio\nasync def test_get_pipeline_by_uuid(mock_app):\n    \"\"\"Test getting pipeline by UUID\"\"\"\n    pipelinemgr = get_pipelinemgr_module()\n    persistence_pipeline = get_persistence_pipeline_module()\n\n    mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))\n\n    manager = pipelinemgr.PipelineManager(mock_app)\n    await manager.initialize()\n\n    # Create and add test pipeline\n    pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)\n    pipeline_entity.uuid = 'test-uuid'\n    pipeline_entity.stages = []\n    pipeline_entity.config = {}\n    pipeline_entity.extensions_preferences = {'plugins': []}\n\n    await manager.load_pipeline(pipeline_entity)\n\n    # Test retrieval\n    result = await manager.get_pipeline_by_uuid('test-uuid')\n    assert result is not None\n    assert result.pipeline_entity.uuid == 'test-uuid'\n\n    # Test non-existent UUID\n    result = await manager.get_pipeline_by_uuid('non-existent')\n    assert result is None\n\n\n@pytest.mark.asyncio\nasync def test_remove_pipeline(mock_app):\n    \"\"\"Test removing a pipeline\"\"\"\n    pipelinemgr = get_pipelinemgr_module()\n    persistence_pipeline = get_persistence_pipeline_module()\n\n    mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))\n\n    manager = pipelinemgr.PipelineManager(mock_app)\n    await manager.initialize()\n\n    # Create and add test pipeline\n    pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)\n    pipeline_entity.uuid = 'test-uuid'\n    pipeline_entity.stages = []\n    pipeline_entity.config = {}\n    pipeline_entity.extensions_preferences = {'plugins': []}\n\n    await manager.load_pipeline(pipeline_entity)\n    assert len(manager.pipelines) == 1\n\n    # Remove pipeline\n    await manager.remove_pipeline('test-uuid')\n    assert len(manager.pipelines) == 0\n\n\n@pytest.mark.asyncio\nasync def test_runtime_pipeline_execute(mock_app, sample_query):\n    \"\"\"Test runtime pipeline execution\"\"\"\n    pipelinemgr = get_pipelinemgr_module()\n    stage = get_stage_module()\n    persistence_pipeline = get_persistence_pipeline_module()\n\n    # Create mock stage that returns a simple result dict (avoiding Pydantic validation)\n    mock_result = Mock()\n    mock_result.result_type = Mock()\n    mock_result.result_type.value = 'CONTINUE'  # Simulate enum value\n    mock_result.new_query = sample_query\n    mock_result.user_notice = ''\n    mock_result.console_notice = ''\n    mock_result.debug_notice = ''\n    mock_result.error_notice = ''\n\n    # Make it look like ResultType.CONTINUE\n    from unittest.mock import MagicMock\n\n    CONTINUE = MagicMock()\n    CONTINUE.__eq__ = lambda self, other: True  # Always equal for comparison\n    mock_result.result_type = CONTINUE\n\n    mock_stage = Mock(spec=stage.PipelineStage)\n    mock_stage.process = AsyncMock(return_value=mock_result)\n\n    # Create stage container\n    stage_container = pipelinemgr.StageInstContainer(inst_name='TestStage', inst=mock_stage)\n\n    # Create pipeline entity\n    pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)\n    pipeline_entity.config = sample_query.pipeline_config\n    pipeline_entity.extensions_preferences = {'plugins': []}\n\n    # Create runtime pipeline\n    runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [stage_container])\n\n    # Mock plugin connector\n    event_ctx = Mock()\n    event_ctx.is_prevented_default = Mock(return_value=False)\n    mock_app.plugin_connector.emit_event = AsyncMock(return_value=event_ctx)\n\n    # Add query to cached_queries to prevent KeyError in finally block\n    mock_app.query_pool.cached_queries[sample_query.query_id] = sample_query\n\n    # Execute pipeline\n    await runtime_pipeline.run(sample_query)\n\n    # Verify stage was called\n    mock_stage.process.assert_called_once()\n"
  },
  {
    "path": "tests/unit_tests/pipeline/test_ratelimit.py",
    "content": "\"\"\"\nRateLimit stage unit tests\n\nTests the actual RateLimit implementation from pkg.pipeline.ratelimit\n\"\"\"\n\nimport pytest\nfrom unittest.mock import AsyncMock, Mock, patch\nfrom importlib import import_module\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\n\n\ndef get_modules():\n    \"\"\"Lazy import to ensure proper initialization order\"\"\"\n    # Import pipelinemgr first to trigger proper stage registration\n    ratelimit = import_module('langbot.pkg.pipeline.ratelimit.ratelimit')\n    entities = import_module('langbot.pkg.pipeline.entities')\n    algo_module = import_module('langbot.pkg.pipeline.ratelimit.algo')\n    return ratelimit, entities, algo_module\n\n\n@pytest.mark.asyncio\nasync def test_require_access_allowed(mock_app, sample_query):\n    \"\"\"Test RequireRateLimitOccupancy allows access when rate limit is not exceeded\"\"\"\n    ratelimit, entities, algo_module = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.PERSON\n    sample_query.launcher_id = '12345'\n    sample_query.pipeline_config = {}\n\n    # Create mock algorithm that allows access\n    mock_algo = Mock(spec=algo_module.ReteLimitAlgo)\n    mock_algo.require_access = AsyncMock(return_value=True)\n    mock_algo.initialize = AsyncMock()\n\n    stage = ratelimit.RateLimit(mock_app)\n\n    # Patch the algorithm selection to use our mock\n    with patch.object(algo_module, 'preregistered_algos', []):\n        stage.algo = mock_algo\n\n    result = await stage.process(sample_query, 'RequireRateLimitOccupancy')\n\n    assert result.result_type == entities.ResultType.CONTINUE\n    assert result.new_query == sample_query\n    mock_algo.require_access.assert_called_once_with(sample_query, 'person', '12345')\n\n\n@pytest.mark.asyncio\nasync def test_require_access_denied(mock_app, sample_query):\n    \"\"\"Test RequireRateLimitOccupancy denies access when rate limit is exceeded\"\"\"\n    ratelimit, entities, algo_module = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.PERSON\n    sample_query.launcher_id = '12345'\n    sample_query.pipeline_config = {}\n\n    # Create mock algorithm that denies access\n    mock_algo = Mock(spec=algo_module.ReteLimitAlgo)\n    mock_algo.require_access = AsyncMock(return_value=False)\n    mock_algo.initialize = AsyncMock()\n\n    stage = ratelimit.RateLimit(mock_app)\n\n    # Patch the algorithm selection to use our mock\n    with patch.object(algo_module, 'preregistered_algos', []):\n        stage.algo = mock_algo\n\n    result = await stage.process(sample_query, 'RequireRateLimitOccupancy')\n\n    assert result.result_type == entities.ResultType.INTERRUPT\n    assert result.user_notice != ''\n    mock_algo.require_access.assert_called_once()\n\n\n@pytest.mark.asyncio\nasync def test_release_access(mock_app, sample_query):\n    \"\"\"Test ReleaseRateLimitOccupancy releases rate limit occupancy\"\"\"\n    ratelimit, entities, algo_module = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.PERSON\n    sample_query.launcher_id = '12345'\n    sample_query.pipeline_config = {}\n\n    # Create mock algorithm\n    mock_algo = Mock(spec=algo_module.ReteLimitAlgo)\n    mock_algo.release_access = AsyncMock()\n    mock_algo.initialize = AsyncMock()\n\n    stage = ratelimit.RateLimit(mock_app)\n\n    # Patch the algorithm selection to use our mock\n    with patch.object(algo_module, 'preregistered_algos', []):\n        stage.algo = mock_algo\n\n    result = await stage.process(sample_query, 'ReleaseRateLimitOccupancy')\n\n    assert result.result_type == entities.ResultType.CONTINUE\n    assert result.new_query == sample_query\n    mock_algo.release_access.assert_called_once_with(sample_query, 'person', '12345')\n"
  },
  {
    "path": "tests/unit_tests/pipeline/test_resprule.py",
    "content": "\"\"\"\nGroupRespondRuleCheckStage unit tests\n\nTests the actual GroupRespondRuleCheckStage implementation from pkg.pipeline.resprule\n\"\"\"\n\nimport pytest\nfrom unittest.mock import AsyncMock, Mock\nfrom importlib import import_module\nimport langbot_plugin.api.entities.builtin.provider.session as provider_session\nimport langbot_plugin.api.entities.builtin.platform.message as platform_message\n\n\ndef get_modules():\n    \"\"\"Lazy import to ensure proper initialization order\"\"\"\n    # Import pipelinemgr first to trigger proper stage registration\n    # pipelinemgr = import_module('langbot.pkg.pipeline.pipelinemgr')\n    resprule = import_module('langbot.pkg.pipeline.resprule.resprule')\n    entities = import_module('langbot.pkg.pipeline.entities')\n    rule = import_module('langbot.pkg.pipeline.resprule.rule')\n    rule_entities = import_module('langbot.pkg.pipeline.resprule.entities')\n    return resprule, entities, rule, rule_entities\n\n\n@pytest.mark.asyncio\nasync def test_person_message_skip(mock_app, sample_query):\n    \"\"\"Test person message skips rule check\"\"\"\n    resprule, entities, rule, rule_entities = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.PERSON\n    sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}}\n\n    stage = resprule.GroupRespondRuleCheckStage(mock_app)\n    await stage.initialize(sample_query.pipeline_config)\n\n    result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')\n\n    assert result.result_type == entities.ResultType.CONTINUE\n    assert result.new_query == sample_query\n\n\n@pytest.mark.asyncio\nasync def test_group_message_no_match(mock_app, sample_query):\n    \"\"\"Test group message with no matching rules\"\"\"\n    resprule, entities, rule, rule_entities = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.GROUP\n    sample_query.launcher_id = '12345'\n    sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}}\n\n    # Create mock rule matcher that doesn't match\n    mock_rule = Mock(spec=rule.GroupRespondRule)\n    mock_rule.match = AsyncMock(\n        return_value=rule_entities.RuleJudgeResult(matching=False, replacement=sample_query.message_chain)\n    )\n\n    stage = resprule.GroupRespondRuleCheckStage(mock_app)\n    await stage.initialize(sample_query.pipeline_config)\n    stage.rule_matchers = [mock_rule]\n\n    result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')\n\n    assert result.result_type == entities.ResultType.INTERRUPT\n    assert result.new_query == sample_query\n    mock_rule.match.assert_called_once()\n\n\n@pytest.mark.asyncio\nasync def test_group_message_match(mock_app, sample_query):\n    \"\"\"Test group message with matching rule\"\"\"\n    resprule, entities, rule, rule_entities = get_modules()\n\n    sample_query.launcher_type = provider_session.LauncherTypes.GROUP\n    sample_query.launcher_id = '12345'\n    sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}}\n\n    # Create new message chain after rule processing\n    new_chain = platform_message.MessageChain([platform_message.Plain(text='Processed message')])\n\n    # Create mock rule matcher that matches\n    mock_rule = Mock(spec=rule.GroupRespondRule)\n    mock_rule.match = AsyncMock(return_value=rule_entities.RuleJudgeResult(matching=True, replacement=new_chain))\n\n    stage = resprule.GroupRespondRuleCheckStage(mock_app)\n    await stage.initialize(sample_query.pipeline_config)\n    stage.rule_matchers = [mock_rule]\n\n    result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')\n\n    assert result.result_type == entities.ResultType.CONTINUE\n    assert result.new_query == sample_query\n    assert sample_query.message_chain == new_chain\n    mock_rule.match.assert_called_once()\n\n\n@pytest.mark.asyncio\nasync def test_atbot_rule_match(mock_app, sample_query):\n    \"\"\"Test AtBotRule removes At component\"\"\"\n    resprule, entities, rule, rule_entities = get_modules()\n    atbot_module = import_module('langbot.pkg.pipeline.resprule.rules.atbot')\n\n    sample_query.launcher_type = provider_session.LauncherTypes.GROUP\n    sample_query.adapter.bot_account_id = '999'\n\n    # Create message chain with At component\n    message_chain = platform_message.MessageChain(\n        [platform_message.At(target='999'), platform_message.Plain(text='Hello bot')]\n    )\n    sample_query.message_chain = message_chain\n\n    atbot_rule = atbot_module.AtBotRule(mock_app)\n    await atbot_rule.initialize()\n\n    result = await atbot_rule.match(str(message_chain), message_chain, {}, sample_query)\n\n    assert result.matching is True\n    # At component should be removed\n    assert len(result.replacement.root) == 1\n    assert isinstance(result.replacement.root[0], platform_message.Plain)\n\n\n@pytest.mark.asyncio\nasync def test_atbot_rule_no_match(mock_app, sample_query):\n    \"\"\"Test AtBotRule when no At component present\"\"\"\n    resprule, entities, rule, rule_entities = get_modules()\n    atbot_module = import_module('langbot.pkg.pipeline.resprule.rules.atbot')\n\n    sample_query.launcher_type = provider_session.LauncherTypes.GROUP\n    sample_query.adapter.bot_account_id = '999'\n\n    # Create message chain without At component\n    message_chain = platform_message.MessageChain([platform_message.Plain(text='Hello')])\n    sample_query.message_chain = message_chain\n\n    atbot_rule = atbot_module.AtBotRule(mock_app)\n    await atbot_rule.initialize()\n\n    result = await atbot_rule.match(str(message_chain), message_chain, {}, sample_query)\n\n    assert result.matching is False\n"
  },
  {
    "path": "tests/unit_tests/pipeline/test_simple.py",
    "content": "\"\"\"\nSimple standalone tests to verify test infrastructure\nThese tests don't import the actual pipeline code to avoid circular import issues\n\"\"\"\n\nimport pytest\nfrom unittest.mock import Mock, AsyncMock\n\n\ndef test_pytest_works():\n    \"\"\"Verify pytest is working\"\"\"\n    assert True\n\n\n@pytest.mark.asyncio\nasync def test_async_works():\n    \"\"\"Verify async tests work\"\"\"\n    mock = AsyncMock(return_value=42)\n    result = await mock()\n    assert result == 42\n\n\ndef test_mocks_work():\n    \"\"\"Verify mocking works\"\"\"\n    mock = Mock()\n    mock.return_value = 'test'\n    assert mock() == 'test'\n\n\ndef test_fixtures_work(mock_app):\n    \"\"\"Verify fixtures are loaded\"\"\"\n    assert mock_app is not None\n    assert mock_app.logger is not None\n    assert mock_app.sess_mgr is not None\n\n\ndef test_sample_query(sample_query):\n    \"\"\"Verify sample query fixture works\"\"\"\n    assert sample_query.query_id == 'test-query-id'\n    assert sample_query.launcher_id == 12345\n"
  },
  {
    "path": "tests/unit_tests/plugin/__init__.py",
    "content": "# Plugin connector unit tests\n"
  },
  {
    "path": "tests/unit_tests/plugin/test_plugin_component_filtering.py",
    "content": "\"\"\"Test plugin list filtering by component kinds.\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock\nimport pytest\n\n\n@pytest.mark.asyncio\nasync def test_plugin_list_filter_by_component_kinds():\n    \"\"\"Test that plugins can be filtered by component kinds.\"\"\"\n    from src.langbot.pkg.plugin.connector import PluginRuntimeConnector\n\n    # Mock the application\n    mock_app = MagicMock()\n    mock_app.instance_config.data.get.return_value = {'enable': True}\n    mock_app.logger = MagicMock()\n\n    # Create connector\n    connector = PluginRuntimeConnector(mock_app, AsyncMock())\n    connector.handler = MagicMock()\n\n    # Mock plugin data with different component kinds\n    mock_plugins = [\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author1',\n                        'name': 'plugin_with_tool',\n                    }\n                }\n            },\n            'components': [{'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool1'}}}}],\n        },\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author2',\n                        'name': 'plugin_with_knowledge_engine_only',\n                    }\n                }\n            },\n            'components': [{'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever1'}}}}],\n        },\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author3',\n                        'name': 'plugin_with_command',\n                    }\n                }\n            },\n            'components': [{'manifest': {'manifest': {'kind': 'Command', 'metadata': {'name': 'cmd1'}}}}],\n        },\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author4',\n                        'name': 'plugin_with_event_listener',\n                    }\n                }\n            },\n            'components': [{'manifest': {'manifest': {'kind': 'EventListener', 'metadata': {'name': 'listener1'}}}}],\n        },\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author5',\n                        'name': 'plugin_with_mixed_components',\n                    }\n                }\n            },\n            'components': [\n                {'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever2'}}}},\n                {'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool2'}}}},\n            ],\n        },\n    ]\n\n    connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)\n\n    # Mock database query\n    async def mock_execute_async(query):\n        mock_result = MagicMock()\n        mock_result.__iter__ = lambda self: iter([])\n        return mock_result\n\n    mock_app.persistence_mgr.execute_async = mock_execute_async\n\n    # Test filtering by pipeline component kinds (Command, EventListener, Tool)\n    pipeline_component_kinds = ['Command', 'EventListener', 'Tool']\n    result = await connector.list_plugins(component_kinds=pipeline_component_kinds)\n\n    # Verify that only plugins with pipeline-related components are returned\n    assert len(result) == 4\n    plugin_names = [p['manifest']['manifest']['metadata']['name'] for p in result]\n    assert 'plugin_with_tool' in plugin_names\n    assert 'plugin_with_command' in plugin_names\n    assert 'plugin_with_event_listener' in plugin_names\n    assert 'plugin_with_mixed_components' in plugin_names\n    # Plugin with only KnowledgeEngine should NOT be included\n    assert 'plugin_with_knowledge_engine_only' not in plugin_names\n\n\n@pytest.mark.asyncio\nasync def test_plugin_list_filter_no_filter():\n    \"\"\"Test that all plugins are returned when no filter is specified.\"\"\"\n    from src.langbot.pkg.plugin.connector import PluginRuntimeConnector\n\n    # Mock the application\n    mock_app = MagicMock()\n    mock_app.instance_config.data.get.return_value = {'enable': True}\n    mock_app.logger = MagicMock()\n\n    # Create connector\n    connector = PluginRuntimeConnector(mock_app, AsyncMock())\n    connector.handler = MagicMock()\n\n    # Mock plugin data with different component kinds\n    mock_plugins = [\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author1',\n                        'name': 'plugin1',\n                    }\n                }\n            },\n            'components': [{'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool1'}}}}],\n        },\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author2',\n                        'name': 'plugin2',\n                    }\n                }\n            },\n            'components': [{'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever1'}}}}],\n        },\n    ]\n\n    connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)\n\n    # Mock database query\n    async def mock_execute_async(query):\n        mock_result = MagicMock()\n        mock_result.__iter__ = lambda self: iter([])\n        return mock_result\n\n    mock_app.persistence_mgr.execute_async = mock_execute_async\n\n    # Test without filter - should return all plugins\n    result = await connector.list_plugins()\n\n    assert len(result) == 2\n    plugin_names = [p['manifest']['manifest']['metadata']['name'] for p in result]\n    assert 'plugin1' in plugin_names\n    assert 'plugin2' in plugin_names\n\n\n@pytest.mark.asyncio\nasync def test_plugin_list_filter_empty_result():\n    \"\"\"Test that empty list is returned when no plugins match the filter.\"\"\"\n    from src.langbot.pkg.plugin.connector import PluginRuntimeConnector\n\n    # Mock the application\n    mock_app = MagicMock()\n    mock_app.instance_config.data.get.return_value = {'enable': True}\n    mock_app.logger = MagicMock()\n\n    # Create connector\n    connector = PluginRuntimeConnector(mock_app, AsyncMock())\n    connector.handler = MagicMock()\n\n    # Mock plugin data - only KnowledgeEngine plugins\n    mock_plugins = [\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author1',\n                        'name': 'plugin1',\n                    }\n                }\n            },\n            'components': [{'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever1'}}}}],\n        },\n    ]\n\n    connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)\n\n    # Mock database query\n    async def mock_execute_async(query):\n        mock_result = MagicMock()\n        mock_result.__iter__ = lambda self: iter([])\n        return mock_result\n\n    mock_app.persistence_mgr.execute_async = mock_execute_async\n\n    # Filter by Tool kind - should return empty list\n    result = await connector.list_plugins(component_kinds=['Tool'])\n\n    assert len(result) == 0\n\n\n@pytest.mark.asyncio\nasync def test_plugin_list_filter_plugin_without_components():\n    \"\"\"Test that plugins without components are excluded when filtering.\"\"\"\n    from src.langbot.pkg.plugin.connector import PluginRuntimeConnector\n\n    # Mock the application\n    mock_app = MagicMock()\n    mock_app.instance_config.data.get.return_value = {'enable': True}\n    mock_app.logger = MagicMock()\n\n    # Create connector\n    connector = PluginRuntimeConnector(mock_app, AsyncMock())\n    connector.handler = MagicMock()\n\n    # Mock plugin data - one with components, one without\n    mock_plugins = [\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author1',\n                        'name': 'plugin_with_tool',\n                    }\n                }\n            },\n            'components': [{'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool1'}}}}],\n        },\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author2',\n                        'name': 'plugin_without_components',\n                    }\n                }\n            },\n            'components': [],\n        },\n    ]\n\n    connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)\n\n    # Mock database query\n    async def mock_execute_async(query):\n        mock_result = MagicMock()\n        mock_result.__iter__ = lambda self: iter([])\n        return mock_result\n\n    mock_app.persistence_mgr.execute_async = mock_execute_async\n\n    # Filter by Tool kind - should return only plugin with Tool\n    result = await connector.list_plugins(component_kinds=['Tool'])\n\n    assert len(result) == 1\n    assert result[0]['manifest']['manifest']['metadata']['name'] == 'plugin_with_tool'\n"
  },
  {
    "path": "tests/unit_tests/plugin/test_plugin_list_sorting.py",
    "content": "\"\"\"Test plugin list sorting functionality.\"\"\"\n\nfrom datetime import datetime, timedelta\nfrom unittest.mock import AsyncMock, MagicMock\nimport pytest\n\n\n@pytest.mark.asyncio\nasync def test_plugin_list_sorting_debug_first():\n    \"\"\"Test that debug plugins appear before non-debug plugins.\"\"\"\n    from src.langbot.pkg.plugin.connector import PluginRuntimeConnector\n\n    # Mock the application\n    mock_app = MagicMock()\n    mock_app.instance_config.data.get.return_value = {'enable': True}\n    mock_app.logger = MagicMock()\n\n    # Create connector\n    connector = PluginRuntimeConnector(mock_app, AsyncMock())\n    connector.handler = MagicMock()\n\n    # Mock plugin data with different debug states and timestamps\n    now = datetime.now()\n    mock_plugins = [\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author1',\n                        'name': 'plugin1',\n                    }\n                }\n            },\n        },\n        {\n            'debug': True,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author2',\n                        'name': 'plugin2',\n                    }\n                }\n            },\n        },\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author3',\n                        'name': 'plugin3',\n                    }\n                }\n            },\n        },\n    ]\n\n    connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)\n\n    # Mock database query to return all timestamps in a single batch\n    async def mock_execute_async(query):\n        mock_result = MagicMock()\n\n        # Create mock rows for all plugins with timestamps\n        mock_rows = []\n\n        # plugin1: oldest, plugin2: middle, plugin3: newest\n        mock_row1 = MagicMock()\n        mock_row1.plugin_author = 'author1'\n        mock_row1.plugin_name = 'plugin1'\n        mock_row1.created_at = now - timedelta(days=2)\n        mock_rows.append(mock_row1)\n\n        mock_row2 = MagicMock()\n        mock_row2.plugin_author = 'author2'\n        mock_row2.plugin_name = 'plugin2'\n        mock_row2.created_at = now - timedelta(days=1)\n        mock_rows.append(mock_row2)\n\n        mock_row3 = MagicMock()\n        mock_row3.plugin_author = 'author3'\n        mock_row3.plugin_name = 'plugin3'\n        mock_row3.created_at = now\n        mock_rows.append(mock_row3)\n\n        # Make the result iterable\n        mock_result.__iter__ = lambda self: iter(mock_rows)\n\n        return mock_result\n\n    mock_app.persistence_mgr.execute_async = mock_execute_async\n\n    # Call list_plugins\n    result = await connector.list_plugins()\n\n    # Verify sorting: debug plugin should be first\n    assert len(result) == 3\n    assert result[0]['debug'] is True  # plugin2 (debug)\n    assert result[0]['manifest']['manifest']['metadata']['name'] == 'plugin2'\n\n    # Remaining should be sorted by created_at (newest first)\n    assert result[1]['debug'] is False\n    assert result[1]['manifest']['manifest']['metadata']['name'] == 'plugin3'  # newest non-debug\n    assert result[2]['debug'] is False\n    assert result[2]['manifest']['manifest']['metadata']['name'] == 'plugin1'  # oldest non-debug\n\n\n@pytest.mark.asyncio\nasync def test_plugin_list_sorting_by_installation_time():\n    \"\"\"Test that non-debug plugins are sorted by installation time (newest first).\"\"\"\n    from src.langbot.pkg.plugin.connector import PluginRuntimeConnector\n\n    # Mock the application\n    mock_app = MagicMock()\n    mock_app.instance_config.data.get.return_value = {'enable': True}\n    mock_app.logger = MagicMock()\n\n    # Create connector\n    connector = PluginRuntimeConnector(mock_app, AsyncMock())\n    connector.handler = MagicMock()\n\n    # Mock plugin data - all non-debug with different installation times\n    now = datetime.now()\n    mock_plugins = [\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author1',\n                        'name': 'oldest_plugin',\n                    }\n                }\n            },\n        },\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author2',\n                        'name': 'middle_plugin',\n                    }\n                }\n            },\n        },\n        {\n            'debug': False,\n            'manifest': {\n                'manifest': {\n                    'metadata': {\n                        'author': 'author3',\n                        'name': 'newest_plugin',\n                    }\n                }\n            },\n        },\n    ]\n\n    connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)\n\n    # Mock database query to return all timestamps in a single batch\n    async def mock_execute_async(query):\n        mock_result = MagicMock()\n\n        # Create mock rows for all plugins with timestamps\n        mock_rows = []\n\n        # oldest_plugin: oldest, middle_plugin: middle, newest_plugin: newest\n        mock_row1 = MagicMock()\n        mock_row1.plugin_author = 'author1'\n        mock_row1.plugin_name = 'oldest_plugin'\n        mock_row1.created_at = now - timedelta(days=10)\n        mock_rows.append(mock_row1)\n\n        mock_row2 = MagicMock()\n        mock_row2.plugin_author = 'author2'\n        mock_row2.plugin_name = 'middle_plugin'\n        mock_row2.created_at = now - timedelta(days=5)\n        mock_rows.append(mock_row2)\n\n        mock_row3 = MagicMock()\n        mock_row3.plugin_author = 'author3'\n        mock_row3.plugin_name = 'newest_plugin'\n        mock_row3.created_at = now\n        mock_rows.append(mock_row3)\n\n        # Make the result iterable\n        mock_result.__iter__ = lambda self: iter(mock_rows)\n\n        return mock_result\n\n    mock_app.persistence_mgr.execute_async = mock_execute_async\n\n    # Call list_plugins\n    result = await connector.list_plugins()\n\n    # Verify sorting: newest first\n    assert len(result) == 3\n    assert result[0]['manifest']['manifest']['metadata']['name'] == 'newest_plugin'\n    assert result[1]['manifest']['manifest']['metadata']['name'] == 'middle_plugin'\n    assert result[2]['manifest']['manifest']['metadata']['name'] == 'oldest_plugin'\n\n\n@pytest.mark.asyncio\nasync def test_plugin_list_empty():\n    \"\"\"Test that empty plugin list is handled correctly.\"\"\"\n    from src.langbot.pkg.plugin.connector import PluginRuntimeConnector\n\n    # Mock the application\n    mock_app = MagicMock()\n    mock_app.instance_config.data.get.return_value = {'enable': True}\n    mock_app.logger = MagicMock()\n\n    # Create connector\n    connector = PluginRuntimeConnector(mock_app, AsyncMock())\n    connector.handler = MagicMock()\n\n    # Mock empty plugin list\n    connector.handler.list_plugins = AsyncMock(return_value=[])\n\n    # Call list_plugins\n    result = await connector.list_plugins()\n\n    # Verify empty list\n    assert len(result) == 0\n"
  },
  {
    "path": "tests/unit_tests/storage/__init__.py",
    "content": ""
  },
  {
    "path": "tests/unit_tests/storage/test_storage_provider_selection.py",
    "content": "\"\"\"\nTests for storage manager and provider selection\n\"\"\"\n\nimport pytest\nfrom unittest.mock import Mock, AsyncMock, patch\nfrom langbot.pkg.storage.mgr import StorageMgr\nfrom langbot.pkg.storage.providers.localstorage import LocalStorageProvider\nfrom langbot.pkg.storage.providers.s3storage import S3StorageProvider\n\n\nclass TestStorageProviderSelection:\n    \"\"\"Test storage provider selection based on configuration\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_default_to_local_storage(self):\n        \"\"\"Test that local storage is used by default when no config is provided\"\"\"\n        # Mock application\n        mock_app = Mock()\n        mock_app.instance_config = Mock()\n        mock_app.instance_config.data = {}\n        mock_app.logger = Mock()\n\n        storage_mgr = StorageMgr(mock_app)\n\n        with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:\n            await storage_mgr.initialize()\n            assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)\n            mock_init.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_explicit_local_storage(self):\n        \"\"\"Test that local storage is used when explicitly configured\"\"\"\n        # Mock application\n        mock_app = Mock()\n        mock_app.instance_config = Mock()\n        mock_app.instance_config.data = {'storage': {'use': 'local'}}\n        mock_app.logger = Mock()\n\n        storage_mgr = StorageMgr(mock_app)\n\n        with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:\n            await storage_mgr.initialize()\n            assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)\n            mock_init.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_s3_storage_provider_selection(self):\n        \"\"\"Test that S3 storage is used when configured\"\"\"\n        # Mock application\n        mock_app = Mock()\n        mock_app.instance_config = Mock()\n        mock_app.instance_config.data = {\n            'storage': {\n                'use': 's3',\n                's3': {\n                    'endpoint_url': 'https://s3.amazonaws.com',\n                    'access_key_id': 'test_key',\n                    'secret_access_key': 'test_secret',\n                    'region': 'us-east-1',\n                    'bucket': 'test-bucket',\n                },\n            }\n        }\n        mock_app.logger = Mock()\n\n        storage_mgr = StorageMgr(mock_app)\n\n        with patch.object(S3StorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:\n            await storage_mgr.initialize()\n            assert isinstance(storage_mgr.storage_provider, S3StorageProvider)\n            mock_init.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_invalid_storage_type_defaults_to_local(self):\n        \"\"\"Test that invalid storage type defaults to local storage\"\"\"\n        # Mock application\n        mock_app = Mock()\n        mock_app.instance_config = Mock()\n        mock_app.instance_config.data = {'storage': {'use': 'invalid_type'}}\n        mock_app.logger = Mock()\n\n        storage_mgr = StorageMgr(mock_app)\n\n        with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:\n            await storage_mgr.initialize()\n            assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)\n            mock_init.assert_called_once()\n\n\nif __name__ == '__main__':\n    pytest.main([__file__, '-v'])\n"
  },
  {
    "path": "web/.env.example",
    "content": "NEXT_PUBLIC_API_BASE_URL=http://localhost:5300\n"
  },
  {
    "path": "web/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.*\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# env files (can opt-in for committing if needed)\n.env*\n!.env.example\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n"
  },
  {
    "path": "web/.lintstagedrc.json",
    "content": "{\n  \"*.{js,jsx,ts,tsx}\": [\"eslint --fix\"],\n  \"**/*\": [\"bash -c 'cd \\\"$(pwd)\\\" && next build\"]\n}\n"
  },
  {
    "path": "web/.prettierrc.mjs",
    "content": "/**\n * @see https://prettier.io/docs/configuration\n * @type {import(\"prettier\").Config}\n */\nconst config = {\n  // 单行长度\n  printWidth: 80,\n  // 缩进\n  tabWidth: 2,\n  // 使用空格代替tab缩进\n  useTabs: false,\n  // 句末使用分号\n  semi: true,\n  // 使用单引号\n  singleQuote: true,\n  // 大括号前后空格\n  bracketSpacing: true,\n  trailingComma: 'all',\n};\n\nexport default config;\n"
  },
  {
    "path": "web/README.md",
    "content": "# Debug LangBot Frontend\n\nPlease refer to the [Development Guide](https://docs.langbot.app/en/develop/dev-config.html) for more information.\n"
  },
  {
    "path": "web/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": true,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/app/global.css\",\n    \"baseColor\": \"zinc\",\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}\n"
  },
  {
    "path": "web/eslint.config.mjs",
    "content": "import { dirname } from 'path';\nimport { fileURLToPath } from 'url';\nimport { FlatCompat } from '@eslint/eslintrc';\nimport eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\nconst compat = new FlatCompat({\n  baseDirectory: __dirname,\n});\n\nconst eslintConfig = [\n  ...compat.extends('next/core-web-vitals', 'next/typescript'),\n  eslintPluginPrettierRecommended,\n];\n\nexport default eslintConfig;\n"
  },
  {
    "path": "web/next",
    "content": ""
  },
  {
    "path": "web/next.config.ts",
    "content": "import type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n  /* config options here */\n  output: 'export',\n};\n\nexport default nextConfig;\n"
  },
  {
    "path": "web/package.json",
    "content": "{\n  \"name\": \"web\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev --turbopack\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"lint\": \"eslint src\",\n    \"lint:fix\": \"eslint src --fix\",\n    \"lint-staged\": \"lint-staged\"\n  },\n  \"lint-staged\": {\n    \"*.{js,jsx,ts,tsx}\": [\n      \"next lint --fix\",\n      \"prettier --write\"\n    ]\n  },\n  \"overrides\": {\n    \"@radix-ui/react-focus-scope\": \"1.1.7\"\n  },\n  \"dependencies\": {\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"@hookform/resolvers\": \"^5.0.1\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-checkbox\": \"^1.3.1\",\n    \"@radix-ui/react-collapsible\": \"^1.1.12\",\n    \"@radix-ui/react-context-menu\": \"^2.2.15\",\n    \"@radix-ui/react-dialog\": \"^1.1.14\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-hover-card\": \"^1.1.13\",\n    \"@radix-ui/react-label\": \"^2.1.6\",\n    \"@radix-ui/react-popover\": \"^1.1.14\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.9\",\n    \"@radix-ui/react-select\": \"^2.2.4\",\n    \"@radix-ui/react-separator\": \"^1.1.8\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.2.4\",\n    \"@radix-ui/react-tabs\": \"^1.1.11\",\n    \"@radix-ui/react-toggle\": \"^1.1.8\",\n    \"@radix-ui/react-toggle-group\": \"^1.1.9\",\n    \"@radix-ui/react-tooltip\": \"^1.2.7\",\n    \"@tailwindcss/postcss\": \"^4.1.5\",\n    \"@tanstack/react-table\": \"^8.21.3\",\n    \"axios\": \"^1.13.5\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"highlight.js\": \"^11.11.1\",\n    \"i18next\": \"^25.1.2\",\n    \"i18next-browser-languagedetector\": \"^8.1.0\",\n    \"input-otp\": \"^1.4.2\",\n    \"lodash\": \"^4.17.23\",\n    \"lucide-react\": \"^0.507.0\",\n    \"next\": \"~16.1.5\",\n    \"next-themes\": \"^0.4.6\",\n    \"postcss\": \"^8.5.3\",\n    \"qrcode\": \"^1.5.4\",\n    \"react\": \"19.2.1\",\n    \"react-dom\": \"19.2.1\",\n    \"react-hook-form\": \"^7.56.3\",\n    \"react-i18next\": \"^15.5.1\",\n    \"react-markdown\": \"^10.1.0\",\n    \"react-photo-view\": \"^1.2.7\",\n    \"react-syntax-highlighter\": \"^16.1.0\",\n    \"recharts\": \"2.15.4\",\n    \"rehype-autolink-headings\": \"^7.1.0\",\n    \"rehype-highlight\": \"^7.0.2\",\n    \"rehype-raw\": \"^7.0.0\",\n    \"rehype-sanitize\": \"^6.0.0\",\n    \"rehype-slug\": \"^6.0.0\",\n    \"remark-gfm\": \"^4.0.1\",\n    \"sonner\": \"^2.0.3\",\n    \"tailwind-merge\": \"^3.2.0\",\n    \"tailwindcss\": \"^4.1.5\",\n    \"uuidjs\": \"^5.1.0\",\n    \"zod\": \"^3.24.4\"\n  },\n  \"devDependencies\": {\n    \"@eslint/eslintrc\": \"^3\",\n    \"@types/debug\": \"^4.1.12\",\n    \"@types/estree\": \"^1.0.8\",\n    \"@types/estree-jsx\": \"^1.0.5\",\n    \"@types/hast\": \"^3.0.4\",\n    \"@types/lodash\": \"^4.17.16\",\n    \"@types/mdast\": \"^4.0.4\",\n    \"@types/ms\": \"^2.1.0\",\n    \"@types/node\": \"^20\",\n    \"@types/qrcode\": \"^1.5.6\",\n    \"@types/react\": \"~19.2.7\",\n    \"@types/react-dom\": \"~19.2.3\",\n    \"@types/react-syntax-highlighter\": \"^15.5.13\",\n    \"@types/unist\": \"^3.0.3\",\n    \"eslint\": \"^9\",\n    \"eslint-config-next\": \"15.2.4\",\n    \"eslint-config-prettier\": \"^10.1.2\",\n    \"eslint-plugin-prettier\": \"^5.2.6\",\n    \"lint-staged\": \"^15.5.1\",\n    \"prettier\": \"^3.5.3\",\n    \"tw-animate-css\": \"^1.2.9\",\n    \"typescript\": \"^5.8.3\",\n    \"typescript-eslint\": \"^8.31.1\"\n  },\n  \"packageManager\": \"pnpm@8.9.2+sha512.b9d35fe91b2a5854dadc43034a3e7b2e675fa4b56e20e8e09ef078fa553c18f8aed44051e7b36e8b8dd435f97eb0c44c4ff3b44fc7c6fa7d21e1fac17bbe661e\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"minimatch\": \"3.1.3\"\n    }\n  }\n}\n"
  },
  {
    "path": "web/postcss.config.mjs",
    "content": "const config = {\n  plugins: {\n    '@tailwindcss/postcss': {},\n  },\n};\nexport default config;\n"
  },
  {
    "path": "web/src/app/auth/space/callback/page.tsx",
    "content": "'use client';\n\nimport { useEffect, useState, useCallback, Suspense } from 'react';\nimport { useRouter, useSearchParams } from 'next/navigation';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Loader2,\n  AlertCircle,\n  CheckCircle2,\n  AlertTriangle,\n} from 'lucide-react';\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n  CardDescription,\n} from '@/components/ui/card';\nimport { Button } from '@/components/ui/button';\nimport { LoadingSpinner } from '@/components/ui/loading-spinner';\nimport langbotIcon from '@/app/assets/langbot-logo.webp';\n\nfunction SpaceOAuthCallbackContent() {\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const { t } = useTranslation();\n\n  const [status, setStatus] = useState<\n    'loading' | 'confirm' | 'success' | 'error'\n  >('loading');\n  const [errorMessage, setErrorMessage] = useState<string>('');\n  const [isBindMode, setIsBindMode] = useState(false);\n  const [code, setCode] = useState<string | null>(null);\n  const [isProcessing, setIsProcessing] = useState(false);\n  const [localEmail, setLocalEmail] = useState<string>('');\n\n  const handleOAuthCallback = useCallback(\n    async (authCode: string) => {\n      try {\n        const response = await httpClient.exchangeSpaceOAuthCode(authCode);\n        localStorage.setItem('token', response.token);\n        if (response.user) {\n          localStorage.setItem('userEmail', response.user);\n        }\n        setStatus('success');\n        toast.success(t('common.spaceLoginSuccess'));\n        setTimeout(() => {\n          router.push('/home');\n        }, 1000);\n      } catch (err) {\n        setStatus('error');\n        const errorObj = err as { msg?: string };\n        const errMsg = (errorObj?.msg || '').toLowerCase();\n        if (errMsg.includes('account email mismatch')) {\n          setErrorMessage(t('account.spaceEmailMismatch'));\n        } else {\n          setErrorMessage(t('common.spaceLoginFailed'));\n        }\n      }\n    },\n    [router, t],\n  );\n\n  const [bindState, setBindState] = useState<string | null>(null);\n\n  const handleBindAccount = useCallback(\n    async (authCode: string, state: string) => {\n      setIsProcessing(true);\n      try {\n        const response = await httpClient.bindSpaceAccount(authCode, state);\n        localStorage.setItem('token', response.token);\n        if (response.user) {\n          localStorage.setItem('userEmail', response.user);\n        }\n        setStatus('success');\n        toast.success(t('account.bindSpaceSuccess'));\n        setTimeout(() => {\n          router.push('/home');\n        }, 1000);\n      } catch (err) {\n        setStatus('error');\n        const errorObj = err as { msg?: string };\n        const errMsg = (errorObj?.msg || '').toLowerCase();\n        if (errMsg.includes('account email mismatch')) {\n          setErrorMessage(t('account.spaceEmailMismatch'));\n        } else {\n          setErrorMessage(t('account.bindSpaceFailed'));\n        }\n      } finally {\n        setIsProcessing(false);\n      }\n    },\n    [router, t],\n  );\n\n  useEffect(() => {\n    const authCode = searchParams.get('code');\n    const error = searchParams.get('error');\n    const errorDescription = searchParams.get('error_description');\n    const mode = searchParams.get('mode');\n    const state = searchParams.get('state');\n\n    if (error) {\n      setStatus('error');\n      setErrorMessage(\n        errorDescription || error || t('common.spaceLoginFailed'),\n      );\n      return;\n    }\n\n    if (!authCode) {\n      setStatus('error');\n      setErrorMessage(t('common.spaceLoginNoCode'));\n      return;\n    }\n\n    setCode(authCode);\n\n    if (mode === 'bind') {\n      // Bind mode - verify state (token) exists\n      if (!state) {\n        setStatus('error');\n        setErrorMessage(t('account.bindSpaceInvalidState'));\n        return;\n      }\n      setBindState(state);\n      setIsBindMode(true);\n      setLocalEmail(localStorage.getItem('userEmail') || '');\n      setStatus('confirm');\n    } else {\n      // Normal login/register mode\n      handleOAuthCallback(authCode);\n    }\n  }, [searchParams, handleOAuthCallback, t]);\n\n  const handleConfirmBind = () => {\n    if (code && bindState) {\n      handleBindAccount(code, bindState);\n    }\n  };\n\n  const handleCancelBind = () => {\n    router.push('/home');\n  };\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900\">\n      <Card className=\"w-[400px] shadow-lg dark:shadow-white/10\">\n        <CardHeader className=\"text-center\">\n          <img\n            src={langbotIcon.src}\n            alt=\"LangBot\"\n            className=\"w-16 h-16 mb-4 mx-auto\"\n          />\n          <CardTitle className=\"text-xl\">\n            {status === 'loading' && t('common.spaceLoginProcessing')}\n            {status === 'confirm' && t('account.bindSpaceConfirmTitle')}\n            {status === 'success' &&\n              (isBindMode\n                ? t('account.bindSpaceSuccess')\n                : t('common.spaceLoginSuccess'))}\n            {status === 'error' &&\n              (isBindMode\n                ? t('account.bindSpaceFailed')\n                : t('common.spaceLoginError'))}\n          </CardTitle>\n          <CardDescription>\n            {status === 'loading' &&\n              t('common.spaceLoginProcessingDescription')}\n            {status === 'confirm' && t('account.bindSpaceConfirmDescription')}\n            {status === 'success' && t('common.spaceLoginSuccessDescription')}\n            {status === 'error' && errorMessage}\n          </CardDescription>\n        </CardHeader>\n        <CardContent className=\"flex flex-col items-center space-y-4\">\n          {status === 'loading' && <LoadingSpinner size=\"lg\" text=\"\" />}\n          {status === 'confirm' && (\n            <>\n              <AlertTriangle className=\"h-12 w-12 text-yellow-500\" />\n              <p className=\"text-sm text-center text-muted-foreground px-4\">\n                {t('account.bindSpaceWarning', {\n                  localEmail: localEmail || '-',\n                })}\n              </p>\n              <div className=\"flex gap-3 w-full\">\n                <Button\n                  variant=\"outline\"\n                  className=\"flex-1\"\n                  onClick={handleCancelBind}\n                  disabled={isProcessing}\n                >\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  className=\"flex-1\"\n                  onClick={handleConfirmBind}\n                  disabled={isProcessing}\n                >\n                  {isProcessing ? (\n                    <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                  ) : null}\n                  {t('common.confirm')}\n                </Button>\n              </div>\n            </>\n          )}\n          {status === 'success' && (\n            <CheckCircle2 className=\"h-12 w-12 text-green-500\" />\n          )}\n          {status === 'error' && (\n            <>\n              <AlertCircle className=\"h-12 w-12 text-red-500\" />\n              <Button\n                onClick={() => router.push(isBindMode ? '/home' : '/login')}\n                className=\"w-full mt-4\"\n              >\n                {isBindMode ? t('common.backToHome') : t('common.backToLogin')}\n              </Button>\n            </>\n          )}\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n\nfunction LoadingFallback() {\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900\">\n      <Card className=\"w-[400px] shadow-lg dark:shadow-white/10\">\n        <CardContent className=\"flex flex-col items-center py-12\">\n          <LoadingSpinner size=\"lg\" text=\"\" />\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n\nexport default function SpaceOAuthCallback() {\n  return (\n    <Suspense fallback={<LoadingFallback />}>\n      <SpaceOAuthCallbackContent />\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "web/src/app/global.css",
    "content": ":root {\n  /* 适用于 Firefox 的滚动条 */\n  scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */\n  scrollbar-width: thin; /* auto | thin | none */\n  --radius: 0.625rem;\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.141 0.005 285.823);\n  --card: oklch(1 0 0);\n  --card-foreground: oklch(0.141 0.005 285.823);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.141 0.005 285.823);\n  --primary: oklch(0.21 0.006 285.885);\n  --primary-foreground: oklch(0.985 0 0);\n  --secondary: oklch(0.967 0.001 286.375);\n  --secondary-foreground: oklch(0.21 0.006 285.885);\n  --muted: oklch(0.967 0.001 286.375);\n  --muted-foreground: oklch(0.552 0.016 285.938);\n  --accent: oklch(0.967 0.001 286.375);\n  --accent-foreground: oklch(0.21 0.006 285.885);\n  --destructive: oklch(0.577 0.245 27.325);\n  --border: oklch(0.92 0.004 286.32);\n  --input: oklch(0.92 0.004 286.32);\n  --ring: oklch(0.705 0.015 286.067);\n  --chart-1: oklch(0.646 0.222 41.116);\n  --chart-2: oklch(0.6 0.118 184.704);\n  --chart-3: oklch(0.398 0.07 227.392);\n  --chart-4: oklch(0.828 0.189 84.429);\n  --chart-5: oklch(0.769 0.188 70.08);\n  --sidebar: oklch(0.985 0 0);\n  --sidebar-foreground: oklch(0.141 0.005 285.823);\n  --sidebar-primary: oklch(0.21 0.006 285.885);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.967 0.001 286.375);\n  --sidebar-accent-foreground: oklch(0.21 0.006 285.885);\n  --sidebar-border: oklch(0.92 0.004 286.32);\n  --sidebar-ring: oklch(0.705 0.015 286.067);\n}\n\n/* WebKit 内核浏览器定制 */\n::-webkit-scrollbar {\n  width: 6px; /* 垂直滚动条宽度 */\n  height: 6px; /* 水平滚动条高度 */\n}\n\n::-webkit-scrollbar-track {\n  background: transparent; /* 隐藏轨道背景 */\n}\n\n::-webkit-scrollbar-thumb {\n  background: rgba(0, 0, 0, 0.2); /* 半透明黑色 */\n  border-radius: 3px;\n  transition: background 0.3s;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: rgba(0, 0, 0, 0.35); /* 悬停加深 */\n}\n\n/* 暗黑模式下的滚动条 */\n.dark ::-webkit-scrollbar-thumb {\n  background: rgba(255, 255, 255, 0.2); /* 半透明白色 */\n}\n\n.dark ::-webkit-scrollbar-thumb:hover {\n  background: rgba(255, 255, 255, 0.35); /* 悬停加深 */\n}\n\n/* 兼容 Edge */\n@supports (-ms-ime-align: auto) {\n  body {\n    -ms-overflow-style: -ms-autohiding-scrollbar; /* 自动隐藏滚动条 */\n  }\n}\n\n@import 'tailwindcss';\n\n@import 'tw-animate-css';\n\n@custom-variant dark (&:is(.dark *));\n\n@theme inline {\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n}\n\n.dark {\n  --background: oklch(0.08 0.002 285.823);\n  --foreground: oklch(0.985 0 0);\n  --card: oklch(0.12 0.004 285.885);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.12 0.004 285.885);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.62 0.2 255);\n  --primary-foreground: oklch(1 0 0);\n  --secondary: oklch(0.18 0.004 286.033);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.18 0.004 286.033);\n  --muted-foreground: oklch(0.705 0.015 286.067);\n  --accent: oklch(0.18 0.004 286.033);\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.704 0.191 22.216);\n  --border: oklch(1 0 0 / 8%);\n  --input: oklch(1 0 0 / 10%);\n  --ring: oklch(0.552 0.016 285.938);\n  --chart-1: oklch(0.488 0.243 264.376);\n  --chart-2: oklch(0.696 0.17 162.48);\n  --chart-3: oklch(0.769 0.188 70.08);\n  --chart-4: oklch(0.627 0.265 303.9);\n  --chart-5: oklch(0.645 0.246 16.439);\n  --sidebar: oklch(0.1 0.003 285.885);\n  --sidebar-foreground: oklch(0.985 0 0);\n  --sidebar-primary: oklch(0.62 0.2 255);\n  --sidebar-primary-foreground: oklch(1 0 0);\n  --sidebar-accent: oklch(0.18 0.004 286.033);\n  --sidebar-accent-foreground: oklch(0.985 0 0);\n  --sidebar-border: oklch(1 0 0 / 8%);\n  --sidebar-ring: oklch(0.552 0.016 285.938);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n"
  },
  {
    "path": "web/src/app/home/bots/BotDetailDialog.tsx",
    "content": "'use client';\n\nimport { useState, useEffect } from 'react';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogDescription,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarProvider,\n} from '@/components/ui/sidebar';\nimport { Button } from '@/components/ui/button';\nimport BotForm from '@/app/home/bots/components/bot-form/BotForm';\nimport { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';\nimport BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';\nimport { useTranslation } from 'react-i18next';\nimport { z } from 'zod';\nimport { httpClient } from '@/app/infra/http/HttpClient';\n\ninterface BotDetailDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  botId?: string;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  onFormSubmit: (value: z.infer<any>) => void;\n  onFormCancel: () => void;\n  onBotDeleted: () => void;\n  onNewBotCreated: (botId: string) => void;\n}\n\nexport default function BotDetailDialog({\n  open,\n  onOpenChange,\n  botId: propBotId,\n  onFormSubmit,\n  onFormCancel,\n  onBotDeleted,\n  onNewBotCreated,\n}: BotDetailDialogProps) {\n  const { t } = useTranslation();\n  const [botId, setBotId] = useState<string | undefined>(propBotId);\n  const [activeMenu, setActiveMenu] = useState('config');\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n\n  useEffect(() => {\n    setBotId(propBotId);\n    setActiveMenu('config');\n  }, [propBotId, open]);\n\n  const menu = [\n    {\n      key: 'config',\n      label: t('bots.configuration'),\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z\"></path>\n        </svg>\n      ),\n    },\n    {\n      key: 'logs',\n      label: t('bots.logs'),\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z\"></path>\n        </svg>\n      ),\n    },\n    {\n      key: 'sessions',\n      label: t('bots.sessionMonitor.title'),\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z\"></path>\n        </svg>\n      ),\n    },\n  ];\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const handleFormSubmit = (value: any) => {\n    onFormSubmit(value);\n  };\n\n  const handleFormCancel = () => {\n    onFormCancel();\n  };\n\n  const handleBotDeleted = () => {\n    httpClient.deleteBot(botId ?? '').then(() => {\n      onBotDeleted();\n    });\n  };\n\n  const handleNewBotCreated = (newBotId: string) => {\n    setBotId(newBotId);\n    setActiveMenu('config');\n    onNewBotCreated(newBotId);\n  };\n\n  const handleDelete = () => {\n    setShowDeleteConfirm(true);\n  };\n\n  const confirmDelete = () => {\n    handleBotDeleted();\n    setShowDeleteConfirm(false);\n  };\n\n  if (!botId) {\n    return (\n      <>\n        <Dialog open={open} onOpenChange={onOpenChange}>\n          <DialogContent className=\"overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex\">\n            <main className=\"flex flex-1 flex-col h-[70vh]\">\n              <DialogHeader className=\"px-6 pt-6 pb-4 shrink-0\">\n                <DialogTitle>{t('bots.createBot')}</DialogTitle>\n                <DialogDescription className=\"sr-only\">\n                  {t('bots.createBot')}\n                </DialogDescription>\n              </DialogHeader>\n              <div className=\"flex-1 overflow-y-auto px-6 pb-6\">\n                <BotForm\n                  initBotId={undefined}\n                  onFormSubmit={handleFormSubmit}\n                  onBotDeleted={handleBotDeleted}\n                  onNewBotCreated={handleNewBotCreated}\n                />\n              </div>\n              <DialogFooter className=\"px-6 py-4 border-t shrink-0\">\n                <div className=\"flex justify-end gap-2\">\n                  <Button type=\"submit\" form=\"bot-form\">\n                    {t('common.submit')}\n                  </Button>\n                  <Button\n                    type=\"button\"\n                    variant=\"outline\"\n                    onClick={handleFormCancel}\n                  >\n                    {t('common.cancel')}\n                  </Button>\n                </div>\n              </DialogFooter>\n            </main>\n          </DialogContent>\n        </Dialog>\n      </>\n    );\n  }\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={onOpenChange}>\n        <DialogContent className=\"overflow-hidden p-0 !max-w-[70rem] max-h-[75vh] flex\">\n          <SidebarProvider className=\"items-start w-full flex\">\n            <Sidebar\n              collapsible=\"none\"\n              className=\"hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white dark:bg-black\"\n            >\n              <SidebarContent>\n                <SidebarGroup>\n                  <SidebarGroupContent>\n                    <SidebarMenu>\n                      {menu.map((item) => (\n                        <SidebarMenuItem key={item.key}>\n                          <SidebarMenuButton\n                            asChild\n                            isActive={activeMenu === item.key}\n                            onClick={() => setActiveMenu(item.key)}\n                          >\n                            <a href=\"#\">\n                              {item.icon}\n                              <span>{item.label}</span>\n                            </a>\n                          </SidebarMenuButton>\n                        </SidebarMenuItem>\n                      ))}\n                    </SidebarMenu>\n                  </SidebarGroupContent>\n                </SidebarGroup>\n              </SidebarContent>\n            </Sidebar>\n            <main className=\"flex flex-1 flex-col h-[75vh]\">\n              <DialogHeader className=\"px-6 pt-6 pb-4 shrink-0\">\n                <DialogTitle>\n                  {activeMenu === 'config'\n                    ? t('bots.editBot')\n                    : activeMenu === 'logs'\n                      ? t('bots.botLogTitle')\n                      : t('bots.sessionMonitor.title')}\n                </DialogTitle>\n                <DialogDescription className=\"sr-only\">\n                  {activeMenu === 'config'\n                    ? t('bots.editBot')\n                    : activeMenu === 'logs'\n                      ? t('bots.botLogTitle')\n                      : t('bots.sessionMonitor.title')}\n                </DialogDescription>\n              </DialogHeader>\n              <div\n                className={\n                  activeMenu === 'sessions'\n                    ? 'flex-1 min-h-0'\n                    : 'flex-1 overflow-y-auto px-6 pb-6'\n                }\n              >\n                {activeMenu === 'config' && (\n                  <BotForm\n                    initBotId={botId}\n                    onFormSubmit={handleFormSubmit}\n                    onBotDeleted={handleBotDeleted}\n                    onNewBotCreated={handleNewBotCreated}\n                  />\n                )}\n                {activeMenu === 'logs' && botId && (\n                  <BotLogListComponent botId={botId} />\n                )}\n                {activeMenu === 'sessions' && botId && (\n                  <BotSessionMonitor botId={botId} />\n                )}\n              </div>\n              {activeMenu === 'config' && (\n                <DialogFooter className=\"px-6 py-4 border-t shrink-0\">\n                  <div className=\"flex justify-end gap-2\">\n                    <Button\n                      type=\"button\"\n                      variant=\"destructive\"\n                      onClick={handleDelete}\n                    >\n                      {t('common.delete')}\n                    </Button>\n                    <Button type=\"submit\" form=\"bot-form\">\n                      {t('common.save')}\n                    </Button>\n                    <Button\n                      type=\"button\"\n                      variant=\"outline\"\n                      onClick={handleFormCancel}\n                    >\n                      {t('common.cancel')}\n                    </Button>\n                  </div>\n                </DialogFooter>\n              )}\n            </main>\n          </SidebarProvider>\n        </DialogContent>\n      </Dialog>\n\n      {/* 删除确认对话框 */}\n      <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>{t('common.confirmDelete')}</DialogTitle>\n            <DialogDescription className=\"sr-only\">\n              {t('bots.deleteConfirmation')}\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"py-4\">{t('bots.deleteConfirmation')}</div>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setShowDeleteConfirm(false)}\n            >\n              {t('common.cancel')}\n            </Button>\n            <Button variant=\"destructive\" onClick={confirmDelete}>\n              {t('common.confirmDelete')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/bots/botConfig.module.css",
    "content": ".botListContainer {\n  width: 100%;\n  padding-left: 0.8rem;\n  padding-right: 0.8rem;\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));\n  gap: 2rem;\n  justify-items: stretch;\n  align-items: start;\n}\n"
  },
  {
    "path": "web/src/app/home/bots/components/bot-card/BotCard.tsx",
    "content": "import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO';\nimport styles from './botCard.module.css';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { Switch } from '@/components/ui/switch';\nimport { useTranslation } from 'react-i18next';\nimport { toast } from 'sonner';\n\nexport default function BotCard({\n  botCardVO,\n  setBotEnableCallback,\n}: {\n  botCardVO: BotCardVO;\n  setBotEnableCallback: (id: string, enable: boolean) => void;\n}) {\n  const { t } = useTranslation();\n\n  function setBotEnable(enable: boolean) {\n    return httpClient.updateBot(botCardVO.id, {\n      name: botCardVO.name,\n      description: botCardVO.description,\n      adapter: botCardVO.adapter,\n      adapter_config: botCardVO.adapterConfig,\n      enable: enable,\n    });\n  }\n\n  return (\n    <div className={`${styles.cardContainer}`}>\n      <div className={`${styles.iconBasicInfoContainer}`}>\n        <img\n          className={`${styles.iconImage}`}\n          src={botCardVO.iconURL}\n          alt=\"icon\"\n        />\n\n        <div className={`${styles.basicInfoContainer}`}>\n          <div className={`${styles.basicInfoNameContainer}`}>\n            <div className={`${styles.basicInfoName}`}>{botCardVO.name}</div>\n            <div className={`${styles.basicInfoDescription}`}>\n              {botCardVO.description}\n            </div>\n          </div>\n\n          <div className={`${styles.basicInfoAdapterContainer}`}>\n            <svg\n              className={`${styles.basicInfoAdapterIcon}`}\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M2 8.99374C2 5.68349 4.67654 3 8.00066 3H15.9993C19.3134 3 22 5.69478 22 8.99374V21H8.00066C4.68659 21 2 18.3052 2 15.0063V8.99374ZM20 19V8.99374C20 6.79539 18.2049 5 15.9993 5H8.00066C5.78458 5 4 6.78458 4 8.99374V15.0063C4 17.2046 5.79512 19 8.00066 19H20ZM14 11H16V13H14V11ZM8 11H10V13H8V11Z\"></path>\n            </svg>\n            <span className={`${styles.basicInfoAdapterLabel}`}>\n              {botCardVO.adapterLabel}\n            </span>\n          </div>\n\n          <div className={`${styles.basicInfoPipelineContainer}`}>\n            <svg\n              className={`${styles.basicInfoPipelineIcon}`}\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z\"></path>\n            </svg>\n            <span className={`${styles.basicInfoPipelineLabel}`}>\n              {botCardVO.usePipelineName}\n            </span>\n          </div>\n        </div>\n\n        <div className={`${styles.botOperationContainer}`}>\n          <Switch\n            checked={botCardVO.enable}\n            onCheckedChange={(e) => {\n              setBotEnable(e)\n                .then(() => {\n                  setBotEnableCallback(botCardVO.id, e);\n                })\n                .catch((err) => {\n                  console.error(err);\n                  toast.error(t('bots.setBotEnableError'));\n                });\n            }}\n            onClick={(e) => {\n              e.stopPropagation();\n            }}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/bots/components/bot-card/BotCardVO.ts",
    "content": "export interface IBotCardVO {\n  id: string;\n  iconURL: string;\n  name: string;\n  description: string;\n  adapter: string;\n  adapterLabel: string;\n  adapterConfig: object;\n  usePipelineName: string;\n  enable: boolean;\n}\n\nexport class BotCardVO implements IBotCardVO {\n  id: string;\n  iconURL: string;\n  name: string;\n  description: string;\n  adapter: string;\n  adapterLabel: string;\n  adapterConfig: object;\n  usePipelineName: string;\n  enable: boolean;\n\n  constructor(props: IBotCardVO) {\n    this.id = props.id;\n    this.iconURL = props.iconURL;\n    this.name = props.name;\n    this.description = props.description;\n    this.adapter = props.adapter;\n    this.adapterConfig = props.adapterConfig;\n    this.adapterLabel = props.adapterLabel;\n    this.usePipelineName = props.usePipelineName;\n    this.enable = props.enable;\n  }\n}\n"
  },
  {
    "path": "web/src/app/home/bots/components/bot-card/botCard.module.css",
    "content": ".cardContainer {\n  width: 100%;\n  height: 10rem;\n  background-color: #fff;\n  border-radius: 10px;\n  box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);\n  padding: 1.2rem;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n:global(.dark) .cardContainer {\n  background-color: #1f1f22;\n  box-shadow: 0;\n}\n\n.cardContainer:hover {\n  box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);\n}\n\n:global(.dark) .cardContainer:hover {\n  box-shadow: 0;\n}\n\n.iconBasicInfoContainer {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  flex-direction: row;\n  gap: 0.8rem;\n  user-select: none;\n}\n\n.iconImage {\n  width: 4rem;\n  height: 4rem;\n  margin: 0.2rem;\n  border-radius: 8%;\n}\n\n.basicInfoContainer {\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  gap: 0.2rem;\n  width: 100%;\n}\n\n.basicInfoNameContainer {\n  display: flex;\n  flex-direction: column;\n}\n\n.basicInfoName {\n  font-size: 1.4rem;\n  font-weight: 500;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  color: #1a1a1a;\n}\n\n:global(.dark) .basicInfoName {\n  color: #f0f0f0;\n}\n\n.basicInfoDescription {\n  font-size: 1rem;\n  font-weight: 300;\n  color: #b1b1b1;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n:global(.dark) .basicInfoDescription {\n  color: #888888;\n}\n\n.basicInfoAdapterContainer {\n  display: flex;\n  flex-direction: row;\n  gap: 0.4rem;\n}\n\n.basicInfoAdapterIcon {\n  width: 1.2rem;\n  height: 1.2rem;\n  margin-top: 0.2rem;\n  color: #626262;\n}\n\n:global(.dark) .basicInfoAdapterIcon {\n  color: #a0a0a0;\n}\n\n.basicInfoAdapterLabel {\n  font-size: 1.2rem;\n  font-weight: 500;\n  color: #626262;\n}\n\n:global(.dark) .basicInfoAdapterLabel {\n  color: #a0a0a0;\n}\n\n.basicInfoPipelineContainer {\n  display: flex;\n  flex-direction: row;\n  gap: 0.4rem;\n}\n\n.basicInfoPipelineIcon {\n  width: 1.2rem;\n  height: 1.2rem;\n  color: #626262;\n  margin-top: 0.2rem;\n}\n\n:global(.dark) .basicInfoPipelineIcon {\n  color: #a0a0a0;\n}\n\n.basicInfoPipelineLabel {\n  font-size: 1.2rem;\n  font-weight: 500;\n  color: #626262;\n}\n\n:global(.dark) .basicInfoPipelineLabel {\n  color: #a0a0a0;\n}\n\n.bigText {\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  font-size: 1.4rem;\n  font-weight: bold;\n  max-width: 100%;\n}\n\n.botOperationContainer {\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  align-items: flex-end;\n  height: 100%;\n  width: 3rem;\n  gap: 0.4rem;\n}\n"
  },
  {
    "path": "web/src/app/home/bots/components/bot-form/BotForm.tsx",
    "content": "import React, { useEffect, useMemo, useState } from 'react';\nimport {\n  IChooseAdapterEntity,\n  IPipelineEntity,\n} from '@/app/home/bots/components/bot-form/ChooseEntity';\nimport {\n  DynamicFormItemConfig,\n  getDefaultValues,\n  parseDynamicFormItemType,\n} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';\nimport { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';\nimport { UUID } from 'uuidjs';\nimport DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { Bot } from '@/app/infra/entities/api';\n\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { useForm } from 'react-hook-form';\nimport { z } from 'zod';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport { Copy, Check } from 'lucide-react';\n\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from '@/components/ui/form';\nimport { Input } from '@/components/ui/input';\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { Switch } from '@/components/ui/switch';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\nimport { CustomApiError } from '@/app/infra/entities/common';\n\nconst getFormSchema = (t: (key: string) => string) =>\n  z.object({\n    name: z.string().min(1, { message: t('bots.botNameRequired') }),\n    description: z\n      .string()\n      .min(1, { message: t('bots.botDescriptionRequired') }),\n    adapter: z.string().min(1, { message: t('bots.adapterRequired') }),\n    adapter_config: z.record(z.string(), z.any()),\n    enable: z.boolean().optional(),\n    use_pipeline_uuid: z.string().optional(),\n  });\n\nexport default function BotForm({\n  initBotId,\n  onFormSubmit,\n  onBotDeleted,\n  onNewBotCreated,\n}: {\n  initBotId?: string;\n  onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;\n  onBotDeleted: () => void;\n  onNewBotCreated: (botId: string) => void;\n}) {\n  const { t } = useTranslation();\n  const formSchema = getFormSchema(t);\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      name: '',\n      description: t('bots.defaultDescription'),\n      adapter: '',\n      adapter_config: {},\n      enable: true,\n      use_pipeline_uuid: '',\n    },\n  });\n\n  const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);\n\n  const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =\n    useState(new Map<string, IDynamicFormItemSchema[]>());\n  // const [form] = Form.useForm<IBotFormEntity>();\n  const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false);\n  // const [dynamicForm] = Form.useForm();\n  const [adapterNameList, setAdapterNameList] = useState<\n    IChooseAdapterEntity[]\n  >([]);\n  const [adapterIconList, setAdapterIconList] = useState<\n    Record<string, string>\n  >({});\n  const [adapterDescriptionList, setAdapterDescriptionList] = useState<\n    Record<string, string>\n  >({});\n\n  const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(\n    [],\n  );\n\n  const [dynamicFormConfigList, setDynamicFormConfigList] = useState<\n    IDynamicFormItemSchema[]\n  >([]);\n  const [, setIsLoading] = useState<boolean>(false);\n  const [webhookUrl, setWebhookUrl] = useState<string>('');\n  const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');\n  const [copied, setCopied] = useState<boolean>(false);\n  const [extraCopied, setExtraCopied] = useState<boolean>(false);\n\n  // Watch adapter and adapter_config for filtering\n  const currentAdapter = form.watch('adapter');\n  const currentAdapterConfig = form.watch('adapter_config');\n\n  // Derive the filtered config list via useMemo instead of useEffect+setState\n  // to avoid creating new array references that would cause DynamicFormComponent\n  // to re-subscribe its form.watch, re-emit values, and trigger an infinite loop.\n  // Only depend on the specific field we care about (enable-webhook) rather than\n  // the entire currentAdapterConfig object, which changes on every emission.\n  const enableWebhook = currentAdapterConfig?.['enable-webhook'];\n  const filteredDynamicFormConfigList = useMemo(() => {\n    if (currentAdapter === 'lark' && enableWebhook === false) {\n      // Hide encrypt-key field when webhook is disabled\n      return dynamicFormConfigList.filter(\n        (config) => config.name !== 'encrypt-key',\n      );\n    }\n    // For non-Lark adapters or when webhook is enabled/undefined, show all fields\n    return dynamicFormConfigList;\n  }, [currentAdapter, enableWebhook, dynamicFormConfigList]);\n\n  useEffect(() => {\n    setBotFormValues();\n  }, []);\n\n  // 复制到剪贴板的辅助函数\n  const copyToClipboard = (\n    text: string,\n    setStatus: React.Dispatch<React.SetStateAction<boolean>>,\n  ) => {\n    if (navigator.clipboard && navigator.clipboard.writeText) {\n      navigator.clipboard\n        .writeText(text)\n        .then(() => {\n          setStatus(true);\n          setTimeout(() => setStatus(false), 2000);\n        })\n        .catch(() => {\n          // 降级：创建临时textarea复制\n          fallbackCopy(text, setStatus);\n        });\n    } else {\n      fallbackCopy(text, setStatus);\n    }\n  };\n\n  const fallbackCopy = (\n    text: string,\n    setStatus: React.Dispatch<React.SetStateAction<boolean>>,\n  ) => {\n    const textarea = document.createElement('textarea');\n    textarea.value = text;\n    textarea.style.position = 'fixed';\n    textarea.style.opacity = '0';\n    document.body.appendChild(textarea);\n    textarea.select();\n    const successful = document.execCommand('copy');\n    document.body.removeChild(textarea);\n    if (successful) {\n      setStatus(true);\n      setTimeout(() => setStatus(false), 2000);\n    }\n  };\n\n  function setBotFormValues() {\n    initBotFormComponent().then(() => {\n      // 拉取初始化表单信息\n      if (initBotId) {\n        getBotConfig(initBotId)\n          .then((val) => {\n            form.setValue('name', val.name);\n            form.setValue('description', val.description);\n            form.setValue('adapter', val.adapter);\n            form.setValue('adapter_config', val.adapter_config);\n            form.setValue('enable', val.enable);\n            form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || '');\n            handleAdapterSelect(val.adapter);\n            // dynamicForm.setFieldsValue(val.adapter_config);\n\n            // 设置 webhook 地址（如果有）\n            if (val.webhook_full_url) {\n              setWebhookUrl(val.webhook_full_url);\n            } else {\n              setWebhookUrl('');\n            }\n            setExtraWebhookUrl(val.extra_webhook_full_url || '');\n          })\n          .catch((err) => {\n            toast.error(\n              t('bots.getBotConfigError') + (err as CustomApiError).msg,\n            );\n          });\n      } else {\n        form.reset();\n        setWebhookUrl('');\n        setExtraWebhookUrl('');\n      }\n    });\n  }\n\n  async function initBotFormComponent() {\n    // 初始化流水线列表\n    const pipelinesRes = await httpClient.getPipelines();\n    setPipelineNameList(\n      pipelinesRes.pipelines.map((item) => {\n        return {\n          label: item.name,\n          value: item.uuid ?? '',\n        };\n      }),\n    );\n\n    // 拉取adapter\n    const adaptersRes = await httpClient.getAdapters();\n    setAdapterNameList(\n      adaptersRes.adapters.map((item) => {\n        return {\n          label: extractI18nObject(item.label),\n          value: item.name,\n        };\n      }),\n    );\n\n    // 初始化适配器图标列表\n    setAdapterIconList(\n      adaptersRes.adapters.reduce(\n        (acc, item) => {\n          acc[item.name] = httpClient.getAdapterIconURL(item.name);\n          return acc;\n        },\n        {} as Record<string, string>,\n      ),\n    );\n\n    // 初始化适配器描述列表\n    setAdapterDescriptionList(\n      adaptersRes.adapters.reduce(\n        (acc, item) => {\n          acc[item.name] = extractI18nObject(item.description);\n          return acc;\n        },\n        {} as Record<string, string>,\n      ),\n    );\n\n    // 初始化适配器表单map\n    adaptersRes.adapters.forEach((rawAdapter) => {\n      adapterNameToDynamicConfigMap.set(\n        rawAdapter.name,\n        rawAdapter.spec.config.map(\n          (item) =>\n            new DynamicFormItemConfig({\n              default: item.default,\n              id: UUID.generate(),\n              label: item.label,\n              description: item.description,\n              name: item.name,\n              required: item.required,\n              type: parseDynamicFormItemType(item.type),\n              options: item.options,\n              show_if: item.show_if,\n            }),\n        ),\n      );\n    });\n    setAdapterNameToDynamicConfigMap(adapterNameToDynamicConfigMap);\n  }\n\n  async function getBotConfig(botId: string): Promise<\n    z.infer<typeof formSchema> & {\n      webhook_full_url?: string;\n      extra_webhook_full_url?: string;\n    }\n  > {\n    return new Promise((resolve, reject) => {\n      httpClient\n        .getBot(botId)\n        .then((res) => {\n          const bot = res.bot;\n          const runtimeValues = bot.adapter_runtime_values as\n            | Record<string, unknown>\n            | undefined;\n          resolve({\n            adapter: bot.adapter,\n            description: bot.description,\n            name: bot.name,\n            adapter_config: bot.adapter_config,\n            enable: bot.enable ?? true,\n            use_pipeline_uuid: bot.use_pipeline_uuid ?? '',\n            webhook_full_url: runtimeValues?.webhook_full_url as\n              | string\n              | undefined,\n            extra_webhook_full_url: runtimeValues?.extra_webhook_full_url as\n              | string\n              | undefined,\n          });\n        })\n        .catch((err) => {\n          reject(err);\n        });\n    });\n  }\n\n  function handleAdapterSelect(adapterName: string) {\n    if (adapterName) {\n      const dynamicFormConfigList =\n        adapterNameToDynamicConfigMap.get(adapterName);\n      if (dynamicFormConfigList) {\n        setDynamicFormConfigList(dynamicFormConfigList);\n        if (!initBotId) {\n          form.setValue(\n            'adapter_config',\n            getDefaultValues(dynamicFormConfigList),\n          );\n        }\n      }\n      setShowDynamicForm(true);\n    } else {\n      setShowDynamicForm(false);\n    }\n  }\n\n  // 只有通过外层固定表单验证才会走到这里，真正的提交逻辑在这里\n  function onDynamicFormSubmit() {\n    setIsLoading(true);\n    if (initBotId) {\n      // 编辑提交\n      const updateBot: Bot = {\n        uuid: initBotId,\n        name: form.getValues().name,\n        description: form.getValues().description,\n        adapter: form.getValues().adapter,\n        adapter_config: form.getValues().adapter_config,\n        enable: form.getValues().enable,\n        use_pipeline_uuid: form.getValues().use_pipeline_uuid,\n      };\n      httpClient\n        .updateBot(initBotId, updateBot)\n        .then(() => {\n          onFormSubmit(form.getValues());\n          toast.success(t('bots.saveSuccess'));\n        })\n        .catch((err) => {\n          toast.error(t('bots.saveError') + err.msg);\n        })\n        .finally(() => {\n          setIsLoading(false);\n          // form.reset();\n          // dynamicForm.resetFields();\n        });\n    } else {\n      // 创建提交\n      const newBot: Bot = {\n        name: form.getValues().name,\n        description: form.getValues().description,\n        adapter: form.getValues().adapter,\n        adapter_config: form.getValues().adapter_config,\n      };\n      httpClient\n        .createBot(newBot)\n        .then((res) => {\n          toast.success(t('bots.createSuccess'));\n          initBotId = res.uuid;\n\n          setBotFormValues();\n\n          onNewBotCreated(res.uuid);\n        })\n        .catch((err) => {\n          toast.error(t('bots.createError') + err.msg);\n        })\n        .finally(() => {\n          setIsLoading(false);\n          form.reset();\n          // dynamicForm.resetFields();\n        });\n    }\n  }\n\n  function deleteBot() {\n    if (initBotId) {\n      httpClient\n        .deleteBot(initBotId)\n        .then(() => {\n          onBotDeleted();\n          toast.success(t('bots.deleteSuccess'));\n        })\n        .catch((err) => {\n          toast.error(t('bots.deleteError') + err.msg);\n        });\n    }\n  }\n\n  return (\n    <div>\n      <Dialog\n        open={showDeleteConfirmModal}\n        onOpenChange={setShowDeleteConfirmModal}\n      >\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>{t('common.confirmDelete')}</DialogTitle>\n          </DialogHeader>\n          <DialogDescription>{t('bots.deleteConfirmation')}</DialogDescription>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setShowDeleteConfirmModal(false)}\n            >\n              取消\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={() => {\n                deleteBot();\n                setShowDeleteConfirmModal(false);\n              }}\n            >\n              {t('common.confirmDelete')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      <Form {...form}>\n        <form\n          id=\"bot-form\"\n          onSubmit={form.handleSubmit(onDynamicFormSubmit)}\n          className=\"space-y-8\"\n        >\n          <div className=\"space-y-4\">\n            {/* 是否启用 & 绑定流水线  仅在编辑模式 */}\n            {initBotId && (\n              <>\n                <div className=\"flex items-center gap-6\">\n                  <FormField\n                    control={form.control}\n                    name=\"enable\"\n                    render={({ field }) => (\n                      <FormItem className=\"flex flex-col justify-start gap-[0.8rem] h-[3.8rem]\">\n                        <FormLabel>{t('common.enable')}</FormLabel>\n                        <FormControl>\n                          <Switch\n                            checked={field.value}\n                            onCheckedChange={field.onChange}\n                          />\n                        </FormControl>\n                      </FormItem>\n                    )}\n                  />\n\n                  <FormField\n                    control={form.control}\n                    name=\"use_pipeline_uuid\"\n                    render={({ field }) => (\n                      <FormItem className=\"flex flex-col justify-start gap-[0.8rem] h-[3.8rem]\">\n                        <FormLabel>{t('bots.bindPipeline')}</FormLabel>\n                        <FormControl>\n                          <Select onValueChange={field.onChange} {...field}>\n                            <SelectTrigger className=\"bg-[#ffffff] dark:bg-[#2a2a2e]\">\n                              <SelectValue\n                                placeholder={t('bots.selectPipeline')}\n                              />\n                            </SelectTrigger>\n                            <SelectContent className=\"fixed z-[1000]\">\n                              <SelectGroup>\n                                {pipelineNameList.map((item) => (\n                                  <SelectItem\n                                    key={item.value}\n                                    value={item.value}\n                                  >\n                                    {item.label}\n                                  </SelectItem>\n                                ))}\n                              </SelectGroup>\n                            </SelectContent>\n                          </Select>\n                        </FormControl>\n                      </FormItem>\n                    )}\n                  />\n                </div>\n\n                {/* Webhook 地址显示（统一 Webhook 模式） */}\n                {webhookUrl &&\n                  (currentAdapter !== 'lark' || enableWebhook !== false) && (\n                    <FormItem>\n                      <FormLabel>{t('bots.webhookUrl')}</FormLabel>\n                      <div className=\"flex items-center gap-2\">\n                        <Input\n                          value={webhookUrl}\n                          readOnly\n                          className=\"flex-1 bg-gray-50 dark:bg-gray-900\"\n                          onClick={(e) => {\n                            // 点击输入框时自动全选\n                            (e.target as HTMLInputElement).select();\n                          }}\n                        />\n                        <Button\n                          type=\"button\"\n                          variant=\"outline\"\n                          size=\"sm\"\n                          onClick={() => copyToClipboard(webhookUrl, setCopied)}\n                        >\n                          {copied ? (\n                            <Check className=\"h-4 w-4 text-green-600 mr-2\" />\n                          ) : (\n                            <Copy className=\"h-4 w-4 mr-2\" />\n                          )}\n                          {t('common.copy')}\n                        </Button>\n                      </div>\n                      {extraWebhookUrl && (\n                        <div className=\"flex items-center gap-2 mt-2\">\n                          <Input\n                            value={extraWebhookUrl}\n                            readOnly\n                            className=\"flex-1 bg-gray-50 dark:bg-gray-900\"\n                            onClick={(e) => {\n                              (e.target as HTMLInputElement).select();\n                            }}\n                          />\n                          <Button\n                            type=\"button\"\n                            variant=\"outline\"\n                            size=\"sm\"\n                            onClick={() =>\n                              copyToClipboard(extraWebhookUrl, setExtraCopied)\n                            }\n                          >\n                            {extraCopied ? (\n                              <Check className=\"h-4 w-4 text-green-600 mr-2\" />\n                            ) : (\n                              <Copy className=\"h-4 w-4 mr-2\" />\n                            )}\n                            {t('common.copy')}\n                          </Button>\n                        </div>\n                      )}\n                      <p className=\"text-sm text-gray-500 mt-1\">\n                        {extraWebhookUrl\n                          ? t('bots.webhookUrlHintEither')\n                          : t('bots.webhookUrlHint')}\n                      </p>\n                    </FormItem>\n                  )}\n              </>\n            )}\n\n            <FormField\n              control={form.control}\n              name=\"name\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>\n                    {t('bots.botName')}\n                    <span className=\"text-red-500\">*</span>\n                  </FormLabel>\n                  <FormControl>\n                    <Input {...field} />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n            <FormField\n              control={form.control}\n              name=\"description\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>\n                    {t('bots.botDescription')}\n                    <span className=\"text-red-500\">*</span>\n                  </FormLabel>\n                  <FormControl>\n                    <Input {...field} />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n\n            <FormField\n              control={form.control}\n              name=\"adapter\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>\n                    {t('bots.platformAdapter')}\n                    <span className=\"text-red-500\">*</span>\n                  </FormLabel>\n                  <FormControl>\n                    <div className=\"relative\">\n                      <Select\n                        onValueChange={(value) => {\n                          field.onChange(value);\n                          handleAdapterSelect(value);\n                        }}\n                        value={field.value}\n                      >\n                        <SelectTrigger className=\"w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]\">\n                          <SelectValue placeholder={t('bots.selectAdapter')} />\n                        </SelectTrigger>\n                        <SelectContent className=\"fixed z-[1000]\">\n                          <SelectGroup>\n                            {adapterNameList.map((item) => (\n                              <SelectItem key={item.value} value={item.value}>\n                                {item.label}\n                              </SelectItem>\n                            ))}\n                          </SelectGroup>\n                        </SelectContent>\n                      </Select>\n                    </div>\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n\n            {form.watch('adapter') && (\n              <div className=\"flex items-start gap-3 p-4 rounded-lg border\">\n                <img\n                  src={adapterIconList[form.watch('adapter')]}\n                  alt=\"adapter icon\"\n                  className=\"w-12 h-12 rounded-[8%]\"\n                />\n                <div className=\"flex flex-col gap-1\">\n                  <div className=\"font-medium\">\n                    {\n                      adapterNameList.find(\n                        (item) => item.value === form.watch('adapter'),\n                      )?.label\n                    }\n                  </div>\n                  <div className=\"text-sm text-gray-500\">\n                    {adapterDescriptionList[form.watch('adapter')]}\n                  </div>\n                </div>\n              </div>\n            )}\n\n            {showDynamicForm && filteredDynamicFormConfigList.length > 0 && (\n              <div className=\"space-y-4\">\n                <div className=\"text-lg font-medium\">\n                  {t('bots.adapterConfig')}\n                </div>\n                <DynamicFormComponent\n                  itemConfigList={filteredDynamicFormConfigList}\n                  initialValues={currentAdapterConfig}\n                  onSubmit={(values) => {\n                    form.setValue('adapter_config', values);\n                  }}\n                />\n              </div>\n            )}\n          </div>\n        </form>\n      </Form>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/bots/components/bot-form/ChooseEntity.ts",
    "content": "export interface IChooseAdapterEntity {\n  label: string;\n  value: string;\n}\n\nexport interface IPipelineEntity {\n  label: string;\n  value: string;\n}\n"
  },
  {
    "path": "web/src/app/home/bots/components/bot-log/BotLogManager.ts",
    "content": "import { httpClient } from '@/app/infra/http/HttpClient';\nimport {\n  BotLog,\n  GetBotLogsResponse,\n} from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';\n\nexport class BotLogManager {\n  private botId: string;\n  private callbacks: ((_: BotLog[]) => void)[] = [];\n  private intervalIds: number[] = [];\n\n  constructor(botId: string) {\n    this.botId = botId;\n  }\n\n  startListenServerPush() {\n    const timerNumber = setInterval(() => {\n      this.getLogList(-1, 50).then((response) => {\n        this.callbacks.forEach((callback) =>\n          callback(this.parseResponse(response)),\n        );\n      });\n    }, 3000);\n    this.intervalIds.push(Number(timerNumber));\n  }\n\n  stopServerPush() {\n    this.intervalIds.forEach((id) => clearInterval(id));\n    this.intervalIds = [];\n  }\n\n  subscribeLogPush(callback: (_: BotLog[]) => void) {\n    if (!this.callbacks.includes(callback)) {\n      this.callbacks.push(callback);\n    }\n  }\n\n  dispose() {\n    this.callbacks = [];\n  }\n\n  /**\n   * 获取日志页的基本信息\n   */\n  private getLogList(next: number, count: number = 20) {\n    return httpClient.getBotLogs(this.botId, {\n      from_index: next,\n      max_count: count,\n    });\n  }\n\n  async loadFirstPage() {\n    return this.parseResponse(await this.getLogList(-1, 10));\n  }\n\n  async loadMore(position: number, total: number) {\n    return this.parseResponse(await this.getLogList(position, total));\n  }\n\n  private parseResponse(httpResponse: GetBotLogsResponse): BotLog[] {\n    return httpResponse.logs;\n  }\n}\n"
  },
  {
    "path": "web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx",
    "content": "'use client';\n\nimport { useState } from 'react';\nimport { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';\nimport styles from './botLog.module.css';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { PhotoProvider } from 'react-photo-view';\nimport { useTranslation } from 'react-i18next';\nimport { Check, ChevronDown, ChevronRight } from 'lucide-react';\nimport { toast } from 'sonner';\n\nexport function BotLogCard({ botLog }: { botLog: BotLog }) {\n  const { t } = useTranslation();\n  const baseURL = httpClient.getBaseUrl();\n  const [copied, setCopied] = useState(false);\n  const [expanded, setExpanded] = useState(false);\n\n  // Fallback 复制方法，用于不支持 clipboard API 的环境\n  function fallbackCopy(text: string) {\n    const textArea = document.createElement('textarea');\n    textArea.value = text;\n    textArea.style.position = 'fixed';\n    textArea.style.left = '-9999px';\n    textArea.style.top = '-9999px';\n    document.body.appendChild(textArea);\n    textArea.focus();\n    textArea.select();\n    try {\n      document.execCommand('copy');\n      toast.success(t('common.copySuccess'));\n    } catch {\n      toast.error(t('common.copyFailed'));\n    }\n    document.body.removeChild(textArea);\n  }\n\n  function formatTime(timestamp: number) {\n    const now = new Date();\n    const date = new Date(timestamp * 1000);\n\n    // 获取各个时间部分\n    const year = date.getFullYear();\n    const month = date.getMonth() + 1; // 月份从0开始，需要+1\n    const day = date.getDate();\n    const hours = date.getHours().toString().padStart(2, '0');\n    const minutes = date.getMinutes().toString().padStart(2, '0');\n\n    // 判断时间范围\n    const isToday = now.toDateString() === date.toDateString();\n    const isYesterday =\n      new Date(now.setDate(now.getDate() - 1)).toDateString() ===\n      date.toDateString();\n    const isThisYear = now.getFullYear() === year;\n\n    if (isToday) {\n      return `${hours}:${minutes}`; // 今天的消息：小时:分钟\n    } else if (isYesterday) {\n      return `${t('bots.yesterday')} ${hours}:${minutes}`; // 昨天的消息：昨天 小时:分钟\n    } else if (isThisYear) {\n      return t('bots.dateFormat', { month, day }); // 本年消息：x月x日\n    } else {\n      return t('bots.earlier'); // 更早的消息：更久之前\n    }\n  }\n\n  function getSubChatId(str: string) {\n    const strArr = str.split('');\n    return strArr;\n  }\n\n  // 根据日志级别返回对应的样式类\n  function getLevelStyles(level: string) {\n    switch (level.toLowerCase()) {\n      case 'error':\n        return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';\n      case 'warning':\n        return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';\n      case 'info':\n        return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';\n      case 'debug':\n        return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';\n      default:\n        return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';\n    }\n  }\n\n  // 截取文本的简短版本\n  function getShortText(text: string, maxLength: number = 100) {\n    if (text.length <= maxLength) return text;\n    return text.substring(0, maxLength) + '...';\n  }\n\n  // 判断是否需要展开按钮\n  const needsExpand = botLog.text.length > 100 || botLog.images.length > 0;\n\n  return (\n    <div className={`${styles.botLogCardContainer}`}>\n      {/* 头部标签，时间 */}\n      <div className={`${styles.cardTitleContainer}`}>\n        <div className={`flex flex-row gap-2 items-center`}>\n          <div\n            className={`px-2 py-1 rounded text-xs font-medium uppercase ${getLevelStyles(\n              botLog.level,\n            )}`}\n          >\n            {botLog.level}\n          </div>\n          {botLog.message_session_id && (\n            <div\n              className={`${styles.tag} ${styles.chatTag} relative`}\n              onClick={(e) => {\n                e.stopPropagation();\n                // 兼容性更好的复制方法\n                if (navigator.clipboard && navigator.clipboard.writeText) {\n                  navigator.clipboard\n                    .writeText(botLog.message_session_id)\n                    .then(() => {\n                      setCopied(true);\n                      setTimeout(() => setCopied(false), 2000);\n                      toast.success(t('common.copySuccess'));\n                    })\n                    .catch(() => {\n                      // fallback\n                      fallbackCopy(botLog.message_session_id);\n                    });\n                } else {\n                  fallbackCopy(botLog.message_session_id);\n                }\n              }}\n              title={t('common.clickToCopy')}\n            >\n              {copied ? (\n                <Check className=\"w-4 h-4 text-green-600\" />\n              ) : (\n                <svg\n                  className=\"icon\"\n                  viewBox=\"0 0 1024 1024\"\n                  version=\"1.1\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  p-id=\"1664\"\n                  width=\"16\"\n                  height=\"16\"\n                  fill=\"currentColor\"\n                >\n                  <path\n                    d=\"M96.1 575.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z\"\n                    p-id=\"1665\"\n                    fill=\"currentColor\"\n                  ></path>\n                  <path\n                    d=\"M742.1 450.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.1 26-13.8 26-31s-11.7-31.3-26-31.4zM742.1 577.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.2 26-13.8 26-31s-11.7-31.3-26-31.4z\"\n                    p-id=\"1666\"\n                    fill=\"currentColor\"\n                  ></path>\n                  <path\n                    d=\"M736.1 63.9H417c-70.4 0-128 57.6-128 128h-64.9c-70.4 0-128 57.6-128 128v128c-0.1 17.7 14.4 32 32.2 32 17.8 0 32.2-14.4 32.2-32.1V320c0-35.2 28.8-64 64-64H289v447.8c0 70.4 57.6 128 128 128h255.1c-0.1 35.2-28.8 63.8-64 63.8H224.5c-35.2 0-64-28.8-64-64V703.5c0-17.7-14.4-32.1-32.2-32.1-17.8 0-32.3 14.4-32.3 32.1v128.3c0 70.4 57.6 128 128 128h384.1c70.4 0 128-57.6 128-128h65c70.4 0 128-57.6 128-128V255.9l-193-192z m0.1 63.4l127.7 128.3H800c-35.2 0-64-28.8-64-64v-64.3h0.2z m64 641H416.1c-35.2 0-64-28.8-64-64v-513c0-35.2 28.8-64 64-64H671V191c0 70.4 57.6 128 128 128h65.2v385.3c0 35.2-28.8 64-64 64z\"\n                    p-id=\"1667\"\n                    fill=\"currentColor\"\n                  ></path>\n                </svg>\n              )}\n\n              <span className={`${styles.chatId}`}>\n                {getSubChatId(botLog.message_session_id)}\n              </span>\n            </div>\n          )}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {needsExpand && (\n            <button\n              onClick={() => setExpanded(!expanded)}\n              className=\"flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors\"\n            >\n              {expanded ? (\n                <>\n                  <ChevronDown className=\"w-3 h-3\" />\n                  {t('bots.collapse')}\n                </>\n              ) : (\n                <>\n                  <ChevronRight className=\"w-3 h-3\" />\n                  {t('bots.viewDetails')}\n                </>\n              )}\n            </button>\n          )}\n          <div className={`${styles.timestamp}`}>\n            {formatTime(botLog.timestamp)}\n          </div>\n        </div>\n      </div>\n\n      {/* 日志内容 - 简化显示 */}\n      <div className={`${styles.cardText}`}>\n        {expanded ? botLog.text : getShortText(botLog.text)}\n      </div>\n\n      {/* 图片 - 只在展开时显示 */}\n      {expanded && botLog.images.length > 0 && (\n        <PhotoProvider>\n          <div className={`flex flex-wrap gap-2 mt-3`}>\n            {botLog.images.map((item) => (\n              <img\n                key={item}\n                src={`${baseURL}/api/v1/files/image/${item}`}\n                alt=\"\"\n                className=\"max-w-xs rounded cursor-pointer hover:opacity-90 transition-opacity\"\n              />\n            ))}\n          </div>\n        </PhotoProvider>\n      )}\n\n      {/* 图片数量提示 - 未展开时显示 */}\n      {!expanded && botLog.images.length > 0 && (\n        <div className=\"mt-2 text-xs text-gray-500 dark:text-gray-400\">\n          📷 {botLog.images.length} {t('bots.imagesAttached')}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx",
    "content": "'use client';\n\nimport { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';\nimport { useCallback, useEffect, useRef, useState, useMemo } from 'react';\nimport { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';\nimport { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard';\nimport styles from './botLog.module.css';\nimport { Switch } from '@/components/ui/switch';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { ChevronDownIcon, ExternalLink } from 'lucide-react';\nimport { debounce } from 'lodash';\nimport { useTranslation } from 'react-i18next';\nimport { useRouter } from 'next/navigation';\n\nexport function BotLogListComponent({ botId }: { botId: string }) {\n  const { t } = useTranslation();\n  const router = useRouter();\n  const manager = useRef(new BotLogManager(botId)).current;\n  const [botLogList, setBotLogList] = useState<BotLog[]>([]);\n  const [autoFlush, setAutoFlush] = useState(true);\n  const [selectedLevels, setSelectedLevels] = useState<string[]>([\n    'info',\n    'warning',\n    'error',\n  ]);\n  const listContainerRef = useRef<HTMLDivElement>(null);\n  const botLogListRef = useRef<BotLog[]>(botLogList);\n\n  const logLevels = [\n    { value: 'error', label: 'ERROR' },\n    { value: 'warning', label: 'WARNING' },\n    { value: 'info', label: 'INFO' },\n    { value: 'debug', label: 'DEBUG' },\n  ];\n\n  useEffect(() => {\n    initComponent();\n    return () => {\n      onDestroy();\n    };\n  }, []);\n\n  useEffect(() => {\n    botLogListRef.current = botLogList;\n  }, [botLogList]);\n\n  // 根据级别过滤日志\n  const filteredLogs = useMemo(() => {\n    if (selectedLevels.length === 0) {\n      return botLogList;\n    }\n    return botLogList.filter((log) => selectedLevels.includes(log.level));\n  }, [botLogList, selectedLevels]);\n\n  const handleLevelToggle = (levelValue: string) => {\n    setSelectedLevels((prev) => {\n      if (prev.includes(levelValue)) {\n        return prev.filter((l) => l !== levelValue);\n      } else {\n        return [...prev, levelValue];\n      }\n    });\n  };\n\n  const getDisplayText = () => {\n    if (selectedLevels.length === 0) {\n      return t('bots.selectLevel');\n    }\n    if (selectedLevels.length === logLevels.length) {\n      return t('bots.allLevels');\n    }\n    // 如果选中3个或以上，显示数量\n    if (selectedLevels.length >= 3) {\n      return `${selectedLevels.length} ${t('bots.levelsSelected')}`;\n    }\n    // 显示选中级别的标签（大写形式）\n    return logLevels\n      .filter((level) => selectedLevels.includes(level.value))\n      .map((level) => level.label)\n      .join(', ');\n  };\n\n  // 观测自动刷新状态\n  useEffect(() => {\n    if (autoFlush) {\n      manager.startListenServerPush();\n    } else {\n      manager.stopServerPush();\n    }\n    return () => {\n      manager.stopServerPush();\n    };\n  }, [autoFlush]);\n\n  function initComponent() {\n    // 订阅日志推送\n    manager.subscribeLogPush(handleBotLogPush);\n    // 加载第一页日志\n    manager.loadFirstPage().then((response) => {\n      setBotLogList(response.reverse());\n    });\n    // 监听滚动\n    listenScroll();\n  }\n\n  function onDestroy() {\n    manager.dispose();\n    removeScrollListener();\n  }\n\n  function listenScroll() {\n    if (!listContainerRef.current) {\n      return;\n    }\n    const list = listContainerRef.current;\n    list.addEventListener('scroll', handleScroll);\n  }\n\n  function removeScrollListener() {\n    if (!listContainerRef.current) {\n      return;\n    }\n    const list = listContainerRef.current;\n    list.removeEventListener('scroll', handleScroll);\n  }\n\n  function loadMore() {\n    // 加载更多日志\n    const list = botLogListRef.current;\n    const lastSeq = list[list.length - 1].seq_id;\n    if (lastSeq === 0) {\n      return;\n    }\n    manager.loadMore(lastSeq - 1, 10).then((response) => {\n      setBotLogList([...list, ...response.reverse()]);\n    });\n  }\n\n  function handleBotLogPush(response: BotLog[]) {\n    setBotLogList(response.reverse());\n  }\n\n  const handleScroll = useCallback(\n    debounce(() => {\n      if (!listContainerRef.current) return;\n\n      const { scrollTop, scrollHeight, clientHeight } =\n        listContainerRef.current;\n      const isBottom = scrollTop + clientHeight >= scrollHeight - 5;\n      const isTop = scrollTop === 0;\n\n      if (isBottom) {\n        setAutoFlush(false);\n        loadMore();\n      }\n      if (isTop) {\n        setAutoFlush(true);\n      }\n      if (!isTop && !isBottom) {\n        setAutoFlush(false);\n      }\n    }, 300), // 防抖延迟 300ms\n    [botLogList], // 依赖项为空\n  );\n\n  return (\n    <div className={`${styles.botLogListContainer}`} ref={listContainerRef}>\n      <div className={`${styles.listHeader}`}>\n        <div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div>\n        <Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} />\n        <div className={'ml-4 mr-2'}>{t('bots.logLevel')}</div>\n        <Popover>\n          <PopoverTrigger asChild>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"w-[180px] flex items-center justify-between\"\n            >\n              <span className=\"text-sm truncate flex-1 text-left\">\n                {getDisplayText()}\n              </span>\n              <ChevronDownIcon className=\"ml-2 h-4 w-4 flex-shrink-0\" />\n            </Button>\n          </PopoverTrigger>\n          <PopoverContent className=\"w-[180px] p-2\">\n            <div className=\"flex flex-col gap-2\">\n              {logLevels.map((level) => (\n                <div key={level.value} className=\"flex items-center space-x-2\">\n                  <Checkbox\n                    id={level.value}\n                    checked={selectedLevels.includes(level.value)}\n                    onCheckedChange={() => handleLevelToggle(level.value)}\n                  />\n                  <label\n                    htmlFor={level.value}\n                    className=\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer\"\n                  >\n                    {level.label}\n                  </label>\n                </div>\n              ))}\n            </div>\n          </PopoverContent>\n        </Popover>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className=\"ml-4 flex items-center gap-1\"\n          onClick={() => router.push(`/home/monitoring?botId=${botId}`)}\n        >\n          <ExternalLink className=\"h-4 w-4\" />\n          <span className=\"text-sm\">{t('bots.viewDetailedLogs')}</span>\n        </Button>\n      </div>\n\n      {filteredLogs.map((botLog) => {\n        return <BotLogCard botLog={botLog} key={botLog.seq_id} />;\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/bots/components/bot-log/view/botLog.module.css",
    "content": ".botLogListContainer {\n  width: 100%;\n  max-width: 100%;\n  min-height: 10rem;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: flex-start;\n  overflow-y: auto;\n  overflow-x: hidden;\n  box-sizing: border-box;\n}\n\n.botLogCardContainer {\n  width: 100%;\n  max-width: 100%;\n  background-color: #fff;\n  border-radius: 8px;\n  border: 1px solid #e2e8f0;\n  padding: 1rem;\n  margin-bottom: 0.75rem;\n  transition: all 0.2s ease;\n  overflow: hidden;\n  box-sizing: border-box;\n}\n\n.botLogCardContainer:hover {\n  border-color: #cbd5e1;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);\n}\n\n:global(.dark) .botLogCardContainer {\n  background-color: #1f1f22;\n  border: 1px solid #2a2a2e;\n}\n\n:global(.dark) .botLogCardContainer:hover {\n  border-color: #3a3a3e;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n}\n\n.listHeader {\n  width: 100%;\n  height: 2.5rem;\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  margin-bottom: 0.5rem;\n}\n\n.tag {\n  display: inline-flex;\n  flex-direction: row;\n  align-items: center;\n  justify-content: center;\n  gap: 0.25rem;\n  height: auto;\n  padding: 0.25rem 0.5rem;\n  border-radius: 4px;\n  background-color: #dbeafe;\n  color: #1e40af;\n  font-size: 0.75rem;\n  font-weight: 500;\n  max-width: 16rem;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  text-transform: uppercase;\n  letter-spacing: 0.025em;\n}\n\n:global(.dark) .tag {\n  background-color: #1e3a8a;\n  color: #93c5fd;\n}\n\n.chatTag {\n  color: #4b5563;\n  background-color: #f3f4f6;\n  text-transform: none;\n  cursor: pointer;\n  transition: all 0.15s ease;\n}\n\n.chatTag:hover {\n  background-color: #e5e7eb;\n}\n\n:global(.dark) .chatTag {\n  color: #9ca3af;\n  background-color: #374151;\n}\n\n:global(.dark) .chatTag:hover {\n  background-color: #4b5563;\n}\n\n.chatId {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,\n    'Courier New', monospace;\n  font-size: 0.7rem;\n}\n\n.cardTitleContainer {\n  width: 100%;\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 0.5rem;\n}\n\n.cardText {\n  color: #1e293b;\n  font-size: 0.875rem;\n  line-height: 1.7;\n  white-space: pre-wrap;\n  word-wrap: break-word;\n  word-break: break-all;\n  overflow-wrap: anywhere;\n  hyphens: auto;\n  max-width: 100%;\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n    'Ubuntu', 'Cantarell', sans-serif;\n}\n\n:global(.dark) .cardText {\n  color: #e2e8f0;\n}\n\n.timestamp {\n  color: #64748b;\n  font-size: 0.75rem;\n  white-space: nowrap;\n}\n\n:global(.dark) .timestamp {\n  color: #64748b;\n}\n"
  },
  {
    "path": "web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx",
    "content": "'use client';\n\nimport React, { useState, useEffect, useRef, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Button } from '@/components/ui/button';\nimport { cn } from '@/lib/utils';\nimport { Copy, Check } from 'lucide-react';\nimport {\n  MessageChainComponent,\n  Plain,\n  At,\n  Image,\n  Quote,\n  Voice,\n} from '@/app/infra/entities/message';\n\ninterface SessionInfo {\n  session_id: string;\n  bot_id: string;\n  bot_name: string;\n  pipeline_id: string;\n  pipeline_name: string;\n  message_count: number;\n  start_time: string;\n  last_activity: string;\n  is_active: boolean;\n  platform?: string | null;\n  user_id?: string | null;\n  user_name?: string | null;\n}\n\ninterface SessionMessage {\n  id: string;\n  timestamp: string;\n  bot_id: string;\n  bot_name: string;\n  pipeline_id: string;\n  pipeline_name: string;\n  message_content: string;\n  session_id: string;\n  status: string;\n  level: string;\n  platform?: string | null;\n  user_id?: string | null;\n  runner_name?: string | null;\n  variables?: string | null;\n  role?: string | null;\n}\n\ninterface BotSessionMonitorProps {\n  botId: string;\n}\n\nexport default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {\n  const { t } = useTranslation();\n  const [sessions, setSessions] = useState<SessionInfo[]>([]);\n  const [selectedSessionId, setSelectedSessionId] = useState<string | null>(\n    null,\n  );\n  const [messages, setMessages] = useState<SessionMessage[]>([]);\n  const [loadingSessions, setLoadingSessions] = useState(false);\n  const [loadingMessages, setLoadingMessages] = useState(false);\n  const [copiedUserId, setCopiedUserId] = useState(false);\n  const messagesContainerRef = useRef<HTMLDivElement>(null);\n\n  const parseSessionType = (sessionId: string): string | null => {\n    const idx = sessionId.indexOf('_');\n    if (idx === -1) return null;\n    const type = sessionId.slice(0, idx);\n    if (type === 'person' || type === 'group') return type;\n    return null;\n  };\n\n  const abbreviateId = (id: string): string => {\n    if (id.length <= 10) return id;\n    return `${id.slice(0, 4)}..${id.slice(-4)}`;\n  };\n\n  const copyUserId = (userId: string) => {\n    navigator.clipboard.writeText(userId).then(() => {\n      setCopiedUserId(true);\n      setTimeout(() => setCopiedUserId(false), 2000);\n    });\n  };\n\n  const loadSessions = useCallback(async () => {\n    setLoadingSessions(true);\n    try {\n      const response = await httpClient.getBotSessions(botId);\n      setSessions(response.sessions ?? []);\n    } catch (error) {\n      console.error('Failed to load sessions:', error);\n    } finally {\n      setLoadingSessions(false);\n    }\n  }, [botId]);\n\n  const loadMessages = useCallback(async (sessionId: string) => {\n    setLoadingMessages(true);\n    try {\n      const response = await httpClient.getSessionMessages(sessionId);\n      const sorted = (response.messages ?? []).sort(\n        (a, b) =>\n          new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),\n      );\n      setMessages(sorted);\n    } catch (error) {\n      console.error('Failed to load session messages:', error);\n    } finally {\n      setLoadingMessages(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    loadSessions();\n  }, [loadSessions]);\n\n  useEffect(() => {\n    if (selectedSessionId) {\n      loadMessages(selectedSessionId);\n    } else {\n      setMessages([]);\n    }\n  }, [selectedSessionId, loadMessages]);\n\n  useEffect(() => {\n    const container = messagesContainerRef.current;\n    if (container) {\n      const viewport = container.querySelector(\n        '[data-radix-scroll-area-viewport]',\n      );\n      const scrollTarget = viewport || container;\n      scrollTarget.scrollTop = scrollTarget.scrollHeight;\n    }\n  }, [messages]);\n\n  const parseMessageChain = (content: string): MessageChainComponent[] => {\n    try {\n      const parsed = JSON.parse(content);\n      if (Array.isArray(parsed)) {\n        return parsed as MessageChainComponent[];\n      }\n    } catch {\n      // Not JSON, return as plain text\n    }\n    return [{ type: 'Plain', text: content } as Plain];\n  };\n\n  const isUserMessage = (msg: SessionMessage): boolean => {\n    if (msg.role === 'assistant') return false;\n    if (msg.role === 'user') return true;\n    return !msg.runner_name;\n  };\n\n  const renderMessageComponent = (\n    component: MessageChainComponent,\n    index: number,\n  ) => {\n    switch (component.type) {\n      case 'Plain':\n        return <span key={index}>{(component as Plain).text}</span>;\n\n      case 'At': {\n        const atComponent = component as At;\n        const displayName =\n          atComponent.display || atComponent.target?.toString() || '';\n        return (\n          <span\n            key={index}\n            className=\"inline-flex align-middle mx-0.5 px-1.5 py-0.5 bg-blue-200/60 dark:bg-blue-800/60 text-blue-700 dark:text-blue-300 rounded-md text-xs font-medium\"\n          >\n            @{displayName}\n          </span>\n        );\n      }\n\n      case 'AtAll':\n        return (\n          <span\n            key={index}\n            className=\"inline-flex align-middle mx-0.5 px-1.5 py-0.5 bg-blue-200/60 dark:bg-blue-800/60 text-blue-700 dark:text-blue-300 rounded-md text-xs font-medium\"\n          >\n            @All\n          </span>\n        );\n\n      case 'Image': {\n        const img = component as Image;\n        const imageUrl = img.url || (img.base64 ? img.base64 : '');\n        if (!imageUrl) {\n          return (\n            <span\n              key={index}\n              className=\"inline-flex items-center gap-1 text-muted-foreground text-xs\"\n            >\n              [Image]\n            </span>\n          );\n        }\n        return (\n          <div key={index} className=\"my-1.5\">\n            <img\n              src={imageUrl}\n              alt=\"Image\"\n              className=\"max-w-full max-h-52 rounded-lg\"\n            />\n          </div>\n        );\n      }\n\n      case 'Voice': {\n        const voice = component as Voice;\n        const voiceUrl = voice.url || (voice.base64 ? voice.base64 : '');\n        if (!voiceUrl) {\n          return (\n            <span\n              key={index}\n              className=\"inline-flex items-center gap-1 text-muted-foreground text-xs\"\n            >\n              🎙 [Voice]\n            </span>\n          );\n        }\n        return (\n          <div key={index} className=\"my-1\">\n            <audio controls src={voiceUrl} className=\"h-8 max-w-[220px]\" />\n          </div>\n        );\n      }\n\n      case 'Quote': {\n        const quote = component as Quote;\n        return (\n          <div\n            key={index}\n            className=\"mb-2 pl-2.5 border-l-2 border-gray-300 dark:border-gray-600 opacity-80\"\n          >\n            <div className=\"text-sm\">\n              {quote.origin?.map((comp, idx) =>\n                renderMessageComponent(comp as MessageChainComponent, idx),\n              )}\n            </div>\n          </div>\n        );\n      }\n\n      case 'Source':\n        return null;\n\n      case 'File': {\n        const file = component as MessageChainComponent & { name?: string };\n        return (\n          <span key={index} className=\"text-muted-foreground text-xs\">\n            📎 {file.name || 'File'}\n          </span>\n        );\n      }\n\n      default:\n        return (\n          <span key={index} className=\"text-muted-foreground text-xs\">\n            [{component.type}]\n          </span>\n        );\n    }\n  };\n\n  const renderMessageContent = (msg: SessionMessage) => {\n    const chain = parseMessageChain(msg.message_content);\n    return (\n      <div className=\"whitespace-pre-wrap break-words\">\n        {chain.map((component, index) =>\n          renderMessageComponent(component, index),\n        )}\n      </div>\n    );\n  };\n\n  const formatTime = (timestamp: string): string => {\n    if (!timestamp) return '';\n    const date = new Date(timestamp);\n    const hours = date.getHours().toString().padStart(2, '0');\n    const minutes = date.getMinutes().toString().padStart(2, '0');\n    return `${hours}:${minutes}`;\n  };\n\n  const formatRelativeTime = (timestamp: string): string => {\n    if (!timestamp) return '';\n    const date = new Date(timestamp);\n    const now = new Date();\n    const diffMs = now.getTime() - date.getTime();\n    const diffMins = Math.floor(diffMs / 60000);\n    const diffHours = Math.floor(diffMs / 3600000);\n    const diffDays = Math.floor(diffMs / 86400000);\n\n    if (diffMins < 1) return '<1m';\n    if (diffMins < 60) return `${diffMins}m`;\n    if (diffHours < 24) return `${diffHours}h`;\n    return `${diffDays}d`;\n  };\n\n  const selectedSession = sessions.find(\n    (s) => s.session_id === selectedSessionId,\n  );\n\n  return (\n    <div className=\"flex h-full min-h-0\">\n      {/* Left Panel: Session List */}\n      <div className=\"w-64 flex-shrink-0 border-r flex flex-col min-h-0\">\n        {/* Refresh Button */}\n        <div className=\"px-2 py-2 border-b shrink-0\">\n          <Button\n            variant=\"ghost\"\n            className=\"w-full h-9 text-sm text-muted-foreground\"\n            onClick={loadSessions}\n            disabled={loadingSessions}\n          >\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={2}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              className={cn(\n                'w-3.5 h-3.5 mr-1.5',\n                loadingSessions && 'animate-spin',\n              )}\n            >\n              <path d=\"M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2\" />\n            </svg>\n            {t('bots.sessionMonitor.refresh')}\n          </Button>\n        </div>\n\n        {/* Session List */}\n        <ScrollArea className=\"flex-1 min-h-0\">\n          {loadingSessions && sessions.length === 0 ? (\n            <div className=\"flex items-center justify-center py-12 text-sm text-muted-foreground\">\n              {t('bots.sessionMonitor.loading')}\n            </div>\n          ) : sessions.length === 0 ? (\n            <div className=\"text-center text-muted-foreground py-12 text-sm\">\n              {t('bots.sessionMonitor.noSessions')}\n            </div>\n          ) : (\n            <div className=\"p-1\">\n              {sessions.map((session) => {\n                const isSelected = selectedSessionId === session.session_id;\n                return (\n                  <button\n                    key={session.session_id}\n                    className={cn(\n                      'w-full text-left px-3 py-2.5 rounded-md transition-colors',\n                      isSelected ? 'bg-accent' : 'hover:bg-accent/50',\n                    )}\n                    onClick={() => setSelectedSessionId(session.session_id)}\n                  >\n                    <div className=\"flex items-center justify-between mb-0.5\">\n                      <span className=\"text-sm font-medium truncate mr-2\">\n                        {session.user_name ||\n                          session.user_id ||\n                          session.session_id.slice(0, 12)}\n                      </span>\n                      <span className=\"text-[11px] text-muted-foreground tabular-nums flex-shrink-0\">\n                        {formatRelativeTime(session.last_activity)}\n                      </span>\n                    </div>\n                    <div className=\"flex items-center gap-1.5 text-xs text-muted-foreground\">\n                      {parseSessionType(session.session_id) && (\n                        <span className=\"px-1 py-0.5 rounded bg-muted text-[10px]\">\n                          {parseSessionType(session.session_id)}\n                        </span>\n                      )}\n                      {session.platform && (\n                        <span className=\"px-1 py-0.5 rounded bg-muted text-[10px]\">\n                          {session.platform}\n                        </span>\n                      )}\n                      {session.user_id && (\n                        <span className=\"truncate text-[10px]\">\n                          {abbreviateId(session.user_id)}\n                        </span>\n                      )}\n                      {session.is_active && (\n                        <span className=\"flex items-center gap-0.5 text-green-600 dark:text-green-400\">\n                          <span className=\"w-1.5 h-1.5 rounded-full bg-green-500 inline-block\" />\n                        </span>\n                      )}\n                      <span className=\"truncate\">{session.pipeline_name}</span>\n                    </div>\n                  </button>\n                );\n              })}\n            </div>\n          )}\n        </ScrollArea>\n      </div>\n\n      {/* Right Panel: Messages */}\n      <div className=\"flex-1 flex flex-col min-h-0 min-w-0\">\n        {!selectedSessionId ? (\n          <div className=\"text-center text-muted-foreground py-12 text-lg flex-1 flex items-center justify-center\">\n            {t('bots.sessionMonitor.selectSession')}\n          </div>\n        ) : (\n          <>\n            {/* Chat Header */}\n            <div className=\"px-6 py-3 border-b shrink-0 flex items-center justify-between\">\n              <div className=\"min-w-0\">\n                <div className=\"text-sm font-medium truncate\">\n                  {selectedSession?.user_name ||\n                    selectedSession?.user_id ||\n                    selectedSessionId.slice(0, 20)}\n                </div>\n                <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n                  {parseSessionType(selectedSessionId) && (\n                    <span>{parseSessionType(selectedSessionId)}</span>\n                  )}\n                  {selectedSession?.platform && (\n                    <>\n                      {parseSessionType(selectedSessionId) && <span>·</span>}\n                      <span>{selectedSession.platform}</span>\n                    </>\n                  )}\n                  {selectedSession?.user_id && (\n                    <>\n                      <span>·</span>\n                      <span className=\"font-mono\">\n                        {selectedSession.user_id}\n                      </span>\n                      <button\n                        onClick={() => copyUserId(selectedSession.user_id!)}\n                        className=\"inline-flex items-center text-muted-foreground hover:text-foreground transition-colors\"\n                        title={t('common.copy')}\n                      >\n                        {copiedUserId ? (\n                          <Check className=\"w-3 h-3 text-green-600\" />\n                        ) : (\n                          <Copy className=\"w-3 h-3\" />\n                        )}\n                      </button>\n                    </>\n                  )}\n                  {selectedSession?.pipeline_name && (\n                    <>\n                      <span>·</span>\n                      <span>{selectedSession.pipeline_name}</span>\n                    </>\n                  )}\n                  {selectedSession?.is_active && (\n                    <>\n                      <span>·</span>\n                      <span className=\"flex items-center gap-1 text-green-600 dark:text-green-400\">\n                        <span className=\"w-1.5 h-1.5 rounded-full bg-green-500 inline-block\" />\n                        Active\n                      </span>\n                    </>\n                  )}\n                </div>\n              </div>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"w-8 h-8\"\n                onClick={() => loadMessages(selectedSessionId)}\n                disabled={loadingMessages}\n              >\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth={2}\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  className={cn('w-4 h-4', loadingMessages && 'animate-spin')}\n                >\n                  <path d=\"M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2\" />\n                </svg>\n              </Button>\n            </div>\n\n            {/* Messages Area — matches DebugDialog style */}\n            <ScrollArea\n              ref={messagesContainerRef}\n              className=\"flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black\"\n            >\n              <div className=\"space-y-6\">\n                {loadingMessages ? (\n                  <div className=\"text-center text-muted-foreground py-12 text-lg\">\n                    {t('bots.sessionMonitor.loading')}\n                  </div>\n                ) : messages.length === 0 ? (\n                  <div className=\"text-center text-muted-foreground py-12 text-lg\">\n                    {t('bots.sessionMonitor.noMessages')}\n                  </div>\n                ) : (\n                  messages.map((msg) => {\n                    const isUser = isUserMessage(msg);\n                    return (\n                      <div\n                        key={msg.id}\n                        className={cn(\n                          'flex',\n                          isUser ? 'justify-end' : 'justify-start',\n                        )}\n                      >\n                        <div\n                          className={cn(\n                            'max-w-3xl px-5 py-3 rounded-2xl',\n                            isUser\n                              ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none'\n                              : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',\n                            msg.status === 'error' && 'ring-1 ring-red-400/50',\n                          )}\n                        >\n                          {renderMessageContent(msg)}\n                          {/* Role label + timestamp inside bubble, matching DebugDialog */}\n                          <div\n                            className={cn(\n                              'text-xs mt-2 flex items-center gap-2',\n                              isUser\n                                ? 'text-gray-600 dark:text-gray-300'\n                                : 'text-gray-500 dark:text-gray-400',\n                            )}\n                          >\n                            <span>\n                              {isUser\n                                ? t('bots.sessionMonitor.userMessage', {\n                                    defaultValue: 'User',\n                                  })\n                                : t('bots.sessionMonitor.botMessage', {\n                                    defaultValue: 'Assistant',\n                                  })}\n                            </span>\n                            <span className=\"tabular-nums\">\n                              {formatTime(msg.timestamp)}\n                            </span>\n                            {msg.status === 'error' && (\n                              <span className=\"text-red-500\">error</span>\n                            )}\n                            {msg.runner_name && (\n                              <span className=\"opacity-70\">\n                                {msg.runner_name}\n                              </span>\n                            )}\n                          </div>\n                        </div>\n                      </div>\n                    );\n                  })\n                )}\n              </div>\n            </ScrollArea>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/bots/page.tsx",
    "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport styles from './botConfig.module.css';\nimport { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO';\nimport BotCard from '@/app/home/bots/components/bot-card/BotCard';\nimport CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { Bot, Adapter } from '@/app/infra/entities/api';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\nimport BotDetailDialog from '@/app/home/bots/BotDetailDialog';\nimport { CustomApiError } from '@/app/infra/entities/common';\nimport { systemInfo } from '@/app/infra/http';\n\nexport default function BotConfigPage() {\n  const { t } = useTranslation();\n  // 机器人详情dialog\n  const [detailDialogOpen, setDetailDialogOpen] = useState<boolean>(false);\n  const [botList, setBotList] = useState<BotCardVO[]>([]);\n  const [selectedBotId, setSelectedBotId] = useState<string>('');\n\n  useEffect(() => {\n    getBotList();\n  }, []);\n\n  async function getBotList() {\n    const adapterListResp = await httpClient.getAdapters();\n    const adapterList = adapterListResp.adapters.map((adapter: Adapter) => {\n      return {\n        label: extractI18nObject(adapter.label),\n        value: adapter.name,\n      };\n    });\n\n    httpClient\n      .getBots()\n      .then((resp) => {\n        const botList: BotCardVO[] = resp.bots.map((bot: Bot) => {\n          return new BotCardVO({\n            id: bot.uuid || '',\n            iconURL: httpClient.getAdapterIconURL(bot.adapter),\n            name: bot.name,\n            description: bot.description,\n            adapter: bot.adapter,\n            adapterConfig: bot.adapter_config,\n            adapterLabel:\n              adapterList.find((item) => item.value === bot.adapter)?.label ||\n              bot.adapter.substring(0, 10),\n            usePipelineName: bot.use_pipeline_name || '',\n            enable: bot.enable || false,\n          });\n        });\n        setBotList(botList);\n      })\n      .catch((err) => {\n        console.error('get bot list error', err);\n        toast.error(t('bots.getBotListError') + (err as CustomApiError).msg);\n      });\n  }\n\n  function handleCreateBotClick() {\n    const maxBots = systemInfo.limitation?.max_bots ?? -1;\n    if (maxBots >= 0 && botList.length >= maxBots) {\n      toast.error(t('limitation.maxBotsReached', { max: maxBots }));\n      return;\n    }\n    setSelectedBotId('');\n    setDetailDialogOpen(true);\n  }\n\n  function selectBot(botUUID: string) {\n    setSelectedBotId(botUUID);\n    setDetailDialogOpen(true);\n  }\n\n  function handleFormSubmit() {\n    getBotList();\n    // setDetailDialogOpen(false);\n  }\n\n  function handleFormCancel() {\n    setDetailDialogOpen(false);\n  }\n\n  function handleBotDeleted() {\n    getBotList();\n    setDetailDialogOpen(false);\n  }\n\n  function handleNewBotCreated(botId: string) {\n    getBotList();\n    setSelectedBotId(botId);\n  }\n\n  return (\n    <div>\n      <BotDetailDialog\n        open={detailDialogOpen}\n        onOpenChange={setDetailDialogOpen}\n        botId={selectedBotId || undefined}\n        onFormSubmit={handleFormSubmit}\n        onFormCancel={handleFormCancel}\n        onBotDeleted={handleBotDeleted}\n        onNewBotCreated={handleNewBotCreated}\n      />\n\n      {/* 注意：其余的返回内容需要保持在Spin组件外部 */}\n      <div className={`${styles.botListContainer}`}>\n        <CreateCardComponent\n          width={'100%'}\n          height={'10rem'}\n          plusSize={'90px'}\n          onClick={handleCreateBotClick}\n        />\n        {botList.map((cardVO) => {\n          return (\n            <div\n              key={cardVO.id}\n              onClick={() => {\n                selectBot(cardVO.id);\n              }}\n            >\n              <BotCard\n                botCardVO={cardVO}\n                setBotEnableCallback={(id, enable) => {\n                  setBotList(\n                    botList.map((bot) => {\n                      if (bot.id === id) {\n                        return { ...bot, enable: enable };\n                      }\n                      return bot;\n                    }),\n                  );\n                }}\n              />\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport { useState, useEffect } from 'react';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n} from '@/components/ui/item';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { systemInfo } from '@/app/infra/http';\nimport { Loader2, ExternalLink, KeyRound } from 'lucide-react';\nimport PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';\n\ninterface AccountSettingsDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport default function AccountSettingsDialog({\n  open,\n  onOpenChange,\n}: AccountSettingsDialogProps) {\n  const { t } = useTranslation();\n  const [accountType, setAccountType] = useState<'local' | 'space'>('local');\n  const [hasPassword, setHasPassword] = useState(false);\n  const [userEmail, setUserEmail] = useState('');\n  const [loading, setLoading] = useState(true);\n  const [spaceBindLoading, setSpaceBindLoading] = useState(false);\n  const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);\n\n  useEffect(() => {\n    if (open) {\n      loadUserInfo();\n    }\n  }, [open]);\n\n  async function loadUserInfo() {\n    setLoading(true);\n    try {\n      const info = await httpClient.getUserInfo();\n      setAccountType(info.account_type);\n      setHasPassword(info.has_password);\n      setUserEmail(info.user);\n    } catch {\n      toast.error(t('common.error'));\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  const handleBindSpace = async () => {\n    setSpaceBindLoading(true);\n    try {\n      const token = localStorage.getItem('token');\n      if (!token) {\n        toast.error(t('common.error'));\n        setSpaceBindLoading(false);\n        return;\n      }\n      const currentOrigin = window.location.origin;\n      const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;\n      // Pass token as state for security verification\n      const response = await httpClient.getSpaceAuthorizeUrl(\n        redirectUri,\n        token,\n      );\n      window.location.href = response.authorize_url;\n    } catch {\n      toast.error(t('common.spaceLoginFailed'));\n      setSpaceBindLoading(false);\n    }\n  };\n\n  const handlePasswordDialogClose = (dialogOpen: boolean) => {\n    setPasswordDialogOpen(dialogOpen);\n    if (!dialogOpen) {\n      // Reload user info to update password status\n      loadUserInfo();\n    }\n  };\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={onOpenChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('account.settings')}</DialogTitle>\n            <DialogDescription>{userEmail}</DialogDescription>\n          </DialogHeader>\n\n          {loading ? (\n            <div className=\"flex justify-center py-8\">\n              <Loader2 className=\"h-6 w-6 animate-spin\" />\n            </div>\n          ) : (\n            <div className=\"space-y-2\">\n              {/* Password Item */}\n              <Item size=\"sm\" variant=\"muted\" className=\"rounded-lg\">\n                <ItemMedia variant=\"icon\">\n                  <KeyRound className=\"h-4 w-4\" />\n                </ItemMedia>\n                <ItemContent>\n                  <ItemTitle>{t('account.passwordStatus')}</ItemTitle>\n                  <ItemDescription>\n                    {hasPassword\n                      ? t('account.passwordSetDescription')\n                      : t('account.setPasswordHint')}\n                  </ItemDescription>\n                </ItemContent>\n                <ItemActions>\n                  <Button\n                    variant=\"outline\"\n                    size=\"sm\"\n                    onClick={() => setPasswordDialogOpen(true)}\n                    disabled={!systemInfo.allow_modify_login_info}\n                  >\n                    {hasPassword\n                      ? t('common.changePassword')\n                      : t('account.setPassword')}\n                  </Button>\n                </ItemActions>\n              </Item>\n\n              {/* Space Account Item */}\n              <Item size=\"sm\" variant=\"muted\" className=\"rounded-lg\">\n                <ItemMedia variant=\"icon\">\n                  <svg\n                    className=\"h-4 w-4\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <path\n                      d=\"M12 2L2 7L12 12L22 7L12 2Z\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    />\n                    <path\n                      d=\"M2 17L12 22L22 17\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    />\n                    <path\n                      d=\"M2 12L12 17L22 12\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    />\n                  </svg>\n                </ItemMedia>\n                <ItemContent>\n                  <ItemTitle>{t('account.spaceStatus')}</ItemTitle>\n                  <ItemDescription>\n                    {accountType === 'space'\n                      ? t('account.spaceBoundDescription')\n                      : t('account.bindSpaceDescription')}\n                  </ItemDescription>\n                </ItemContent>\n                {accountType === 'local' && (\n                  <ItemActions>\n                    <Button\n                      variant=\"outline\"\n                      size=\"sm\"\n                      onClick={handleBindSpace}\n                      disabled={\n                        spaceBindLoading || !systemInfo.allow_modify_login_info\n                      }\n                    >\n                      {spaceBindLoading ? (\n                        <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                      ) : (\n                        <ExternalLink className=\"mr-2 h-4 w-4\" />\n                      )}\n                      {t('account.bindSpaceButton')}\n                    </Button>\n                  </ItemActions>\n                )}\n              </Item>\n            </div>\n          )}\n        </DialogContent>\n      </Dialog>\n\n      <PasswordChangeDialog\n        open={passwordDialogOpen}\n        onOpenChange={handlePasswordDialogClose}\n        hasPassword={hasPassword}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { toast } from 'sonner';\nimport { Copy, Check, Trash2, Plus } from 'lucide-react';\nimport { useRouter, usePathname, useSearchParams } from 'next/navigation';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n  DialogDescription,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { Switch } from '@/components/ui/switch';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@/components/ui/table';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n} from '@/components/ui/alert-dialog';\nimport * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';\nimport { backendClient } from '@/app/infra/http';\n\ninterface ApiKey {\n  id: number;\n  name: string;\n  key: string;\n  description: string;\n  created_at: string;\n}\n\ninterface Webhook {\n  id: number;\n  name: string;\n  url: string;\n  description: string;\n  enabled: boolean;\n  created_at: string;\n}\n\ninterface ApiIntegrationDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport default function ApiIntegrationDialog({\n  open,\n  onOpenChange,\n}: ApiIntegrationDialogProps) {\n  const { t } = useTranslation();\n  const router = useRouter();\n  const pathname = usePathname();\n  const searchParams = useSearchParams();\n  const [activeTab, setActiveTab] = useState('apikeys');\n  const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);\n  const [webhooks, setWebhooks] = useState<Webhook[]>([]);\n  const [loading, setLoading] = useState(false);\n  const [showCreateDialog, setShowCreateDialog] = useState(false);\n  const [newKeyName, setNewKeyName] = useState('');\n  const [newKeyDescription, setNewKeyDescription] = useState('');\n  const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);\n  const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);\n\n  // Webhook state\n  const [showCreateWebhookDialog, setShowCreateWebhookDialog] = useState(false);\n  const [newWebhookName, setNewWebhookName] = useState('');\n  const [newWebhookUrl, setNewWebhookUrl] = useState('');\n  const [newWebhookDescription, setNewWebhookDescription] = useState('');\n  const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);\n  const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);\n  const [copiedKey, setCopiedKey] = useState<string | null>(null);\n\n  // Sync URL with dialog state\n  useEffect(() => {\n    if (open) {\n      const params = new URLSearchParams(searchParams.toString());\n      params.set('action', 'showApiIntegrationSettings');\n      router.replace(`${pathname}?${params.toString()}`, { scroll: false });\n    }\n  }, [open]);\n\n  const handleOpenChange = (newOpen: boolean) => {\n    if (!newOpen && (deleteKeyId || deleteWebhookId)) {\n      return;\n    }\n    if (!newOpen) {\n      const params = new URLSearchParams(searchParams.toString());\n      params.delete('action');\n      const newUrl = params.toString()\n        ? `${pathname}?${params.toString()}`\n        : pathname;\n      router.replace(newUrl, { scroll: false });\n    }\n    onOpenChange(newOpen);\n  };\n\n  // 清理 body 样式，防止对话框关闭后页面无法交互\n  useEffect(() => {\n    if (!deleteKeyId && !deleteWebhookId) {\n      const cleanup = () => {\n        document.body.style.removeProperty('pointer-events');\n      };\n\n      cleanup();\n      const timer = setTimeout(cleanup, 100);\n      return () => clearTimeout(timer);\n    }\n  }, [deleteKeyId, deleteWebhookId]);\n\n  useEffect(() => {\n    if (open) {\n      loadApiKeys();\n      loadWebhooks();\n    }\n  }, [open]);\n\n  const loadApiKeys = async () => {\n    setLoading(true);\n    try {\n      const response = (await backendClient.get('/api/v1/apikeys')) as {\n        keys: ApiKey[];\n      };\n      setApiKeys(response.keys || []);\n    } catch (error) {\n      toast.error(`Failed to load API keys: ${error}`);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleCreateApiKey = async () => {\n    if (!newKeyName.trim()) {\n      toast.error(t('common.apiKeyNameRequired'));\n      return;\n    }\n\n    try {\n      const response = (await backendClient.post('/api/v1/apikeys', {\n        name: newKeyName,\n        description: newKeyDescription,\n      })) as { key: ApiKey };\n\n      setCreatedKey(response.key);\n      toast.success(t('common.apiKeyCreated'));\n      setNewKeyName('');\n      setNewKeyDescription('');\n      setShowCreateDialog(false);\n      loadApiKeys();\n    } catch (error) {\n      toast.error(`Failed to create API key: ${error}`);\n    }\n  };\n\n  const handleDeleteApiKey = async (keyId: number) => {\n    try {\n      await backendClient.delete(`/api/v1/apikeys/${keyId}`);\n      toast.success(t('common.apiKeyDeleted'));\n      loadApiKeys();\n      setDeleteKeyId(null);\n    } catch (error) {\n      toast.error(`Failed to delete API key: ${error}`);\n    }\n  };\n\n  const handleCopyKey = (key: string) => {\n    navigator.clipboard.writeText(key);\n    setCopiedKey(key);\n    setTimeout(() => setCopiedKey(null), 2000);\n  };\n\n  const maskApiKey = (key: string) => {\n    if (key.length <= 8) return key;\n    return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`;\n  };\n\n  // Webhook methods\n  const loadWebhooks = async () => {\n    setLoading(true);\n    try {\n      const response = (await backendClient.get('/api/v1/webhooks')) as {\n        webhooks: Webhook[];\n      };\n      setWebhooks(response.webhooks || []);\n    } catch (error) {\n      toast.error(`Failed to load webhooks: ${error}`);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleCreateWebhook = async () => {\n    if (!newWebhookName.trim()) {\n      toast.error(t('common.webhookNameRequired'));\n      return;\n    }\n    if (!newWebhookUrl.trim()) {\n      toast.error(t('common.webhookUrlRequired'));\n      return;\n    }\n\n    try {\n      await backendClient.post('/api/v1/webhooks', {\n        name: newWebhookName,\n        url: newWebhookUrl,\n        description: newWebhookDescription,\n        enabled: newWebhookEnabled,\n      });\n\n      toast.success(t('common.webhookCreated'));\n      setNewWebhookName('');\n      setNewWebhookUrl('');\n      setNewWebhookDescription('');\n      setNewWebhookEnabled(true);\n      setShowCreateWebhookDialog(false);\n      loadWebhooks();\n    } catch (error) {\n      toast.error(`Failed to create webhook: ${error}`);\n    }\n  };\n\n  const handleDeleteWebhook = async (webhookId: number) => {\n    try {\n      await backendClient.delete(`/api/v1/webhooks/${webhookId}`);\n      toast.success(t('common.webhookDeleted'));\n      loadWebhooks();\n      setDeleteWebhookId(null);\n    } catch (error) {\n      toast.error(`Failed to delete webhook: ${error}`);\n    }\n  };\n\n  const handleToggleWebhook = async (webhook: Webhook) => {\n    try {\n      await backendClient.put(`/api/v1/webhooks/${webhook.id}`, {\n        enabled: !webhook.enabled,\n      });\n      loadWebhooks();\n    } catch (error) {\n      toast.error(`Failed to update webhook: ${error}`);\n    }\n  };\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"sm:max-w-[800px] h-[26rem] flex flex-col\">\n          <DialogHeader>\n            <DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>\n          </DialogHeader>\n\n          <Tabs\n            value={activeTab}\n            onValueChange={setActiveTab}\n            className=\"w-full flex-1 flex flex-col overflow-hidden\"\n          >\n            <TabsList className=\"shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]\">\n              <TabsTrigger className=\"px-5 py-4 cursor-pointer\" value=\"apikeys\">\n                {t('common.apiKeys')}\n              </TabsTrigger>\n              <TabsTrigger\n                className=\"px-5 py-4 cursor-pointer\"\n                value=\"webhooks\"\n              >\n                {t('common.webhooks')}\n              </TabsTrigger>\n            </TabsList>\n\n            {/* API Keys Tab */}\n            <TabsContent\n              value=\"apikeys\"\n              className=\"space-y-4 flex-1 flex flex-col overflow-hidden\"\n            >\n              <div className=\"flex items-start gap-2 text-sm text-muted-foreground\">\n                {t('common.apiKeyHint')}\n              </div>\n\n              <div className=\"flex justify-end\">\n                <Button\n                  onClick={() => setShowCreateDialog(true)}\n                  size=\"sm\"\n                  className=\"gap-2\"\n                >\n                  <Plus className=\"h-4 w-4\" />\n                  {t('common.createApiKey')}\n                </Button>\n              </div>\n\n              {loading ? (\n                <div className=\"text-center py-8 text-muted-foreground\">\n                  {t('common.loading')}\n                </div>\n              ) : apiKeys.length === 0 ? (\n                <div className=\"text-center py-8 text-muted-foreground\">\n                  {t('common.noApiKeys')}\n                </div>\n              ) : (\n                <div className=\"border rounded-md overflow-auto flex-1\">\n                  <Table>\n                    <TableHeader>\n                      <TableRow>\n                        <TableHead className=\"min-w-[120px]\">\n                          {t('common.name')}\n                        </TableHead>\n                        <TableHead className=\"min-w-[200px]\">\n                          {t('common.apiKeyValue')}\n                        </TableHead>\n                        <TableHead className=\"w-[100px]\">\n                          {t('common.actions')}\n                        </TableHead>\n                      </TableRow>\n                    </TableHeader>\n                    <TableBody>\n                      {apiKeys.map((key) => (\n                        <TableRow key={key.id}>\n                          <TableCell>\n                            <div>\n                              <div className=\"font-medium\">{key.name}</div>\n                              {key.description && (\n                                <div className=\"text-sm text-muted-foreground\">\n                                  {key.description}\n                                </div>\n                              )}\n                            </div>\n                          </TableCell>\n                          <TableCell>\n                            <code className=\"text-sm bg-muted px-2 py-1 rounded\">\n                              {maskApiKey(key.key)}\n                            </code>\n                          </TableCell>\n                          <TableCell>\n                            <div className=\"flex gap-2\">\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => handleCopyKey(key.key)}\n                                title={t('common.copyApiKey')}\n                              >\n                                {copiedKey === key.key ? (\n                                  <Check className=\"h-4 w-4 text-green-600\" />\n                                ) : (\n                                  <Copy className=\"h-4 w-4\" />\n                                )}\n                              </Button>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setDeleteKeyId(key.id)}\n                                title={t('common.delete')}\n                              >\n                                <Trash2 className=\"h-4 w-4\" />\n                              </Button>\n                            </div>\n                          </TableCell>\n                        </TableRow>\n                      ))}\n                    </TableBody>\n                  </Table>\n                </div>\n              )}\n            </TabsContent>\n\n            {/* Webhooks Tab */}\n            <TabsContent\n              value=\"webhooks\"\n              className=\"space-y-4 flex-1 flex flex-col overflow-hidden\"\n            >\n              <div className=\"flex items-start gap-2 text-sm text-muted-foreground\">\n                {t('common.webhookHint')}\n              </div>\n\n              <div className=\"flex justify-end\">\n                <Button\n                  onClick={() => setShowCreateWebhookDialog(true)}\n                  size=\"sm\"\n                  className=\"gap-2\"\n                >\n                  <Plus className=\"h-4 w-4\" />\n                  {t('common.createWebhook')}\n                </Button>\n              </div>\n\n              {loading ? (\n                <div className=\"text-center py-8 text-muted-foreground\">\n                  {t('common.loading')}\n                </div>\n              ) : webhooks.length === 0 ? (\n                <div className=\"text-center py-8 text-muted-foreground\">\n                  {t('common.noWebhooks')}\n                </div>\n              ) : (\n                <div className=\"border rounded-md overflow-auto flex-1 max-w-full\">\n                  <Table className=\"table-fixed w-full\">\n                    <TableHeader>\n                      <TableRow>\n                        <TableHead className=\"w-[150px]\">\n                          {t('common.name')}\n                        </TableHead>\n                        <TableHead className=\"w-[380px]\">\n                          {t('common.webhookUrl')}\n                        </TableHead>\n                        <TableHead className=\"w-[80px]\">\n                          {t('common.webhookEnabled')}\n                        </TableHead>\n                        <TableHead className=\"w-[80px]\">\n                          {t('common.actions')}\n                        </TableHead>\n                      </TableRow>\n                    </TableHeader>\n                    <TableBody>\n                      {webhooks.map((webhook) => (\n                        <TableRow key={webhook.id}>\n                          <TableCell className=\"truncate\">\n                            <div className=\"truncate\">\n                              <div\n                                className=\"font-medium truncate\"\n                                title={webhook.name}\n                              >\n                                {webhook.name}\n                              </div>\n                              {webhook.description && (\n                                <div\n                                  className=\"text-sm text-muted-foreground truncate\"\n                                  title={webhook.description}\n                                >\n                                  {webhook.description}\n                                </div>\n                              )}\n                            </div>\n                          </TableCell>\n                          <TableCell>\n                            <div className=\"overflow-x-auto max-w-[380px]\">\n                              <code className=\"text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block\">\n                                {webhook.url}\n                              </code>\n                            </div>\n                          </TableCell>\n                          <TableCell>\n                            <Switch\n                              checked={webhook.enabled}\n                              onCheckedChange={() =>\n                                handleToggleWebhook(webhook)\n                              }\n                            />\n                          </TableCell>\n                          <TableCell>\n                            <Button\n                              variant=\"ghost\"\n                              size=\"sm\"\n                              onClick={() => setDeleteWebhookId(webhook.id)}\n                              title={t('common.delete')}\n                            >\n                              <Trash2 className=\"h-4 w-4\" />\n                            </Button>\n                          </TableCell>\n                        </TableRow>\n                      ))}\n                    </TableBody>\n                  </Table>\n                </div>\n              )}\n            </TabsContent>\n          </Tabs>\n\n          <DialogFooter>\n            <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n              {t('common.close')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* Create API Key Dialog */}\n      <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('common.createApiKey')}</DialogTitle>\n          </DialogHeader>\n          <div className=\"space-y-4\">\n            <div>\n              <label className=\"text-sm font-medium\">{t('common.name')}</label>\n              <Input\n                value={newKeyName}\n                onChange={(e) => setNewKeyName(e.target.value)}\n                placeholder={t('common.name')}\n                className=\"mt-1\"\n              />\n            </div>\n            <div>\n              <label className=\"text-sm font-medium\">\n                {t('common.description')}\n              </label>\n              <Input\n                value={newKeyDescription}\n                onChange={(e) => setNewKeyDescription(e.target.value)}\n                placeholder={t('common.description')}\n                className=\"mt-1\"\n              />\n            </div>\n          </div>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setShowCreateDialog(false)}\n            >\n              {t('common.cancel')}\n            </Button>\n            <Button onClick={handleCreateApiKey}>{t('common.create')}</Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* Show Created Key Dialog */}\n      <Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>\n            <DialogDescription>\n              {t('common.apiKeyCreatedMessage')}\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"space-y-4\">\n            <div>\n              <label className=\"text-sm font-medium\">\n                {t('common.apiKeyValue')}\n              </label>\n              <div className=\"flex gap-2 mt-1\">\n                <Input value={createdKey?.key || ''} readOnly />\n                <Button\n                  onClick={() => createdKey && handleCopyKey(createdKey.key)}\n                  variant=\"outline\"\n                  size=\"icon\"\n                >\n                  {copiedKey === createdKey?.key ? (\n                    <Check className=\"h-4 w-4 text-green-600\" />\n                  ) : (\n                    <Copy className=\"h-4 w-4\" />\n                  )}\n                </Button>\n              </div>\n            </div>\n          </div>\n          <DialogFooter>\n            <Button onClick={() => setCreatedKey(null)}>\n              {t('common.close')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* Create Webhook Dialog */}\n      <Dialog\n        open={showCreateWebhookDialog}\n        onOpenChange={setShowCreateWebhookDialog}\n      >\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('common.createWebhook')}</DialogTitle>\n          </DialogHeader>\n          <div className=\"space-y-4\">\n            <div>\n              <label className=\"text-sm font-medium\">{t('common.name')}</label>\n              <Input\n                value={newWebhookName}\n                onChange={(e) => setNewWebhookName(e.target.value)}\n                placeholder={t('common.webhookName')}\n                className=\"mt-1\"\n              />\n            </div>\n            <div>\n              <label className=\"text-sm font-medium\">\n                {t('common.webhookUrl')}\n              </label>\n              <Input\n                value={newWebhookUrl}\n                onChange={(e) => setNewWebhookUrl(e.target.value)}\n                placeholder=\"https://example.com/webhook\"\n                className=\"mt-1\"\n              />\n            </div>\n            <div>\n              <label className=\"text-sm font-medium\">\n                {t('common.description')}\n              </label>\n              <Input\n                value={newWebhookDescription}\n                onChange={(e) => setNewWebhookDescription(e.target.value)}\n                placeholder={t('common.description')}\n                className=\"mt-1\"\n              />\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Switch\n                checked={newWebhookEnabled}\n                onCheckedChange={setNewWebhookEnabled}\n              />\n              <label className=\"text-sm font-medium\">\n                {t('common.webhookEnabled')}\n              </label>\n            </div>\n          </div>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setShowCreateWebhookDialog(false)}\n            >\n              {t('common.cancel')}\n            </Button>\n            <Button onClick={handleCreateWebhook}>{t('common.create')}</Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* Delete API Key Confirmation Dialog */}\n      <Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>\n            <DialogDescription>\n              {t('common.apiKeyCreatedMessage')}\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"space-y-4\">\n            <div>\n              <label className=\"text-sm font-medium\">\n                {t('common.apiKeyValue')}\n              </label>\n              <div className=\"flex gap-2 mt-1\">\n                <Input value={createdKey?.key || ''} readOnly />\n                <Button\n                  onClick={() => createdKey && handleCopyKey(createdKey.key)}\n                  variant=\"outline\"\n                  size=\"icon\"\n                >\n                  {copiedKey === createdKey?.key ? (\n                    <Check className=\"h-4 w-4 text-green-600\" />\n                  ) : (\n                    <Copy className=\"h-4 w-4\" />\n                  )}\n                </Button>\n              </div>\n            </div>\n          </div>\n          <DialogFooter>\n            <Button onClick={() => setCreatedKey(null)}>\n              {t('common.close')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* Delete Confirmation Dialog */}\n      <AlertDialog open={!!deleteKeyId}>\n        <AlertDialogPortal>\n          <AlertDialogOverlay\n            className=\"z-[60]\"\n            onClick={() => setDeleteKeyId(null)}\n          />\n          <AlertDialogPrimitive.Content\n            className=\"fixed left-[50%] top-[50%] z-[60] 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 sm:rounded-lg\"\n            onEscapeKeyDown={() => setDeleteKeyId(null)}\n          >\n            <AlertDialogHeader>\n              <AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>\n              <AlertDialogDescription>\n                {t('common.apiKeyDeleteConfirm')}\n              </AlertDialogDescription>\n            </AlertDialogHeader>\n            <AlertDialogFooter>\n              <AlertDialogCancel onClick={() => setDeleteKeyId(null)}>\n                {t('common.cancel')}\n              </AlertDialogCancel>\n              <AlertDialogAction\n                onClick={() => deleteKeyId && handleDeleteApiKey(deleteKeyId)}\n              >\n                {t('common.delete')}\n              </AlertDialogAction>\n            </AlertDialogFooter>\n          </AlertDialogPrimitive.Content>\n        </AlertDialogPortal>\n      </AlertDialog>\n\n      {/* Delete Webhook Confirmation Dialog */}\n      <AlertDialog open={!!deleteWebhookId}>\n        <AlertDialogPortal>\n          <AlertDialogOverlay\n            className=\"z-[60]\"\n            onClick={() => setDeleteWebhookId(null)}\n          />\n          <AlertDialogPrimitive.Content\n            className=\"fixed left-[50%] top-[50%] z-[60] 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 sm:rounded-lg\"\n            onEscapeKeyDown={() => setDeleteWebhookId(null)}\n          >\n            <AlertDialogHeader>\n              <AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>\n              <AlertDialogDescription>\n                {t('common.webhookDeleteConfirm')}\n              </AlertDialogDescription>\n            </AlertDialogHeader>\n            <AlertDialogFooter>\n              <AlertDialogCancel onClick={() => setDeleteWebhookId(null)}>\n                {t('common.cancel')}\n              </AlertDialogCancel>\n              <AlertDialogAction\n                onClick={() =>\n                  deleteWebhookId && handleDeleteWebhook(deleteWebhookId)\n                }\n              >\n                {t('common.delete')}\n              </AlertDialogAction>\n            </AlertDialogFooter>\n          </AlertDialogPrimitive.Content>\n        </AlertDialogPortal>\n      </AlertDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx",
    "content": "import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { z } from 'zod';\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from '@/components/ui/form';\nimport DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';\nimport { useEffect, useRef } from 'react';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\nimport { useTranslation } from 'react-i18next';\n\nexport default function DynamicFormComponent({\n  itemConfigList,\n  onSubmit,\n  initialValues,\n  onFileUploaded,\n  isEditing,\n  externalDependentValues,\n}: {\n  itemConfigList: IDynamicFormItemSchema[];\n  onSubmit?: (val: object) => unknown;\n  initialValues?: Record<string, object>;\n  onFileUploaded?: (fileKey: string) => void;\n  isEditing?: boolean;\n  externalDependentValues?: Record<string, unknown>;\n}) {\n  const isInitialMount = useRef(true);\n  const previousInitialValues = useRef(initialValues);\n  const { t } = useTranslation();\n\n  // Normalize a form value according to its field type.\n  // This ensures legacy/malformed data (e.g. a plain string for\n  // model-fallback-selector) is coerced to the expected shape\n  // so that downstream components never crash.\n  const normalizeFieldValue = (\n    item: IDynamicFormItemSchema,\n    value: unknown,\n  ): unknown => {\n    if (item.type === 'model-fallback-selector') {\n      if (value != null && typeof value === 'object' && !Array.isArray(value)) {\n        const obj = value as Record<string, unknown>;\n        return {\n          primary: typeof obj.primary === 'string' ? obj.primary : '',\n          fallbacks: Array.isArray(obj.fallbacks)\n            ? (obj.fallbacks as unknown[]).filter(\n                (v): v is string => typeof v === 'string',\n              )\n            : [],\n        };\n      }\n      // Legacy string format or any other unexpected type\n      return {\n        primary: typeof value === 'string' ? value : '',\n        fallbacks: [],\n      };\n    }\n    return value;\n  };\n\n  // 根据 itemConfigList 动态生成 zod schema\n  const formSchema = z.object(\n    itemConfigList.reduce(\n      (acc, item) => {\n        let fieldSchema;\n        switch (item.type) {\n          case 'integer':\n            fieldSchema = z.number();\n            break;\n          case 'float':\n            fieldSchema = z.number();\n            break;\n          case 'boolean':\n            fieldSchema = z.boolean();\n            break;\n          case 'string':\n            fieldSchema = z.string();\n            break;\n          case 'array[string]':\n            fieldSchema = z.array(z.string());\n            break;\n          case 'select':\n            fieldSchema = z.string();\n            break;\n          case 'llm-model-selector':\n            fieldSchema = z.string();\n            break;\n          case 'embedding-model-selector':\n            fieldSchema = z.string();\n            break;\n          case 'knowledge-base-selector':\n            fieldSchema = z.string();\n            break;\n          case 'knowledge-base-multi-selector':\n            fieldSchema = z.array(z.string());\n            break;\n          case 'bot-selector':\n            fieldSchema = z.string();\n            break;\n          case 'model-fallback-selector':\n            fieldSchema = z.object({\n              primary: z.string(),\n              fallbacks: z.array(z.string()),\n            });\n            break;\n          case 'prompt-editor':\n            fieldSchema = z.array(\n              z.object({\n                content: z.string(),\n                role: z.string(),\n              }),\n            );\n            break;\n          default:\n            fieldSchema = z.string();\n        }\n\n        if (\n          item.required &&\n          (fieldSchema instanceof z.ZodString ||\n            fieldSchema instanceof z.ZodArray)\n        ) {\n          fieldSchema = fieldSchema.min(1, {\n            message: t('common.fieldRequired'),\n          });\n        }\n\n        return {\n          ...acc,\n          [item.name]: fieldSchema,\n        };\n      },\n      {} as Record<string, z.ZodTypeAny>,\n    ),\n  );\n\n  type FormValues = z.infer<typeof formSchema>;\n\n  const form = useForm<FormValues>({\n    resolver: zodResolver(formSchema),\n    defaultValues: itemConfigList.reduce((acc, item) => {\n      // 优先使用 initialValues，如果没有则使用默认值\n      const rawValue = initialValues?.[item.name] ?? item.default;\n      return {\n        ...acc,\n        [item.name]: normalizeFieldValue(item, rawValue),\n      };\n    }, {} as FormValues),\n  });\n\n  // 当 initialValues 变化时更新表单值\n  // 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单\n  useEffect(() => {\n    // 首次挂载时，使用 initialValues 初始化表单\n    if (isInitialMount.current) {\n      isInitialMount.current = false;\n      previousInitialValues.current = initialValues;\n      return;\n    }\n\n    // 检查 initialValues 是否真的发生了实质性变化\n    // 使用 JSON.stringify 进行深度比较\n    const hasRealChange =\n      JSON.stringify(previousInitialValues.current) !==\n      JSON.stringify(initialValues);\n\n    if (initialValues && hasRealChange) {\n      // 合并默认值和初始值\n      const mergedValues = itemConfigList.reduce(\n        (acc, item) => {\n          const rawValue = initialValues[item.name] ?? item.default;\n          acc[item.name] = normalizeFieldValue(item, rawValue) as object;\n          return acc;\n        },\n        {} as Record<string, object>,\n      );\n\n      Object.entries(mergedValues).forEach(([key, value]) => {\n        form.setValue(key as keyof FormValues, value);\n      });\n\n      previousInitialValues.current = initialValues;\n    }\n  }, [initialValues, form, itemConfigList]);\n\n  // Get reactive form values for conditional rendering\n  const watchedValues = form.watch();\n\n  // Stable ref for onSubmit to avoid re-triggering the effect when the\n  // parent passes a new closure on every render.\n  const onSubmitRef = useRef(onSubmit);\n  onSubmitRef.current = onSubmit;\n\n  // 监听表单值变化\n  useEffect(() => {\n    // Emit initial form values immediately so the parent always has a valid snapshot,\n    // even if the user saves without modifying any field.\n    // form.watch(callback) only fires on subsequent changes, not on mount.\n    const formValues = form.getValues();\n    const initialFinalValues = itemConfigList.reduce(\n      (acc, item) => {\n        acc[item.name] = formValues[item.name] ?? item.default;\n        return acc;\n      },\n      {} as Record<string, object>,\n    );\n    onSubmitRef.current?.(initialFinalValues);\n\n    // Update previousInitialValues to the emitted snapshot so that if the\n    // parent writes these values back as new initialValues, the deep\n    // comparison in the initialValues-sync useEffect won't detect a change\n    // and won't trigger an infinite update loop.\n    previousInitialValues.current = initialFinalValues as Record<\n      string,\n      object\n    >;\n\n    const subscription = form.watch(() => {\n      const formValues = form.getValues();\n      const finalValues = itemConfigList.reduce(\n        (acc, item) => {\n          acc[item.name] = formValues[item.name] ?? item.default;\n          return acc;\n        },\n        {} as Record<string, object>,\n      );\n      onSubmitRef.current?.(finalValues);\n      previousInitialValues.current = finalValues as Record<string, object>;\n    });\n    return () => subscription.unsubscribe();\n  }, [form, itemConfigList]);\n\n  return (\n    <Form {...form}>\n      <div className=\"space-y-4\">\n        {itemConfigList.map((config) => {\n          if (config.show_if) {\n            const dependValue =\n              watchedValues[\n                config.show_if.field as keyof typeof watchedValues\n              ] !== undefined\n                ? watchedValues[\n                    config.show_if.field as keyof typeof watchedValues\n                  ]\n                : externalDependentValues?.[config.show_if.field];\n\n            if (\n              config.show_if.operator === 'eq' &&\n              dependValue !== config.show_if.value\n            ) {\n              return null;\n            }\n            if (\n              config.show_if.operator === 'neq' &&\n              dependValue === config.show_if.value\n            ) {\n              return null;\n            }\n            if (\n              config.show_if.operator === 'in' &&\n              Array.isArray(config.show_if.value) &&\n              !config.show_if.value.includes(dependValue)\n            ) {\n              return null;\n            }\n          }\n\n          // All fields are disabled when editing (creation_settings are immutable)\n          const isFieldDisabled = !!isEditing;\n\n          return (\n            <FormField\n              key={config.id}\n              control={form.control}\n              name={config.name as keyof FormValues}\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>\n                    {extractI18nObject(config.label)}{' '}\n                    {config.required && <span className=\"text-red-500\">*</span>}\n                  </FormLabel>\n                  <FormControl>\n                    <div\n                      className={\n                        isFieldDisabled ? 'pointer-events-none opacity-60' : ''\n                      }\n                    >\n                      <DynamicFormItemComponent\n                        config={config}\n                        field={field}\n                        onFileUploaded={onFileUploaded}\n                      />\n                    </div>\n                  </FormControl>\n                  {config.description && (\n                    <p className=\"text-sm text-muted-foreground\">\n                      {extractI18nObject(config.description)}\n                    </p>\n                  )}\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n          );\n        })}\n      </div>\n    </Form>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx",
    "content": "import {\n  DynamicFormItemType,\n  IDynamicFormItemSchema,\n  IFileConfig,\n} from '@/app/infra/entities/form/dynamic';\nimport { Input } from '@/components/ui/input';\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { Switch } from '@/components/ui/switch';\nimport { ControllerRenderProps } from 'react-hook-form';\nimport { Button } from '@/components/ui/button';\nimport { useEffect, useState } from 'react';\nimport { httpClient, systemInfo, userInfo } from '@/app/infra/http';\nimport {\n  LLMModel,\n  Bot,\n  KnowledgeBase,\n  EmbeddingModel,\n} from '@/app/infra/entities/api';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Card, CardContent } from '@/components/ui/card';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Plus, X, Eye, Wrench } from 'lucide-react';\n\nexport default function DynamicFormItemComponent({\n  config,\n  field,\n  onFileUploaded,\n}: {\n  config: IDynamicFormItemSchema;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  field: ControllerRenderProps<any, any>;\n  onFileUploaded?: (fileKey: string) => void;\n}) {\n  const [llmModels, setLlmModels] = useState<LLMModel[]>([]);\n  const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);\n  const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);\n  const [bots, setBots] = useState<Bot[]>([]);\n  const [uploading, setUploading] = useState<boolean>(false);\n  const [kbDialogOpen, setKbDialogOpen] = useState(false);\n  const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);\n  const { t } = useTranslation();\n\n  const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {\n    const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB\n\n    if (file.size > MAX_FILE_SIZE) {\n      toast.error(t('plugins.fileUpload.tooLarge'));\n      return null;\n    }\n\n    try {\n      setUploading(true);\n      const response = await httpClient.uploadPluginConfigFile(file);\n      toast.success(t('plugins.fileUpload.success'));\n\n      // 通知父组件文件已上传\n      onFileUploaded?.(response.file_key);\n\n      return {\n        file_key: response.file_key,\n        mimetype: file.type,\n      };\n    } catch (error) {\n      toast.error(\n        t('plugins.fileUpload.failed') + ': ' + (error as Error).message,\n      );\n      return null;\n    } finally {\n      setUploading(false);\n    }\n  };\n\n  useEffect(() => {\n    if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {\n      httpClient\n        .getProviderLLMModels()\n        .then((resp) => {\n          let models = resp.models;\n          // Filter out space-chat-completions models when not logged in with space account or when models service is disabled\n          if (\n            systemInfo.disable_models_service ||\n            userInfo?.account_type !== 'space'\n          ) {\n            models = models.filter(\n              (m) => m.provider?.requester !== 'space-chat-completions',\n            );\n          }\n          setLlmModels(models);\n        })\n        .catch((err) => {\n          toast.error(t('models.getModelListError') + err.msg);\n        });\n    }\n  }, [config.type]);\n\n  useEffect(() => {\n    if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) {\n      httpClient\n        .getProviderEmbeddingModels()\n        .then((resp) => {\n          setEmbeddingModels(resp.models);\n        })\n        .catch((err) => {\n          toast.error(t('embedding.getModelListError') + err.msg);\n        });\n    }\n  }, [config.type]);\n\n  useEffect(() => {\n    if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {\n      httpClient\n        .getProviderLLMModels()\n        .then((resp) => {\n          let models = resp.models;\n          if (\n            systemInfo.disable_models_service ||\n            userInfo?.account_type !== 'space'\n          ) {\n            models = models.filter(\n              (m) => m.provider?.requester !== 'space-chat-completions',\n            );\n          }\n          setLlmModels(models);\n        })\n        .catch((err) => {\n          toast.error('Failed to get LLM model list: ' + err.msg);\n        });\n    }\n  }, [config.type]);\n\n  useEffect(() => {\n    if (\n      config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||\n      config.type === DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR\n    ) {\n      httpClient\n        .getKnowledgeBases()\n        .then((resp) => {\n          setKnowledgeBases(resp.bases);\n        })\n        .catch((err) => {\n          toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);\n        });\n    }\n  }, [config.type]);\n\n  useEffect(() => {\n    if (config.type === DynamicFormItemType.BOT_SELECTOR) {\n      httpClient\n        .getBots()\n        .then((resp) => {\n          setBots(resp.bots);\n        })\n        .catch((err) => {\n          toast.error(t('bots.getBotListError') + err.msg);\n        });\n    }\n  }, [config.type]);\n\n  switch (config.type) {\n    case DynamicFormItemType.INT:\n    case DynamicFormItemType.FLOAT:\n      return (\n        <Input\n          type=\"number\"\n          {...field}\n          onChange={(e) => field.onChange(Number(e.target.value))}\n        />\n      );\n\n    case DynamicFormItemType.STRING:\n      return <Input {...field} />;\n\n    case DynamicFormItemType.TEXT:\n      return <Textarea {...field} className=\"min-h-[120px]\" />;\n\n    case DynamicFormItemType.BOOLEAN:\n      return <Switch checked={field.value} onCheckedChange={field.onChange} />;\n\n    case DynamicFormItemType.STRING_ARRAY:\n      return (\n        <div className=\"space-y-2\">\n          {field.value.map((item: string, index: number) => (\n            <div key={index} className=\"flex gap-2 items-center\">\n              <Input\n                className=\"w-[200px]\"\n                value={item}\n                onChange={(e) => {\n                  const newValue = [...field.value];\n                  newValue[index] = e.target.value;\n                  field.onChange(newValue);\n                }}\n              />\n              <button\n                type=\"button\"\n                className=\"p-2 hover:bg-gray-100 rounded\"\n                onClick={() => {\n                  const newValue = field.value.filter(\n                    (_: string, i: number) => i !== index,\n                  );\n                  field.onChange(newValue);\n                }}\n              >\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"currentColor\"\n                  className=\"w-5 h-5 text-red-500\"\n                >\n                  <path d=\"M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z\"></path>\n                </svg>\n              </button>\n            </div>\n          ))}\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            onClick={() => {\n              field.onChange([...field.value, '']);\n            }}\n          >\n            {t('common.add')}\n          </Button>\n        </div>\n      );\n\n    case DynamicFormItemType.SELECT:\n      return (\n        <Select value={field.value} onValueChange={field.onChange}>\n          <SelectTrigger className=\"bg-[#ffffff] dark:bg-[#2a2a2e]\">\n            <SelectValue placeholder={t('common.select')} />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectGroup>\n              {config.options?.map((option) => (\n                <SelectItem key={option.name} value={option.name}>\n                  {extractI18nObject(option.label)}\n                </SelectItem>\n              ))}\n            </SelectGroup>\n          </SelectContent>\n        </Select>\n      );\n\n    case DynamicFormItemType.LLM_MODEL_SELECTOR:\n      // Group models by provider\n      const groupedModels = llmModels.reduce(\n        (acc, model) => {\n          const providerName =\n            model.provider?.name || model.provider?.requester || 'Unknown';\n          if (!acc[providerName]) acc[providerName] = [];\n          acc[providerName].push(model);\n          return acc;\n        },\n        {} as Record<string, LLMModel[]>,\n      );\n\n      return (\n        <Select value={field.value} onValueChange={field.onChange}>\n          <SelectTrigger className=\"bg-[#ffffff] dark:bg-[#2a2a2e]\">\n            <SelectValue placeholder={t('models.selectModel')} />\n          </SelectTrigger>\n          <SelectContent>\n            {Object.entries(groupedModels).map(([providerName, models]) => (\n              <SelectGroup key={providerName}>\n                <SelectLabel>{providerName}</SelectLabel>\n                {models.map((model) => (\n                  <SelectItem key={model.uuid} value={model.uuid}>\n                    <span className=\"inline-flex items-center gap-1\">\n                      {model.name}\n                      {model.abilities?.includes('vision') && (\n                        <Eye className=\"h-3 w-3 text-muted-foreground\" />\n                      )}\n                      {model.abilities?.includes('func_call') && (\n                        <Wrench className=\"h-3 w-3 text-muted-foreground\" />\n                      )}\n                    </span>\n                  </SelectItem>\n                ))}\n              </SelectGroup>\n            ))}\n          </SelectContent>\n        </Select>\n      );\n\n    case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:\n      // Group embedding models by provider\n      const groupedEmbeddingModels = embeddingModels.reduce(\n        (acc, model) => {\n          const providerName = model.provider?.name || 'Unknown';\n          if (!acc[providerName]) acc[providerName] = [];\n          acc[providerName].push(model);\n          return acc;\n        },\n        {} as Record<string, EmbeddingModel[]>,\n      );\n\n      return (\n        <Select value={field.value} onValueChange={field.onChange}>\n          <SelectTrigger className=\"bg-[#ffffff] dark:bg-[#2a2a2e]\">\n            <SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />\n          </SelectTrigger>\n          <SelectContent>\n            {Object.entries(groupedEmbeddingModels).map(\n              ([providerName, models]) => (\n                <SelectGroup key={providerName}>\n                  <SelectLabel>{providerName}</SelectLabel>\n                  {models.map((model) => (\n                    <SelectItem key={model.uuid} value={model.uuid}>\n                      {model.name}\n                    </SelectItem>\n                  ))}\n                </SelectGroup>\n              ),\n            )}\n          </SelectContent>\n        </Select>\n      );\n\n    case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {\n      // Group models by provider\n      const groupedModelsForFallback = llmModels.reduce(\n        (acc, model) => {\n          const providerName =\n            model.provider?.name || model.provider?.requester || 'Unknown';\n          if (!acc[providerName]) acc[providerName] = [];\n          acc[providerName].push(model);\n          return acc;\n        },\n        {} as Record<string, LLMModel[]>,\n      );\n\n      const rawModelValue = field.value;\n      const modelValue: { primary: string; fallbacks: string[] } =\n        rawModelValue != null &&\n        typeof rawModelValue === 'object' &&\n        !Array.isArray(rawModelValue)\n          ? {\n              primary:\n                typeof (rawModelValue as Record<string, unknown>).primary ===\n                'string'\n                  ? ((rawModelValue as Record<string, unknown>)\n                      .primary as string)\n                  : '',\n              fallbacks: Array.isArray(\n                (rawModelValue as Record<string, unknown>).fallbacks,\n              )\n                ? (\n                    (rawModelValue as Record<string, unknown>)\n                      .fallbacks as unknown[]\n                  ).filter((v): v is string => typeof v === 'string')\n                : [],\n            }\n          : {\n              primary: typeof rawModelValue === 'string' ? rawModelValue : '',\n              fallbacks: [],\n            };\n\n      const renderModelSelect = (\n        value: string,\n        onChange: (val: string) => void,\n        placeholder: string,\n      ) => (\n        <Select value={value} onValueChange={onChange}>\n          <SelectTrigger className=\"bg-[#ffffff] dark:bg-[#2a2a2e]\">\n            <SelectValue placeholder={placeholder} />\n          </SelectTrigger>\n          <SelectContent>\n            {Object.entries(groupedModelsForFallback).map(\n              ([providerName, models]) => (\n                <SelectGroup key={providerName}>\n                  <SelectLabel>{providerName}</SelectLabel>\n                  {models.map((model) => (\n                    <SelectItem key={model.uuid} value={model.uuid}>\n                      <span className=\"inline-flex items-center gap-1\">\n                        {model.name}\n                        {model.abilities?.includes('vision') && (\n                          <Eye className=\"h-3 w-3 text-muted-foreground\" />\n                        )}\n                        {model.abilities?.includes('func_call') && (\n                          <Wrench className=\"h-3 w-3 text-muted-foreground\" />\n                        )}\n                      </span>\n                    </SelectItem>\n                  ))}\n                </SelectGroup>\n              ),\n            )}\n          </SelectContent>\n        </Select>\n      );\n\n      const updateValue = (patch: Partial<typeof modelValue>) => {\n        field.onChange({ ...modelValue, ...patch });\n      };\n\n      const addFallbackModel = () => {\n        updateValue({ fallbacks: [...modelValue.fallbacks, ''] });\n      };\n\n      const updateFallbackModel = (index: number, value: string) => {\n        const updated = [...modelValue.fallbacks];\n        updated[index] = value;\n        updateValue({ fallbacks: updated });\n      };\n\n      const removeFallbackModel = (index: number) => {\n        const updated = [...modelValue.fallbacks];\n        updated.splice(index, 1);\n        updateValue({ fallbacks: updated });\n      };\n\n      const moveFallbackModel = (index: number, direction: 'up' | 'down') => {\n        const updated = [...modelValue.fallbacks];\n        const newIndex = direction === 'up' ? index - 1 : index + 1;\n        if (newIndex < 0 || newIndex >= updated.length) return;\n        [updated[index], updated[newIndex]] = [\n          updated[newIndex],\n          updated[index],\n        ];\n        updateValue({ fallbacks: updated });\n      };\n\n      return (\n        <div className=\"space-y-3\">\n          {/* Primary model selector */}\n          <div>\n            <p className=\"text-xs text-muted-foreground mb-1\">\n              {t('models.fallback.primary')}\n            </p>\n            {renderModelSelect(\n              modelValue.primary,\n              (val) => updateValue({ primary: val }),\n              t('models.selectModel'),\n            )}\n          </div>\n\n          {/* Fallback models */}\n          {modelValue.fallbacks.length > 0 && (\n            <div className=\"space-y-2\">\n              <p className=\"text-xs text-muted-foreground\">\n                {t('models.fallback.fallbackList')}\n              </p>\n              {modelValue.fallbacks.map((fbUuid: string, index: number) => (\n                <div key={index} className=\"flex items-center gap-2\">\n                  <span className=\"text-xs text-muted-foreground w-4 shrink-0\">\n                    {index + 1}.\n                  </span>\n                  <div className=\"flex-1\">\n                    {renderModelSelect(\n                      fbUuid,\n                      (val) => updateFallbackModel(index, val),\n                      t('models.selectModel'),\n                    )}\n                  </div>\n                  <div className=\"flex gap-1 shrink-0\">\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      className=\"h-8 w-8 p-0\"\n                      onClick={() => moveFallbackModel(index, 'up')}\n                      disabled={index === 0}\n                    >\n                      ↑\n                    </Button>\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      className=\"h-8 w-8 p-0\"\n                      onClick={() => moveFallbackModel(index, 'down')}\n                      disabled={index === modelValue.fallbacks.length - 1}\n                    >\n                      ↓\n                    </Button>\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      className=\"h-8 w-8 p-0 text-destructive\"\n                      onClick={() => removeFallbackModel(index)}\n                    >\n                      <X className=\"h-4 w-4\" />\n                    </Button>\n                  </div>\n                </div>\n              ))}\n            </div>\n          )}\n\n          {/* Add fallback button */}\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"w-full\"\n            onClick={addFallbackModel}\n          >\n            <Plus className=\"h-4 w-4 mr-1\" />\n            {t('models.fallback.addFallback')}\n          </Button>\n        </div>\n      );\n    }\n\n    case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:\n      // Group KBs by Knowledge Engine name\n      const kbsByEngine = knowledgeBases.reduce(\n        (acc, kb) => {\n          const engineName = kb.knowledge_engine?.name\n            ? extractI18nObject(kb.knowledge_engine.name)\n            : t('knowledge.unknownEngine');\n          if (!acc[engineName]) {\n            acc[engineName] = [];\n          }\n          acc[engineName].push(kb);\n          return acc;\n        },\n        {} as Record<string, typeof knowledgeBases>,\n      );\n\n      return (\n        <Select value={field.value} onValueChange={field.onChange}>\n          <SelectTrigger className=\"bg-[#ffffff] dark:bg-[#2a2a2e]\">\n            <SelectValue placeholder={t('knowledge.selectKnowledgeBase')} />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectGroup>\n              <SelectItem value=\"__none__\">{t('knowledge.empty')}</SelectItem>\n            </SelectGroup>\n\n            {Object.entries(kbsByEngine).map(([engineName, kbs]) => (\n              <SelectGroup key={engineName}>\n                <SelectLabel>{engineName}</SelectLabel>\n                {kbs.map((base) => (\n                  <SelectItem key={base.uuid} value={base.uuid ?? ''}>\n                    {base.name}\n                  </SelectItem>\n                ))}\n              </SelectGroup>\n            ))}\n          </SelectContent>\n        </Select>\n      );\n\n    case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:\n      // Group KBs by Knowledge Engine name for multi-selector\n      const multiKbsByEngine = knowledgeBases.reduce(\n        (acc, kb) => {\n          const engineName = kb.knowledge_engine?.name\n            ? extractI18nObject(kb.knowledge_engine.name)\n            : t('knowledge.unknownEngine');\n          if (!acc[engineName]) {\n            acc[engineName] = [];\n          }\n          acc[engineName].push(kb);\n          return acc;\n        },\n        {} as Record<string, typeof knowledgeBases>,\n      );\n\n      return (\n        <>\n          <div className=\"space-y-2\">\n            {field.value && field.value.length > 0 ? (\n              <div className=\"space-y-2\">\n                {field.value.map((kbId: string) => {\n                  const currentKb = knowledgeBases.find(\n                    (base) => base.uuid === kbId,\n                  );\n                  if (!currentKb) return null;\n\n                  return (\n                    <div\n                      key={kbId}\n                      className=\"flex items-center justify-between rounded-lg border p-3 hover:bg-accent\"\n                    >\n                      <div className=\"flex items-center gap-2 flex-1\">\n                        <div className=\"flex-1 min-w-0\">\n                          <div className=\"font-medium flex items-center gap-2\">\n                            {currentKb.name}\n                            {currentKb.knowledge_engine?.name && (\n                              <span className=\"text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300\">\n                                {extractI18nObject(\n                                  currentKb.knowledge_engine.name,\n                                )}\n                              </span>\n                            )}\n                          </div>\n                          {currentKb.description && (\n                            <div className=\"text-sm text-muted-foreground\">\n                              {currentKb.description}\n                            </div>\n                          )}\n                        </div>\n                      </div>\n                      <Button\n                        type=\"button\"\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        onClick={() => {\n                          const newValue = field.value.filter(\n                            (id: string) => id !== kbId,\n                          );\n                          field.onChange(newValue);\n                        }}\n                      >\n                        <X className=\"h-4 w-4\" />\n                      </Button>\n                    </div>\n                  );\n                })}\n              </div>\n            ) : (\n              <div className=\"flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border\">\n                <p className=\"text-sm text-muted-foreground\">\n                  {t('knowledge.noKnowledgeBaseSelected')}\n                </p>\n              </div>\n            )}\n          </div>\n\n          <Button\n            type=\"button\"\n            onClick={() => {\n              setTempSelectedKBIds(field.value || []);\n              setKbDialogOpen(true);\n            }}\n            variant=\"outline\"\n            className=\"w-full\"\n          >\n            <Plus className=\"mr-2 h-4 w-4\" />\n            {t('knowledge.addKnowledgeBase')}\n          </Button>\n\n          {/* Knowledge Base Selection Dialog */}\n          <Dialog open={kbDialogOpen} onOpenChange={setKbDialogOpen}>\n            <DialogContent className=\"max-w-2xl max-h-[80vh] overflow-hidden flex flex-col\">\n              <DialogHeader>\n                <DialogTitle>{t('knowledge.selectKnowledgeBases')}</DialogTitle>\n              </DialogHeader>\n              <div className=\"flex-1 overflow-y-auto space-y-4 pr-2\">\n                {Object.entries(multiKbsByEngine).map(([engineName, kbs]) => (\n                  <div key={engineName} className=\"space-y-2\">\n                    <div className=\"text-sm font-semibold text-muted-foreground px-2\">\n                      {engineName}\n                    </div>\n                    {kbs.map((base) => {\n                      const isSelected = tempSelectedKBIds.includes(\n                        base.uuid ?? '',\n                      );\n                      return (\n                        <div\n                          key={base.uuid}\n                          className=\"flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer\"\n                          onClick={() => {\n                            const kbId = base.uuid ?? '';\n                            setTempSelectedKBIds((prev) =>\n                              prev.includes(kbId)\n                                ? prev.filter((id) => id !== kbId)\n                                : [...prev, kbId],\n                            );\n                          }}\n                        >\n                          <Checkbox\n                            checked={isSelected}\n                            aria-label={`Select ${base.name}`}\n                          />\n                          <div className=\"flex-1\">\n                            <div className=\"font-medium\">{base.name}</div>\n                            {base.description && (\n                              <div className=\"text-sm text-muted-foreground\">\n                                {base.description}\n                              </div>\n                            )}\n                          </div>\n                        </div>\n                      );\n                    })}\n                  </div>\n                ))}\n              </div>\n              <DialogFooter>\n                <Button\n                  variant=\"outline\"\n                  onClick={() => setKbDialogOpen(false)}\n                >\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  onClick={() => {\n                    field.onChange(tempSelectedKBIds);\n                    setKbDialogOpen(false);\n                  }}\n                >\n                  {t('common.confirm')}\n                </Button>\n              </DialogFooter>\n            </DialogContent>\n          </Dialog>\n        </>\n      );\n\n    case DynamicFormItemType.BOT_SELECTOR:\n      return (\n        <Select value={field.value} onValueChange={field.onChange}>\n          <SelectTrigger className=\"bg-[#ffffff] dark:bg-[#2a2a2e]\">\n            <SelectValue placeholder={t('bots.selectBot')} />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectGroup>\n              {bots.map((bot) => (\n                <SelectItem key={bot.uuid} value={bot.uuid ?? ''}>\n                  {bot.name}\n                </SelectItem>\n              ))}\n            </SelectGroup>\n          </SelectContent>\n        </Select>\n      );\n\n    case DynamicFormItemType.PROMPT_EDITOR:\n      return (\n        <div className=\"space-y-2\">\n          {field.value.map(\n            (item: { role: string; content: string }, index: number) => (\n              <div key={index} className=\"flex gap-2 items-center\">\n                {/* 角色选择 */}\n                {index === 0 ? (\n                  <div className=\"w-[120px] px-3 py-2 border rounded bg-gray-50 dark:bg-[#2a292e] text-gray-500 dark:text-white dark:border-gray-600\">\n                    system\n                  </div>\n                ) : (\n                  <Select\n                    value={item.role}\n                    onValueChange={(value) => {\n                      const newValue = [...field.value];\n                      newValue[index] = { ...newValue[index], role: value };\n                      field.onChange(newValue);\n                    }}\n                  >\n                    <SelectTrigger className=\"w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]\">\n                      <SelectValue />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectGroup>\n                        <SelectItem value=\"user\">user</SelectItem>\n                        <SelectItem value=\"assistant\">assistant</SelectItem>\n                      </SelectGroup>\n                    </SelectContent>\n                  </Select>\n                )}\n                {/* 内容输入 */}\n                <Textarea\n                  className=\"w-[300px]\"\n                  value={item.content}\n                  onChange={(e) => {\n                    const newValue = [...field.value];\n                    newValue[index] = {\n                      ...newValue[index],\n                      content: e.target.value,\n                    };\n                    field.onChange(newValue);\n                  }}\n                />\n                {/* 删除按钮，第一轮不显示 */}\n                {index !== 0 && (\n                  <button\n                    type=\"button\"\n                    className=\"p-2 hover:bg-gray-100 rounded\"\n                    onClick={() => {\n                      const newValue = field.value.filter(\n                        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n                        (_: any, i: number) => i !== index,\n                      );\n                      field.onChange(newValue);\n                    }}\n                  >\n                    <svg\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"currentColor\"\n                      className=\"w-5 h-5 text-red-500\"\n                    >\n                      <path d=\"M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z\"></path>\n                    </svg>\n                  </button>\n                )}\n              </div>\n            ),\n          )}\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            onClick={() => {\n              field.onChange([...field.value, { role: 'user', content: '' }]);\n            }}\n          >\n            {t('common.addRound')}\n          </Button>\n        </div>\n      );\n\n    case DynamicFormItemType.FILE:\n      return (\n        <div className=\"space-y-2\">\n          {field.value && (field.value as IFileConfig).file_key ? (\n            <Card className=\"py-3 max-w-full overflow-hidden bg-gray-900\">\n              <CardContent className=\"flex items-center gap-3 p-0 px-4 min-w-0\">\n                <div className=\"flex-1 min-w-0 overflow-hidden\">\n                  <div\n                    className=\"text-sm font-medium truncate\"\n                    title={(field.value as IFileConfig).file_key}\n                  >\n                    {(field.value as IFileConfig).file_key}\n                  </div>\n                  <div className=\"text-xs text-muted-foreground truncate\">\n                    {(field.value as IFileConfig).mimetype}\n                  </div>\n                </div>\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  className=\"flex-shrink-0 h-8 w-8 p-0\"\n                  onClick={(e) => {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    field.onChange(null);\n                  }}\n                  title={t('common.delete')}\n                >\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"currentColor\"\n                    className=\"w-4 h-4 text-destructive\"\n                  >\n                    <path d=\"M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z\"></path>\n                  </svg>\n                </Button>\n              </CardContent>\n            </Card>\n          ) : (\n            <div className=\"relative\">\n              <input\n                type=\"file\"\n                accept={config.accept}\n                disabled={uploading}\n                onChange={async (e) => {\n                  const file = e.target.files?.[0];\n                  if (file) {\n                    const fileConfig = await handleFileUpload(file);\n                    if (fileConfig) {\n                      field.onChange(fileConfig);\n                    }\n                  }\n                  e.target.value = '';\n                }}\n                className=\"hidden\"\n                id={`file-input-${config.name}`}\n              />\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"sm\"\n                disabled={uploading}\n                onClick={() =>\n                  document.getElementById(`file-input-${config.name}`)?.click()\n                }\n              >\n                <svg\n                  className=\"w-4 h-4 mr-2\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"currentColor\"\n                >\n                  <path d=\"M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z\"></path>\n                </svg>\n                {uploading\n                  ? t('plugins.fileUpload.uploading')\n                  : t('plugins.fileUpload.chooseFile')}\n              </Button>\n            </div>\n          )}\n        </div>\n      );\n\n    case DynamicFormItemType.FILE_ARRAY:\n      return (\n        <div className=\"space-y-2\">\n          {(field.value as IFileConfig[])?.map(\n            (fileConfig: IFileConfig, index: number) => (\n              <Card\n                key={index}\n                className=\"py-3 max-w-full overflow-hidden bg-gray-900\"\n              >\n                <CardContent className=\"flex items-center gap-3 p-0 px-4 min-w-0\">\n                  <div className=\"flex-1 min-w-0 overflow-hidden\">\n                    <div\n                      className=\"text-sm font-medium truncate\"\n                      title={fileConfig.file_key}\n                    >\n                      {fileConfig.file_key}\n                    </div>\n                    <div className=\"text-xs text-muted-foreground truncate\">\n                      {fileConfig.mimetype}\n                    </div>\n                  </div>\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"flex-shrink-0 h-8 w-8 p-0\"\n                    onClick={(e) => {\n                      e.preventDefault();\n                      e.stopPropagation();\n                      const newValue = (field.value as IFileConfig[]).filter(\n                        (_: IFileConfig, i: number) => i !== index,\n                      );\n                      field.onChange(newValue);\n                    }}\n                    title={t('common.delete')}\n                  >\n                    <svg\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"currentColor\"\n                      className=\"w-4 h-4 text-destructive\"\n                    >\n                      <path d=\"M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z\"></path>\n                    </svg>\n                  </Button>\n                </CardContent>\n              </Card>\n            ),\n          )}\n          <div className=\"relative\">\n            <input\n              type=\"file\"\n              accept={config.accept}\n              disabled={uploading}\n              onChange={async (e) => {\n                const file = e.target.files?.[0];\n                if (file) {\n                  const fileConfig = await handleFileUpload(file);\n                  if (fileConfig) {\n                    field.onChange([...(field.value || []), fileConfig]);\n                  }\n                }\n                e.target.value = '';\n              }}\n              className=\"hidden\"\n              id={`file-array-input-${config.name}`}\n            />\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"sm\"\n              disabled={uploading}\n              onClick={() =>\n                document\n                  .getElementById(`file-array-input-${config.name}`)\n                  ?.click()\n              }\n            >\n              <svg\n                className=\"w-4 h-4 mr-2\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                viewBox=\"0 0 24 24\"\n                fill=\"currentColor\"\n              >\n                <path d=\"M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z\"></path>\n              </svg>\n              {uploading\n                ? t('plugins.fileUpload.uploading')\n                : t('plugins.fileUpload.addFile')}\n            </Button>\n          </div>\n        </div>\n      );\n\n    default:\n      return <Input {...field} />;\n  }\n}\n"
  },
  {
    "path": "web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts",
    "content": "import {\n  IDynamicFormItemSchema,\n  DynamicFormItemType,\n  IDynamicFormItemOption,\n  IShowIfCondition,\n} from '@/app/infra/entities/form/dynamic';\nimport { I18nObject } from '@/app/infra/entities/common';\n\nexport class DynamicFormItemConfig implements IDynamicFormItemSchema {\n  id: string;\n  name: string;\n  default: string | number | boolean | Array<unknown>;\n  label: I18nObject;\n  required: boolean;\n  type: DynamicFormItemType;\n  description?: I18nObject;\n  options?: IDynamicFormItemOption[];\n  show_if?: IShowIfCondition;\n\n  constructor(params: IDynamicFormItemSchema) {\n    this.id = params.id;\n    this.name = params.name;\n    this.default = params.default;\n    this.label = params.label;\n    this.required = params.required;\n    this.type = params.type;\n    this.description = params.description;\n    this.options = params.options;\n    this.show_if = params.show_if;\n  }\n}\n\nexport function isDynamicFormItemType(\n  value: string,\n): value is DynamicFormItemType {\n  return Object.values(DynamicFormItemType).includes(\n    value as DynamicFormItemType,\n  );\n}\n\nexport function parseDynamicFormItemType(value: string): DynamicFormItemType {\n  return isDynamicFormItemType(value) ? value : DynamicFormItemType.UNKNOWN;\n}\n\nexport function getDefaultValues(\n  itemConfigList: IDynamicFormItemSchema[],\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n): Record<string, any> {\n  return itemConfigList.reduce(\n    (acc, item) => {\n      acc[item.name] = item.default;\n      return acc;\n    },\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    {} as Record<string, any>,\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { z } from 'zod';\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from '@/components/ui/form';\nimport { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';\nimport DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\n\n/**\n * N8n认证表单组件\n * 根据选择的认证类型动态显示相应的表单项\n */\nexport default function N8nAuthFormComponent({\n  itemConfigList,\n  onSubmit,\n  initialValues,\n}: {\n  itemConfigList: IDynamicFormItemSchema[];\n  onSubmit?: (val: object) => unknown;\n  initialValues?: Record<string, string>;\n}) {\n  // 当前选择的认证类型\n  const [authType, setAuthType] = useState<string>(\n    initialValues?.['auth-type'] || 'none',\n  );\n\n  // 根据 itemConfigList 动态生成 zod schema\n  const formSchema = z.object(\n    itemConfigList.reduce(\n      (acc, item) => {\n        let fieldSchema;\n        switch (item.type) {\n          case 'integer':\n            fieldSchema = z.number();\n            break;\n          case 'float':\n            fieldSchema = z.number();\n            break;\n          case 'boolean':\n            fieldSchema = z.boolean();\n            break;\n          case 'string':\n            fieldSchema = z.string();\n            break;\n          case 'array[string]':\n            fieldSchema = z.array(z.string());\n            break;\n          case 'select':\n            fieldSchema = z.string();\n            break;\n          case 'llm-model-selector':\n            fieldSchema = z.string();\n            break;\n          case 'prompt-editor':\n            fieldSchema = z.array(\n              z.object({\n                content: z.string(),\n                role: z.string(),\n              }),\n            );\n            break;\n          default:\n            fieldSchema = z.string();\n        }\n\n        if (\n          item.required &&\n          (fieldSchema instanceof z.ZodString ||\n            fieldSchema instanceof z.ZodArray)\n        ) {\n          fieldSchema = fieldSchema.min(1, { message: '此字段为必填项' });\n        }\n\n        return {\n          ...acc,\n          [item.name]: fieldSchema,\n        };\n      },\n      {} as Record<string, z.ZodTypeAny>,\n    ),\n  );\n\n  type FormValues = z.infer<typeof formSchema>;\n\n  const form = useForm<FormValues>({\n    resolver: zodResolver(formSchema),\n    defaultValues: itemConfigList.reduce((acc, item) => {\n      // 优先使用 initialValues，如果没有则使用默认值\n      const value = initialValues?.[item.name] ?? item.default;\n      return {\n        ...acc,\n        [item.name]: value,\n      };\n    }, {} as FormValues),\n  });\n\n  // 当 initialValues 变化时更新表单值\n  useEffect(() => {\n    if (initialValues) {\n      // 合并默认值和初始值\n      const mergedValues = itemConfigList.reduce(\n        (acc, item) => {\n          acc[item.name] = initialValues[item.name] ?? item.default;\n          return acc;\n        },\n        {} as Record<string, string>,\n      );\n\n      Object.entries(mergedValues).forEach(([key, value]) => {\n        form.setValue(key as keyof FormValues, value);\n      });\n\n      // 更新认证类型\n      setAuthType((mergedValues['auth-type'] as string) || 'none');\n    }\n  }, [initialValues, form, itemConfigList]);\n\n  // 监听表单值变化\n  useEffect(() => {\n    const subscription = form.watch((value, { name }) => {\n      // 如果认证类型变化，更新状态\n      if (name === 'auth-type') {\n        setAuthType(value['auth-type'] as string);\n      }\n\n      // 获取完整的表单值，确保包含所有默认值\n      const formValues = form.getValues();\n      const finalValues = itemConfigList.reduce(\n        (acc, item) => {\n          acc[item.name] = formValues[item.name] ?? item.default;\n          return acc;\n        },\n        {} as Record<string, string>,\n      );\n\n      onSubmit?.(finalValues);\n    });\n    return () => subscription.unsubscribe();\n  }, [form, onSubmit, itemConfigList]);\n\n  // 根据认证类型过滤表单项\n  const filteredConfigList = itemConfigList.filter((config) => {\n    // 始终显示webhook-url、auth-type、timeout和output-key\n    if (\n      ['webhook-url', 'auth-type', 'timeout', 'output-key'].includes(\n        config.name,\n      )\n    ) {\n      return true;\n    }\n\n    // 根据认证类型显示相应的表单项\n    if (authType === 'basic' && config.name.startsWith('basic-')) {\n      return true;\n    }\n    if (authType === 'jwt' && config.name.startsWith('jwt-')) {\n      return true;\n    }\n    if (authType === 'header' && config.name.startsWith('header-')) {\n      return true;\n    }\n\n    return false;\n  });\n\n  return (\n    <Form {...form}>\n      <div className=\"space-y-4\">\n        {filteredConfigList.map((config) => (\n          <FormField\n            key={config.id}\n            control={form.control}\n            name={config.name as keyof FormValues}\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  {extractI18nObject(config.label)}{' '}\n                  {config.required && <span className=\"text-red-500\">*</span>}\n                </FormLabel>\n                <FormControl>\n                  <DynamicFormItemComponent config={config} field={field} />\n                </FormControl>\n                {config.description && (\n                  <p className=\"text-sm text-muted-foreground\">\n                    {extractI18nObject(config.description)}\n                  </p>\n                )}\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n        ))}\n      </div>\n    </Form>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/home-sidebar/HomeSidebar.module.css",
    "content": ".sidebarContainer {\n  box-sizing: border-box;\n  width: 11rem;\n  height: 100vh;\n  background-color: #eee;\n  display: flex;\n  flex-direction: column;\n  align-items: flex-start;\n  justify-content: space-between;\n  padding-block: 1rem;\n  padding-left: 0.4rem;\n  user-select: none;\n  /* box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1); */\n}\n\n:global(.dark) .sidebarContainer {\n  background-color: #0a0a0b !important;\n}\n\n.langbotIconContainer {\n  width: 200px;\n  height: 70px;\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  justify-content: center;\n  gap: 0.8rem;\n}\n\n.langbotIcon {\n  width: 2.8rem;\n  height: 2.8rem;\n  box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);\n  border-radius: 8px;\n}\n\n:global(.dark) .langbotIcon {\n  box-shadow: 0 0 10px 0 rgba(255, 255, 255, 0.1);\n}\n\n.langbotTextContainer {\n  display: flex;\n  flex-direction: column;\n  align-items: flex-start;\n  justify-content: flex-start;\n  gap: 0.1rem;\n}\n\n.langbotText {\n  font-size: 1.4rem;\n  font-weight: 500;\n  color: #1a1a1a;\n}\n\n:global(.dark) .langbotText {\n  font-size: 1.4rem;\n  font-weight: 500;\n  color: #f0f0f0 !important;\n}\n\n.langbotVersion {\n  font-size: 0.8rem;\n  font-weight: 700;\n  color: #6c6c6c;\n}\n\n:global(.dark) .langbotVersion {\n  font-size: 0.8rem;\n  font-weight: 700;\n  color: #a0a0a0 !important;\n}\n\n.sidebarTopContainer {\n  width: 100%;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 0.8rem;\n}\n\n.sidebarItemsContainer {\n  display: flex;\n  flex-direction: column;\n  gap: 0.8rem;\n}\n\n.sidebarChildContainer {\n  width: 9.8rem;\n  height: 3rem;\n  padding-left: 1.6rem;\n  font-size: 1rem;\n  border-radius: 12px;\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  justify-content: flex-start;\n  cursor: pointer;\n  gap: 0.5rem;\n  transition: all 0.2s ease;\n  /* background-color: aqua; */\n}\n\n.sidebarSelected {\n  background-color: #2288ee;\n  color: white;\n  box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);\n}\n\n:global(.dark) .sidebarSelected {\n  background-color: #2288ee;\n  color: white;\n  box-shadow: 0 0 10px 0 rgba(34, 136, 238, 0.3);\n}\n\n.sidebarUnselected {\n  color: #6c6c6c;\n}\n\n:global(.dark) .sidebarUnselected {\n  color: #a0a0a0 !important;\n}\n\n.sidebarUnselected:hover {\n  background-color: rgba(34, 136, 238, 0.1);\n  color: #2288ee;\n}\n\n:global(.dark) .sidebarUnselected:hover {\n  background-color: rgba(34, 136, 238, 0.2);\n  color: #66baff;\n}\n\n.sidebarChildIcon {\n  width: 20px;\n  height: 20px;\n  background-color: rgba(96, 149, 209, 0);\n}\n\n.sidebarChildName {\n  color: inherit;\n}\n\n.sidebarBottomContainer {\n  width: 100%;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  margin-top: auto;\n  padding-bottom: 1rem;\n}\n\n.sidebarBottomChildContainer {\n  width: 100%;\n  height: 50px;\n  display: flex;\n  flex-direction: row;\n}\n"
  },
  {
    "path": "web/src/app/home/components/home-sidebar/HomeSidebar.tsx",
    "content": "'use client';\n\nimport styles from './HomeSidebar.module.css';\nimport { useEffect, useState } from 'react';\nimport {\n  SidebarChild,\n  SidebarChildVO,\n} from '@/app/home/components/home-sidebar/HomeSidebarChild';\nimport { useRouter, usePathname, useSearchParams } from 'next/navigation';\nimport { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';\nimport langbotIcon from '@/app/assets/langbot-logo.webp';\nimport { systemInfo, httpClient } from '@/app/infra/http/HttpClient';\nimport { getCloudServiceClientSync } from '@/app/infra/http';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Moon,\n  Sun,\n  Monitor,\n  CircleHelp,\n  Lightbulb,\n  LogOut,\n  KeyRound,\n} from 'lucide-react';\nimport { useTheme } from 'next-themes';\n\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { Button } from '@/components/ui/button';\nimport { LanguageSelector } from '@/components/ui/language-selector';\nimport { Badge } from '@/components/ui/badge';\nimport AccountSettingsDialog from '@/app/home/components/account-settings-dialog/AccountSettingsDialog';\nimport ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';\nimport NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';\nimport ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';\nimport { GitHubRelease } from '@/app/infra/http/CloudServiceClient';\n\n// Compare two version strings, returns true if v1 > v2\nfunction compareVersions(v1: string, v2: string): boolean {\n  // Remove 'v' prefix if present\n  const clean1 = v1.replace(/^v/, '');\n  const clean2 = v2.replace(/^v/, '');\n\n  const parts1 = clean1.split('.').map((p) => parseInt(p, 10) || 0);\n  const parts2 = clean2.split('.').map((p) => parseInt(p, 10) || 0);\n\n  const maxLen = Math.max(parts1.length, parts2.length);\n\n  for (let i = 0; i < maxLen; i++) {\n    const p1 = parts1[i] || 0;\n    const p2 = parts2[i] || 0;\n    if (p1 > p2) return true;\n    if (p1 < p2) return false;\n  }\n  return false;\n}\n\n// TODO 侧边导航栏要加动画\nexport default function HomeSidebar({\n  onSelectedChangeAction,\n}: {\n  onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;\n}) {\n  // 路由相关\n  const router = useRouter();\n  const pathname = usePathname();\n  const searchParams = useSearchParams();\n  // 路由被动变化时处理\n  useEffect(() => {\n    handleRouteChange(pathname);\n  }, [pathname]);\n\n  // 检查 URL 参数，自动打开模型对话框\n  useEffect(() => {\n    if (searchParams.get('action') === 'showModelSettings') {\n      setModelsDialogOpen(true);\n    }\n    if (searchParams.get('action') === 'showAccountSettings') {\n      setAccountSettingsOpen(true);\n    }\n    if (searchParams.get('action') === 'showApiIntegrationSettings') {\n      setApiKeyDialogOpen(true);\n    }\n  }, [searchParams]);\n\n  const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();\n  const { theme, setTheme } = useTheme();\n  const { t } = useTranslation();\n  const [popoverOpen, setPopoverOpen] = useState(false);\n  const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);\n  const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);\n  const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false);\n  const [starCount, setStarCount] = useState<number | null>(null);\n  const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(\n    null,\n  );\n  const [hasNewVersion, setHasNewVersion] = useState(false);\n  const [versionDialogOpen, setVersionDialogOpen] = useState(false);\n  const [modelsDialogOpen, setModelsDialogOpen] = useState(false);\n  const [userEmail, setUserEmail] = useState<string>('');\n\n  // 处理模型对话框的打开和关闭，同时更新 URL\n  function handleModelsDialogChange(open: boolean) {\n    setModelsDialogOpen(open);\n    if (open) {\n      const params = new URLSearchParams(searchParams.toString());\n      params.set('action', 'showModelSettings');\n      router.replace(`${pathname}?${params.toString()}`, { scroll: false });\n    } else {\n      const params = new URLSearchParams(searchParams.toString());\n      params.delete('action');\n      const newUrl = params.toString()\n        ? `${pathname}?${params.toString()}`\n        : pathname;\n      router.replace(newUrl, { scroll: false });\n    }\n  }\n\n  // 处理账户设置对话框的打开和关闭，同时更新 URL\n  function handleAccountSettingsChange(open: boolean) {\n    setAccountSettingsOpen(open);\n    if (open) {\n      const params = new URLSearchParams(searchParams.toString());\n      params.set('action', 'showAccountSettings');\n      router.replace(`${pathname}?${params.toString()}`, { scroll: false });\n    } else {\n      const params = new URLSearchParams(searchParams.toString());\n      params.delete('action');\n      const newUrl = params.toString()\n        ? `${pathname}?${params.toString()}`\n        : pathname;\n      router.replace(newUrl, { scroll: false });\n    }\n  }\n\n  useEffect(() => {\n    initSelect();\n    if (!localStorage.getItem('token')) {\n      localStorage.setItem('token', 'test-token');\n      localStorage.setItem('userEmail', 'test@example.com');\n    }\n\n    // Load user email\n    const storedEmail = localStorage.getItem('userEmail');\n    if (storedEmail) {\n      setUserEmail(storedEmail);\n    } else {\n      // Fetch from API if not in localStorage\n      httpClient\n        .getUserInfo()\n        .then((info) => {\n          setUserEmail(info.user);\n          localStorage.setItem('userEmail', info.user);\n        })\n        .catch(() => {});\n    }\n\n    getCloudServiceClientSync()\n      .get('/api/v1/dist/info/repo')\n      .then((response) => {\n        const data = response as { repo: { stargazers_count: number } };\n        setStarCount(data.repo.stargazers_count);\n      })\n      .catch((error) => {\n        console.error('Failed to fetch GitHub star count:', error);\n      });\n\n    // Fetch releases to check for new version\n    getCloudServiceClientSync()\n      .getLangBotReleases()\n      .then((releases) => {\n        if (releases && releases.length > 0) {\n          // Find the latest non-prerelease, non-draft release\n          const latestStable = releases.find((r) => !r.prerelease && !r.draft);\n          const latest = latestStable || releases[0];\n          setLatestRelease(latest);\n\n          // Compare versions\n          const currentVersion = systemInfo?.version;\n          if (currentVersion && latest.tag_name) {\n            const isNewer = compareVersions(latest.tag_name, currentVersion);\n            setHasNewVersion(isNewer);\n          }\n        }\n      })\n      .catch((error) => {\n        console.error('Failed to fetch releases:', error);\n      });\n  }, []);\n\n  function handleChildClick(child: SidebarChildVO) {\n    setSelectedChild(child);\n    handleRoute(child);\n    onSelectedChangeAction(child);\n  }\n\n  function initSelect() {\n    // 根据当前路径选择对应的菜单项\n    const currentPath = pathname;\n    const matchedChild = sidebarConfigList.find(\n      (childConfig) => childConfig.route === currentPath,\n    );\n    if (matchedChild) {\n      handleChildClick(matchedChild);\n    } else {\n      // 如果没有匹配的路径，则默认选择第一个\n      handleChildClick(sidebarConfigList[0]);\n    }\n  }\n\n  function handleRoute(child: SidebarChildVO) {\n    router.push(`${child.route}`);\n  }\n\n  function handleRouteChange(pathname: string) {\n    // TODO 这段逻辑并不好，未来router封装好后改掉\n    // 判断在home下，并且路由更改的是自己的路由子组件则更新UI\n    const routeList = pathname.split('/');\n    if (\n      routeList[1] === 'home' &&\n      sidebarConfigList.find((childConfig) => childConfig.route === pathname)\n    ) {\n      const routeSelectChild = sidebarConfigList.find(\n        (childConfig) => childConfig.route === pathname,\n      );\n      if (routeSelectChild) {\n        setSelectedChild(routeSelectChild);\n        onSelectedChangeAction(routeSelectChild);\n      }\n    }\n  }\n\n  function handleLogout() {\n    localStorage.removeItem('token');\n    localStorage.removeItem('userEmail');\n    window.location.href = '/login';\n  }\n\n  return (\n    <div className={`${styles.sidebarContainer}`}>\n      <div className={`${styles.sidebarTopContainer}`}>\n        {/* LangBot、ICON区域 */}\n        <div className={`${styles.langbotIconContainer}`}>\n          {/* icon */}\n          <img\n            className={`${styles.langbotIcon}`}\n            src={langbotIcon.src}\n            alt=\"langbot-icon\"\n          />\n          {/* 文字 */}\n          <div className={`${styles.langbotTextContainer}`}>\n            <div className={`${styles.langbotText}`}>LangBot</div>\n            <div className=\"flex items-center gap-1.5\">\n              <div className={`${styles.langbotVersion}`}>\n                {systemInfo?.version}\n              </div>\n              {hasNewVersion && (\n                <Badge\n                  onClick={() => setVersionDialogOpen(true)}\n                  className=\"bg-red-500 hover:bg-red-600 text-white text-[0.6rem] px-1.5 py-0 h-4 cursor-pointer\"\n                >\n                  {t('plugins.new')}\n                </Badge>\n              )}\n            </div>\n          </div>\n        </div>\n        {/* 菜单列表，后期可升级成配置驱动 */}\n        <div className={styles.sidebarItemsContainer}>\n          {sidebarConfigList.map((config) => {\n            return (\n              <div\n                key={config.id}\n                onClick={() => {\n                  handleChildClick(config);\n                }}\n              >\n                <SidebarChild\n                  onClick={() => {}}\n                  isSelected={\n                    selectedChild !== undefined &&\n                    selectedChild.id === config.id\n                  }\n                  icon={config.icon}\n                  name={config.name}\n                />\n              </div>\n            );\n          })}\n        </div>\n      </div>\n\n      <div className={`${styles.sidebarBottomContainer}`}>\n        {starCount !== null && (\n          <div\n            onClick={() => {\n              window.open('https://github.com/langbot-app/LangBot', '_blank');\n            }}\n            className=\"flex justify-center cursor-pointer p-2 rounded-lg transition-colors\"\n          >\n            <Badge\n              variant=\"outline\"\n              className=\"hover:bg-secondary/50 px-3 py-1.5 text-sm font-medium transition-colors border-border relative overflow-hidden group\"\n            >\n              <svg\n                className=\"w-4 h-4 mr-2\"\n                viewBox=\"0 0 24 24\"\n                fill=\"currentColor\"\n              >\n                <path d=\"M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z\" />\n              </svg>\n              <div className=\"absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent group-hover:translate-x-full transition-transform duration-1000 ease-out\"></div>\n              {starCount.toLocaleString()}\n            </Badge>\n          </div>\n        )}\n\n        <SidebarChild\n          onClick={() => handleModelsDialogChange(true)}\n          isSelected={false}\n          icon={\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM4.53956 9.82234C6.8254 10.837 8.68402 12.5048 9.74238 14.7996 10.8008 12.5048 12.6594 10.837 14.9452 9.82234 12.6321 8.79557 10.7676 7.04647 9.74239 4.71088 8.71719 7.04648 6.85267 8.79557 4.53956 9.82234ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899ZM18.3745 19.0469 18.937 18.4883 19.4878 19.0469 18.937 19.5898 18.3745 19.0469Z\"></path>\n            </svg>\n          }\n          name={t('models.title')}\n        />\n\n        <Popover\n          open={popoverOpen}\n          onOpenChange={(open) => {\n            // 防止语言选择器打开时关闭popover\n            if (!open && languageSelectorOpen) return;\n            setPopoverOpen(open);\n          }}\n        >\n          <PopoverTrigger>\n            <SidebarChild\n              onClick={() => {}}\n              isSelected={false}\n              icon={\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"currentColor\"\n                >\n                  <path d=\"M12 3C10.9 3 10 3.9 10 5C10 6.1 10.9 7 12 7C13.1 7 14 6.1 14 5C14 3.9 13.1 3 12 3ZM12 17C10.9 17 10 17.9 10 19C10 20.1 10.9 21 12 21C13.1 21 14 20.1 14 19C14 17.9 13.1 17 12 17ZM12 10C10.9 10 10 10.9 10 12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12C14 10.9 13.1 10 12 10Z\"></path>\n                </svg>\n              }\n              name={t('common.accountOptions')}\n            />\n          </PopoverTrigger>\n          <PopoverContent\n            side=\"right\"\n            align=\"end\"\n            className=\"w-auto p-2 flex flex-col gap-2\"\n          >\n            <div\n              className=\"flex items-center gap-3 p-2 rounded-lg hover:bg-accent cursor-pointer\"\n              onClick={() => {\n                handleAccountSettingsChange(true);\n                setPopoverOpen(false);\n              }}\n            >\n              <div className=\"w-10 h-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium\">\n                {userEmail ? userEmail.charAt(0).toUpperCase() : 'U'}\n              </div>\n              <span className=\"text-sm truncate max-w-[180px]\">\n                {userEmail || t('account.settings')}\n              </span>\n            </div>\n\n            <div className=\"flex items-center gap-2\">\n              <LanguageSelector\n                triggerClassName=\"flex-1\"\n                onOpenChange={setLanguageSelectorOpen}\n              />\n              <Button\n                variant=\"outline\"\n                size=\"icon\"\n                onClick={() =>\n                  setTheme(\n                    theme === 'light'\n                      ? 'dark'\n                      : theme === 'dark'\n                        ? 'system'\n                        : 'light',\n                  )\n                }\n                className=\"h-9 w-9 shrink-0\"\n              >\n                {theme === 'light' && <Sun className=\"h-[1.2rem] w-[1.2rem]\" />}\n                {theme === 'dark' && <Moon className=\"h-[1.2rem] w-[1.2rem]\" />}\n                {theme === 'system' && (\n                  <Monitor className=\"h-[1.2rem] w-[1.2rem]\" />\n                )}\n              </Button>\n            </div>\n\n            <div className=\"flex flex-col gap-1\">\n              <Button\n                variant=\"ghost\"\n                className=\"w-full justify-start font-normal\"\n                onClick={() => {\n                  setApiKeyDialogOpen(true);\n                  setPopoverOpen(false);\n                }}\n              >\n                <KeyRound className=\"w-4 h-4 mr-2\" />\n                {t('common.apiIntegration')}\n              </Button>\n              <Button\n                variant=\"ghost\"\n                className=\"w-full justify-start font-normal\"\n                onClick={() => {\n                  const language = localStorage.getItem('langbot_language');\n                  if (language === 'zh-Hans' || language === 'zh-Hant') {\n                    window.open(\n                      'https://docs.langbot.app/zh/insight/guide',\n                      '_blank',\n                    );\n                  } else {\n                    window.open(\n                      'https://docs.langbot.app/en/insight/guide',\n                      '_blank',\n                    );\n                  }\n                  setPopoverOpen(false);\n                }}\n              >\n                <CircleHelp className=\"w-4 h-4 mr-2\" />\n                {t('common.helpDocs')}\n              </Button>\n              <Button\n                variant=\"ghost\"\n                className=\"w-full justify-start font-normal\"\n                onClick={() => {\n                  window.open(\n                    'https://github.com/langbot-app/LangBot/issues',\n                    '_blank',\n                  );\n                  setPopoverOpen(false);\n                }}\n              >\n                <Lightbulb className=\"w-4 h-4 mr-2\" />\n                {t('common.featureRequest')}\n              </Button>\n              <Button\n                variant=\"ghost\"\n                className=\"w-full justify-start font-normal\"\n                onClick={() => handleLogout()}\n              >\n                <LogOut className=\"w-4 h-4 mr-2\" />\n                {t('common.logout')}\n              </Button>\n            </div>\n          </PopoverContent>\n        </Popover>\n      </div>\n      <AccountSettingsDialog\n        open={accountSettingsOpen}\n        onOpenChange={handleAccountSettingsChange}\n      />\n      <ApiIntegrationDialog\n        open={apiKeyDialogOpen}\n        onOpenChange={setApiKeyDialogOpen}\n      />\n      <NewVersionDialog\n        open={versionDialogOpen}\n        onOpenChange={setVersionDialogOpen}\n        release={latestRelease}\n      />\n      <ModelsDialog\n        open={modelsDialogOpen}\n        onOpenChange={handleModelsDialogChange}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/home-sidebar/HomeSidebarChild.tsx",
    "content": "import styles from './HomeSidebar.module.css';\nimport { I18nObject } from '@/app/infra/entities/common';\n\nexport interface ISidebarChildVO {\n  id: string;\n  icon: React.ReactNode;\n  name: string;\n  route: string;\n  description: string;\n  helpLink: I18nObject;\n}\n\nexport class SidebarChildVO {\n  id: string;\n  icon: React.ReactNode;\n  name: string;\n  route: string;\n  description: string;\n  helpLink: I18nObject;\n\n  constructor(props: ISidebarChildVO) {\n    this.id = props.id;\n    this.icon = props.icon;\n    this.name = props.name;\n    this.route = props.route;\n    this.description = props.description;\n    this.helpLink = props.helpLink;\n  }\n}\n\nexport function SidebarChild({\n  icon,\n  name,\n  isSelected,\n  onClick,\n}: {\n  icon: React.ReactNode;\n  name: string;\n  isSelected: boolean;\n  onClick: () => void;\n}) {\n  return (\n    <div\n      className={`${styles.sidebarChildContainer} ${\n        isSelected ? styles.sidebarSelected : styles.sidebarUnselected\n      }`}\n      onClick={onClick}\n    >\n      <div className={`${styles.sidebarChildIcon}`}>{icon}</div>\n      <span className={`${styles.sidebarChildName}`}>{name}</span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/home-sidebar/sidbarConfigList.tsx",
    "content": "import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';\nimport styles from './HomeSidebar.module.css';\nimport i18n from '@/i18n';\n\nconst t = (key: string) => {\n  return i18n.t(key);\n};\n\nexport const sidebarConfigList = [\n  new SidebarChildVO({\n    id: 'bots',\n    name: t('bots.title'),\n    icon: (\n      <svg\n        className={`${styles.sidebarChildIcon}`}\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 24 24\"\n        fill=\"currentColor\"\n      >\n        <path d=\"M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z\"></path>\n      </svg>\n    ),\n    route: '/home/bots',\n    description: t('bots.description'),\n    helpLink: {\n      en_US: 'https://docs.langbot.app/en/usage/platforms/readme',\n      zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme',\n      ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme',\n    },\n  }),\n  new SidebarChildVO({\n    id: 'pipelines',\n    name: t('pipelines.title'),\n    icon: (\n      <svg\n        className={`${styles.sidebarChildIcon}`}\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 24 24\"\n        fill=\"currentColor\"\n      >\n        <path d=\"M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z\"></path>\n      </svg>\n    ),\n    route: '/home/pipelines',\n    description: t('pipelines.description'),\n    helpLink: {\n      en_US: 'https://docs.langbot.app/en/usage/pipelines/readme',\n      zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme',\n      ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme',\n    },\n  }),\n  new SidebarChildVO({\n    id: 'monitoring',\n    name: t('monitoring.title'),\n    icon: (\n      <svg\n        className={`${styles.sidebarChildIcon}`}\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 24 24\"\n        fill=\"currentColor\"\n      >\n        <path d=\"M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z\"></path>\n      </svg>\n    ),\n    route: '/home/monitoring',\n    description: t('monitoring.description'),\n    helpLink: {\n      en_US: '',\n      zh_Hans: '',\n    },\n  }),\n  new SidebarChildVO({\n    id: 'knowledge',\n    name: t('knowledge.title'),\n    icon: (\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 24 24\"\n        fill=\"currentColor\"\n      >\n        <path d=\"M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z\"></path>\n      </svg>\n    ),\n    route: '/home/knowledge',\n    description: t('knowledge.description'),\n    helpLink: {\n      en_US: 'https://docs.langbot.app/en/usage/knowledge/readme',\n      zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme',\n      ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme',\n    },\n  }),\n  new SidebarChildVO({\n    id: 'plugins',\n    name: t('plugins.title'),\n    icon: (\n      <svg\n        className={`${styles.sidebarChildIcon}`}\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 24 24\"\n        fill=\"currentColor\"\n      >\n        <path d=\"M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z\"></path>\n      </svg>\n    ),\n    route: '/home/plugins',\n    description: t('plugins.description'),\n    helpLink: {\n      en_US: 'https://docs.langbot.app/en/usage/plugin/plugin-intro',\n      zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro',\n      ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro',\n    },\n  }),\n];\n"
  },
  {
    "path": "web/src/app/home/components/home-titlebar/HomeTitleBar.tsx",
    "content": "import { extractI18nObject } from '@/i18n/I18nProvider';\nimport styles from './HomeTittleBar.module.css';\nimport { I18nObject } from '@/app/infra/entities/common';\n\nexport default function HomeTitleBar({\n  title,\n  subtitle,\n  helpLink,\n}: {\n  title: string;\n  subtitle: string;\n  helpLink: I18nObject;\n}) {\n  return (\n    <div className={`${styles.titleBarContainer}`}>\n      <div className={`${styles.titleText}`}>{title}</div>\n      <div className={`${styles.subtitleText}`}>\n        {subtitle}\n        <span className={`${styles.helpLink}`}>\n          <div\n            onClick={() => {\n              window.open(extractI18nObject(helpLink), '_blank');\n            }}\n            className=\"cursor-pointer\"\n          >\n            <svg\n              className=\"w-[1rem] h-[1rem]\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z\"></path>\n            </svg>\n          </div>\n        </span>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/home-titlebar/HomeTittleBar.module.css",
    "content": ".titleBarContainer {\n  width: 100%;\n  padding-top: 1rem;\n  height: 4rem;\n  opacity: 1;\n  font-size: 20px;\n  display: flex;\n  flex-direction: column;\n  align-items: flex-start;\n  justify-content: center;\n}\n\n.titleText {\n  margin-left: 3.2rem;\n  font-size: 1.4rem;\n  font-weight: 500;\n  color: #585858;\n}\n\n:global(.dark) .titleText {\n  color: #e0e0e0;\n}\n\n.subtitleText {\n  margin-left: 3.2rem;\n  font-size: 0.8rem;\n  color: #808080;\n  display: flex;\n  align-items: center;\n}\n\n:global(.dark) .subtitleText {\n  color: #b0b0b0;\n}\n\n.helpLink {\n  margin-left: 0.2rem;\n  font-size: 0.8rem;\n  color: #8b8b8b;\n}\n\n:global(.dark) .helpLink {\n  color: #a0a0a0;\n}\n"
  },
  {
    "path": "web/src/app/home/components/models-dialog/ModelsDialog.tsx",
    "content": "'use client';\n\nimport { useState, useEffect } from 'react';\nimport { Plus, Boxes } from 'lucide-react';\nimport { httpClient, systemInfo } from '@/app/infra/http/HttpClient';\nimport { ModelProvider } from '@/app/infra/entities/api';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport ProviderForm from './component/provider-form/ProviderForm';\nimport { ProviderCard } from './components';\nimport {\n  ExtraArg,\n  ModelType,\n  TestResult,\n  ProviderModels,\n  LANGBOT_MODELS_PROVIDER_REQUESTER,\n} from './types';\nimport { CustomApiError } from '@/app/infra/entities/common';\n\ninterface ModelsDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nfunction convertExtraArgsToObject(\n  args: ExtraArg[],\n): Record<string, string | number | boolean> {\n  const obj: Record<string, string | number | boolean> = {};\n  args.forEach((arg) => {\n    if (arg.key.trim()) {\n      if (arg.type === 'number') obj[arg.key] = Number(arg.value);\n      else if (arg.type === 'boolean') obj[arg.key] = arg.value === 'true';\n      else obj[arg.key] = arg.value;\n    }\n  });\n  return obj;\n}\n\nexport default function ModelsDialog({\n  open,\n  onOpenChange,\n}: ModelsDialogProps) {\n  const { t } = useTranslation();\n\n  const [providers, setProviders] = useState<ModelProvider[]>([]);\n  const [accountType, setAccountType] = useState<'local' | 'space'>('local');\n  const [spaceCredits, setSpaceCredits] = useState<number | null>(null);\n\n  // Expanded providers and their models\n  const [expandedProviders, setExpandedProviders] = useState<Set<string>>(\n    new Set(),\n  );\n  const [providerModels, setProviderModels] = useState<\n    Record<string, ProviderModels>\n  >({});\n  const [loadingProviders, setLoadingProviders] = useState<Set<string>>(\n    new Set(),\n  );\n\n  // Provider form modal\n  const [providerFormOpen, setProviderFormOpen] = useState(false);\n  const [editingProviderId, setEditingProviderId] = useState<string | null>(\n    null,\n  );\n\n  // Popover states\n  const [addModelPopoverOpen, setAddModelPopoverOpen] = useState<string | null>(\n    null,\n  );\n  const [editModelPopoverOpen, setEditModelPopoverOpen] = useState<\n    string | null\n  >(null);\n  const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(\n    null,\n  );\n\n  // Form states\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [isTesting, setIsTesting] = useState(false);\n  const [testResult, setTestResult] = useState<TestResult | null>(null);\n\n  // Track if providers have been loaded initially\n  const [providersLoaded, setProvidersLoaded] = useState(false);\n\n  // Separate LangBot Models provider (hide when models service is disabled)\n  const langbotProvider = systemInfo.disable_models_service\n    ? undefined\n    : providers.find((p) => p.requester === LANGBOT_MODELS_PROVIDER_REQUESTER);\n  const otherProviders = providers.filter(\n    (p) => p.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,\n  );\n\n  useEffect(() => {\n    if (open) {\n      loadUserInfo();\n      loadProviders();\n    }\n  }, [open]);\n\n  // Auto-expand LangBot Models when no external providers exist\n  useEffect(() => {\n    if (providersLoaded && langbotProvider && otherProviders.length === 0) {\n      if (!expandedProviders.has(langbotProvider.uuid)) {\n        setExpandedProviders(new Set([langbotProvider.uuid]));\n        if (!providerModels[langbotProvider.uuid]) {\n          loadProviderModels(langbotProvider.uuid);\n        }\n      }\n    }\n  }, [providersLoaded, providers]);\n\n  async function loadUserInfo() {\n    try {\n      const userInfo = await httpClient.getUserInfo();\n      setAccountType(userInfo.account_type);\n      if (userInfo.account_type === 'space') {\n        const creditsInfo = await httpClient.getSpaceCredits();\n        setSpaceCredits(creditsInfo.credits);\n      }\n    } catch {\n      setAccountType('local');\n    }\n  }\n\n  async function loadProviders() {\n    try {\n      const resp = await httpClient.getModelProviders();\n      setProviders(resp.providers);\n      setProvidersLoaded(true);\n    } catch (err) {\n      console.error('Failed to load providers', err);\n      toast.error(t('models.loadError'));\n    }\n  }\n\n  async function loadProviderModels(providerUuid: string, silent = false) {\n    if (loadingProviders.has(providerUuid)) return;\n\n    if (!silent) {\n      setLoadingProviders((prev) => new Set(prev).add(providerUuid));\n    }\n    try {\n      const [llmResp, embeddingResp] = await Promise.all([\n        httpClient.getProviderLLMModels(providerUuid),\n        httpClient.getProviderEmbeddingModels(providerUuid),\n      ]);\n      setProviderModels((prev) => ({\n        ...prev,\n        [providerUuid]: {\n          llm: llmResp.models,\n          embedding: embeddingResp.models,\n        },\n      }));\n    } catch (err) {\n      console.error('Failed to load models', err);\n    } finally {\n      if (!silent) {\n        setLoadingProviders((prev) => {\n          const next = new Set(prev);\n          next.delete(providerUuid);\n          return next;\n        });\n      }\n    }\n  }\n\n  function toggleProvider(providerUuid: string) {\n    setExpandedProviders((prev) => {\n      const next = new Set(prev);\n      if (next.has(providerUuid)) {\n        next.delete(providerUuid);\n      } else {\n        next.add(providerUuid);\n        if (!providerModels[providerUuid]) {\n          loadProviderModels(providerUuid);\n        }\n      }\n      return next;\n    });\n  }\n\n  function handleCreateProvider() {\n    setEditingProviderId(null);\n    setProviderFormOpen(true);\n  }\n\n  function handleEditProvider(providerId: string) {\n    setEditingProviderId(providerId);\n    setProviderFormOpen(true);\n  }\n\n  async function handleDeleteProvider(providerId: string) {\n    try {\n      await httpClient.deleteModelProvider(providerId);\n      toast.success(t('models.providerDeleted'));\n      loadProviders();\n    } catch (err) {\n      toast.error(t('models.providerDeleteError') + (err as Error).message);\n    }\n  }\n\n  async function handleSpaceLogin() {\n    try {\n      const token = localStorage.getItem('token');\n      if (!token) {\n        toast.error(t('common.error'));\n        return;\n      }\n      const currentOrigin = window.location.origin;\n      const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;\n      const response = await httpClient.getSpaceAuthorizeUrl(\n        redirectUri,\n        token,\n      );\n      window.location.href = response.authorize_url;\n    } catch {\n      toast.error(t('common.spaceLoginFailed'));\n    }\n  }\n\n  async function handleAddModel(\n    providerUuid: string,\n    modelType: ModelType,\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) {\n    if (!name.trim()) {\n      toast.error(t('models.modelNameRequired'));\n      return;\n    }\n    setIsSubmitting(true);\n    try {\n      const extraArgsObj = convertExtraArgsToObject(extraArgs);\n\n      if (modelType === 'llm') {\n        await httpClient.createProviderLLMModel({\n          name,\n          provider_uuid: providerUuid,\n          abilities,\n          extra_args: extraArgsObj,\n        } as never);\n      } else {\n        await httpClient.createProviderEmbeddingModel({\n          name,\n          provider_uuid: providerUuid,\n          extra_args: extraArgsObj,\n        } as never);\n      }\n      setAddModelPopoverOpen(null);\n      loadProviderModels(providerUuid, true);\n      loadProviders();\n    } catch (err) {\n      toast.error(t('models.createError') + (err as Error).message);\n    } finally {\n      setIsSubmitting(false);\n    }\n  }\n\n  async function handleUpdateModel(\n    providerUuid: string,\n    modelId: string,\n    modelType: ModelType,\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) {\n    if (!name.trim()) {\n      toast.error(t('models.modelNameRequired'));\n      return;\n    }\n    setIsSubmitting(true);\n    try {\n      const extraArgsObj = convertExtraArgsToObject(extraArgs);\n\n      if (modelType === 'llm') {\n        await httpClient.updateProviderLLMModel(modelId, {\n          name,\n          provider_uuid: providerUuid,\n          abilities,\n          extra_args: extraArgsObj,\n        } as never);\n      } else {\n        await httpClient.updateProviderEmbeddingModel(modelId, {\n          name,\n          provider_uuid: providerUuid,\n          extra_args: extraArgsObj,\n        } as never);\n      }\n      setEditModelPopoverOpen(null);\n      loadProviderModels(providerUuid, true);\n      loadProviders();\n    } catch (err) {\n      toast.error(t('models.saveError') + (err as Error).message);\n    } finally {\n      setIsSubmitting(false);\n    }\n  }\n\n  async function handleDeleteModel(\n    providerUuid: string,\n    modelId: string,\n    modelType: ModelType,\n  ) {\n    try {\n      if (modelType === 'llm') {\n        await httpClient.deleteProviderLLMModel(modelId);\n      } else {\n        await httpClient.deleteProviderEmbeddingModel(modelId);\n      }\n      toast.success(t('models.deleteSuccess'));\n      loadProviderModels(providerUuid, true);\n      loadProviders();\n    } catch (err) {\n      toast.error(t('models.deleteError') + (err as Error).message);\n    }\n  }\n\n  async function handleTestModel(\n    providerUuid: string,\n    name: string,\n    modelType: ModelType,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) {\n    setIsTesting(true);\n    setTestResult(null);\n    const startTime = Date.now();\n    try {\n      const extraArgsObj = convertExtraArgsToObject(extraArgs);\n\n      // Get the provider info\n      const provider = providers.find((p) => p.uuid === providerUuid);\n      const providerData = {\n        requester: provider?.requester || '',\n        base_url: provider?.base_url || '',\n        api_keys: provider?.api_keys || [],\n      };\n\n      if (modelType === 'llm') {\n        await httpClient.testLLMModel('_', {\n          uuid: '',\n          name,\n          provider_uuid: '',\n          provider: providerData,\n          abilities,\n          extra_args: extraArgsObj,\n        } as never);\n      } else {\n        await httpClient.testEmbeddingModel('_', {\n          uuid: '',\n          name,\n          provider_uuid: '',\n          provider: providerData,\n          extra_args: extraArgsObj,\n        } as never);\n      }\n      const duration = Date.now() - startTime;\n      setTestResult({ success: true, duration });\n    } catch (err) {\n      console.error('Failed to test model', err);\n      toast.error(t('models.testError') + ': ' + (err as CustomApiError).msg);\n      setTestResult(null);\n    } finally {\n      setIsTesting(false);\n    }\n  }\n\n  function handleFormClose() {\n    setProviderFormOpen(false);\n    loadProviders();\n    // Refresh expanded providers\n    expandedProviders.forEach((uuid) => loadProviderModels(uuid));\n  }\n\n  function renderProviderCard(\n    provider: ModelProvider,\n    isLangBotModels: boolean = false,\n  ) {\n    return (\n      <ProviderCard\n        key={provider.uuid}\n        provider={provider}\n        isLangBotModels={isLangBotModels}\n        isExpanded={expandedProviders.has(provider.uuid)}\n        isLoading={loadingProviders.has(provider.uuid)}\n        models={providerModels[provider.uuid]}\n        accountType={accountType}\n        spaceCredits={spaceCredits}\n        addModelPopoverOpen={addModelPopoverOpen}\n        editModelPopoverOpen={editModelPopoverOpen}\n        deleteConfirmOpen={deleteConfirmOpen}\n        onToggle={() => toggleProvider(provider.uuid)}\n        onEditProvider={() => handleEditProvider(provider.uuid)}\n        onDeleteProvider={() => handleDeleteProvider(provider.uuid)}\n        onSpaceLogin={handleSpaceLogin}\n        onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)}\n        onCloseAddModel={() => setAddModelPopoverOpen(null)}\n        onAddModel={(modelType, name, abilities, extraArgs) =>\n          handleAddModel(provider.uuid, modelType, name, abilities, extraArgs)\n        }\n        onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)}\n        onCloseEditModel={() => setEditModelPopoverOpen(null)}\n        onUpdateModel={(modelId, modelType, name, abilities, extraArgs) =>\n          handleUpdateModel(\n            provider.uuid,\n            modelId,\n            modelType,\n            name,\n            abilities,\n            extraArgs,\n          )\n        }\n        onOpenDeleteConfirm={(modelId) => setDeleteConfirmOpen(modelId)}\n        onCloseDeleteConfirm={() => setDeleteConfirmOpen(null)}\n        onDeleteModel={(modelId, modelType) =>\n          handleDeleteModel(provider.uuid, modelId, modelType)\n        }\n        onTestModel={(name, modelType, abilities, extraArgs) =>\n          handleTestModel(provider.uuid, name, modelType, abilities, extraArgs)\n        }\n        isSubmitting={isSubmitting}\n        isTesting={isTesting}\n        testResult={testResult}\n        onResetTestResult={() => setTestResult(null)}\n      />\n    );\n  }\n\n  return (\n    <>\n      <Dialog\n        open={open}\n        onOpenChange={(newOpen) => {\n          if (!newOpen && providerFormOpen) return;\n          onOpenChange(newOpen);\n        }}\n      >\n        <DialogContent className=\"overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]\">\n          <DialogHeader className=\"px-6 pt-6 pb-0 flex-shrink-0\">\n            <DialogTitle>{t('models.title')}</DialogTitle>\n          </DialogHeader>\n\n          <div className=\"flex-1 overflow-auto px-6 pb-6 mt-0\">\n            {/* LangBot Models Card */}\n            {langbotProvider && renderProviderCard(langbotProvider, true)}\n\n            {/* Add Provider Button */}\n            <div className=\"mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10\">\n              <span className=\"text-sm text-muted-foreground\">\n                {otherProviders.length === 0\n                  ? t(\n                      systemInfo.disable_models_service\n                        ? 'models.addProviderHintSimple'\n                        : 'models.addProviderHint',\n                    )\n                  : t('models.providerCount', { count: otherProviders.length })}\n              </span>\n              <div className=\"flex gap-2\">\n                <Button\n                  size=\"sm\"\n                  variant=\"outline\"\n                  onClick={handleCreateProvider}\n                >\n                  <Plus className=\"h-4 w-4 mr-1\" />\n                  {t('models.addProvider')}\n                </Button>\n              </div>\n            </div>\n\n            {/* Provider List */}\n            {otherProviders.length === 0 ? (\n              <div className=\"flex flex-col items-center justify-center py-12 text-muted-foreground\">\n                <Boxes className=\"h-12 w-12 mb-3 opacity-50\" />\n                <p className=\"text-sm\">{t('models.noProviders')}</p>\n              </div>\n            ) : (\n              otherProviders.map((p) => renderProviderCard(p))\n            )}\n          </div>\n        </DialogContent>\n      </Dialog>\n\n      <Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>\n        <DialogContent className=\"w-[600px] p-6\">\n          <DialogHeader>\n            <DialogTitle>\n              {editingProviderId\n                ? t('models.editProvider')\n                : t('models.addProvider')}\n            </DialogTitle>\n          </DialogHeader>\n          <ProviderForm\n            providerId={editingProviderId || undefined}\n            onFormSubmit={handleFormClose}\n            onFormCancel={() => setProviderFormOpen(false)}\n          />\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { httpClient } from '@/app/infra/http/HttpClient';\n\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { useForm } from 'react-hook-form';\nimport { z } from 'zod';\nimport { useTranslation } from 'react-i18next';\n\nimport { Button } from '@/components/ui/button';\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from '@/components/ui/form';\nimport { Input } from '@/components/ui/input';\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { DialogFooter } from '@/components/ui/dialog';\nimport { toast } from 'sonner';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\nimport { CustomApiError } from '@/app/infra/entities/common';\n\nconst getFormSchema = (t: (key: string) => string) =>\n  z.object({\n    name: z.string().min(1, { message: t('models.providerNameRequired') }),\n    requester: z.string().min(1, { message: t('models.requesterRequired') }),\n    base_url: z.string(),\n    api_key: z.string().optional(),\n  });\n\ninterface ProviderFormProps {\n  providerId?: string;\n  onFormSubmit: () => void;\n  onFormCancel: () => void;\n}\n\nexport default function ProviderForm({\n  providerId,\n  onFormSubmit,\n  onFormCancel,\n}: ProviderFormProps) {\n  const { t } = useTranslation();\n  const formSchema = getFormSchema(t);\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      name: '',\n      requester: '',\n      base_url: '',\n      api_key: '',\n    },\n  });\n\n  const [requesterList, setRequesterList] = useState<\n    {\n      label: string;\n      value: string;\n      category: string;\n      defaultUrl: string;\n      description: string;\n    }[]\n  >([]);\n\n  useEffect(() => {\n    loadRequesters();\n    if (providerId) {\n      loadProvider(providerId);\n    }\n  }, [providerId]);\n\n  async function loadRequesters() {\n    const resp = await httpClient.getProviderRequesters();\n    setRequesterList(\n      resp.requesters\n        .filter((item) => item.name !== 'space-chat-completions')\n        .map((item) => ({\n          label: extractI18nObject(item.label),\n          value: item.name,\n          category: item.spec.provider_category || 'manufacturer',\n          defaultUrl:\n            item.spec.config\n              .find((c) => c.name === 'base_url')\n              ?.default?.toString() || '',\n          description: extractI18nObject(item.description),\n        })),\n    );\n  }\n\n  async function loadProvider(id: string) {\n    const resp = await httpClient.getModelProvider(id);\n    const provider = resp.provider;\n\n    form.setValue('name', provider.name);\n    form.setValue('requester', provider.requester);\n    form.setValue('base_url', provider.base_url);\n    form.setValue('api_key', provider.api_keys?.[0] || '');\n  }\n\n  async function handleFormSubmit(values: z.infer<typeof formSchema>) {\n    const data = {\n      name: values.name,\n      requester: values.requester,\n      base_url: values.base_url,\n      api_keys: values.api_key ? [values.api_key] : [],\n    };\n\n    try {\n      if (providerId) {\n        await httpClient.updateModelProvider(providerId, data);\n        toast.success(t('models.providerSaved'));\n      } else {\n        await httpClient.createModelProvider(data);\n        toast.success(t('models.providerCreated'));\n      }\n      onFormSubmit();\n    } catch (err) {\n      toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);\n    }\n  }\n\n  return (\n    <Form {...form}>\n      <form\n        onSubmit={form.handleSubmit(handleFormSubmit)}\n        className=\"space-y-4\"\n      >\n        <FormField\n          control={form.control}\n          name=\"name\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>\n                {t('models.providerName')}\n                <span className=\"text-red-500\">*</span>\n              </FormLabel>\n              <FormControl>\n                <Input {...field} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n\n        <FormField\n          control={form.control}\n          name=\"requester\"\n          render={({ field }) => {\n            const selectedRequester = requesterList.find(\n              (r) => r.value === field.value,\n            );\n            return (\n              <FormItem>\n                <FormLabel>\n                  {t('models.requester')}\n                  <span className=\"text-red-500\">*</span>\n                </FormLabel>\n                <Select\n                  onValueChange={(v) => {\n                    field.onChange(v);\n                    const req = requesterList.find((r) => r.value === v);\n                    // Auto-fill default URL when creating new provider\n                    // or when base_url is empty in edit mode\n                    if (req && (!providerId || !form.getValues('base_url'))) {\n                      form.setValue('base_url', req.defaultUrl);\n                    }\n                  }}\n                  value={field.value}\n                >\n                  <SelectTrigger className=\"bg-background\">\n                    {selectedRequester ? (\n                      <div className=\"flex items-center gap-2\">\n                        <img\n                          src={httpClient.getProviderRequesterIconURL(\n                            selectedRequester.value,\n                          )}\n                          alt={selectedRequester.label}\n                          className=\"h-5 w-5 rounded\"\n                        />\n                        <span>{selectedRequester.label}</span>\n                      </div>\n                    ) : (\n                      <SelectValue placeholder={t('models.selectRequester')} />\n                    )}\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectGroup>\n                      <SelectLabel>{t('models.builtin')}</SelectLabel>\n                      {requesterList\n                        .filter((r) => r.category === 'builtin')\n                        .map((r) => (\n                          <SelectItem key={r.value} value={r.value}>\n                            <div className=\"flex items-center gap-2\">\n                              <img\n                                src={httpClient.getProviderRequesterIconURL(\n                                  r.value,\n                                )}\n                                alt={r.label}\n                                className=\"h-5 w-5 rounded\"\n                              />\n                              <span>{r.label}</span>\n                            </div>\n                          </SelectItem>\n                        ))}\n                    </SelectGroup>\n                    <SelectGroup>\n                      <SelectLabel>{t('models.modelManufacturer')}</SelectLabel>\n                      {requesterList\n                        .filter((r) => r.category === 'manufacturer')\n                        .map((r) => (\n                          <SelectItem key={r.value} value={r.value}>\n                            <div className=\"flex items-center gap-2\">\n                              <img\n                                src={httpClient.getProviderRequesterIconURL(\n                                  r.value,\n                                )}\n                                alt={r.label}\n                                className=\"h-5 w-5 rounded\"\n                              />\n                              <span>{r.label}</span>\n                            </div>\n                          </SelectItem>\n                        ))}\n                    </SelectGroup>\n                    <SelectGroup>\n                      <SelectLabel>\n                        {t('models.aggregationPlatform')}\n                      </SelectLabel>\n                      {requesterList\n                        .filter((r) => r.category === 'maas')\n                        .map((r) => (\n                          <SelectItem key={r.value} value={r.value}>\n                            <div className=\"flex items-center gap-2\">\n                              <img\n                                src={httpClient.getProviderRequesterIconURL(\n                                  r.value,\n                                )}\n                                alt={r.label}\n                                className=\"h-5 w-5 rounded\"\n                              />\n                              <span>{r.label}</span>\n                            </div>\n                          </SelectItem>\n                        ))}\n                    </SelectGroup>\n                    <SelectGroup>\n                      <SelectLabel>{t('models.selfDeployed')}</SelectLabel>\n                      {requesterList\n                        .filter((r) => r.category === 'self-hosted')\n                        .map((r) => (\n                          <SelectItem key={r.value} value={r.value}>\n                            <div className=\"flex items-center gap-2\">\n                              <img\n                                src={httpClient.getProviderRequesterIconURL(\n                                  r.value,\n                                )}\n                                alt={r.label}\n                                className=\"h-5 w-5 rounded\"\n                              />\n                              <span>{r.label}</span>\n                            </div>\n                          </SelectItem>\n                        ))}\n                    </SelectGroup>\n                  </SelectContent>\n                </Select>\n                <FormMessage />\n                {selectedRequester?.description && (\n                  <p className=\"text-sm text-muted-foreground\">\n                    {selectedRequester.description}\n                  </p>\n                )}\n              </FormItem>\n            );\n          }}\n        />\n\n        <FormField\n          control={form.control}\n          name=\"base_url\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>{t('models.requestURL')}</FormLabel>\n              <FormControl>\n                <Input {...field} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n\n        <FormField\n          control={form.control}\n          name=\"api_key\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>{t('models.apiKey')}</FormLabel>\n              <FormControl>\n                <Input {...field} type=\"password\" />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n\n        <DialogFooter>\n          <Button type=\"submit\">{t('common.save')}</Button>\n          <Button type=\"button\" variant=\"outline\" onClick={onFormCancel}>\n            {t('common.cancel')}\n          </Button>\n        </DialogFooter>\n      </form>\n    </Form>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/models-dialog/components/AddModelPopover.tsx",
    "content": "'use client';\n\nimport { useState, useEffect } from 'react';\nimport { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { useTranslation } from 'react-i18next';\nimport { ExtraArg, ModelType, TestResult } from '../types';\nimport ExtraArgsEditor from './ExtraArgsEditor';\n\ninterface AddModelPopoverProps {\n  isOpen: boolean;\n  onOpen: () => void;\n  onClose: () => void;\n  onAddModel: (\n    modelType: ModelType,\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  onTestModel: (\n    name: string,\n    modelType: ModelType,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  isSubmitting: boolean;\n  isTesting: boolean;\n  testResult: TestResult | null;\n  onResetTestResult: () => void;\n}\n\nexport default function AddModelPopover({\n  isOpen,\n  onOpen,\n  onClose,\n  onAddModel,\n  onTestModel,\n  isSubmitting,\n  isTesting,\n  testResult,\n  onResetTestResult,\n}: AddModelPopoverProps) {\n  const { t } = useTranslation();\n\n  const [tab, setTab] = useState<ModelType>('llm');\n  const [name, setName] = useState('');\n  const [abilities, setAbilities] = useState<string[]>([]);\n  const [extraArgs, setExtraArgs] = useState<ExtraArg[]>([]);\n\n  // Reset form when popover opens\n  useEffect(() => {\n    if (isOpen) {\n      setTab('llm');\n      setName('');\n      setAbilities([]);\n      setExtraArgs([]);\n      onResetTestResult();\n    }\n  }, [isOpen]);\n\n  const handleAdd = async () => {\n    await onAddModel(tab, name, abilities, extraArgs);\n  };\n\n  const handleTest = async () => {\n    await onTestModel(name, tab, tab === 'llm' ? abilities : [], extraArgs);\n  };\n\n  const toggleAbility = (ability: string, checked: boolean) => {\n    if (checked) {\n      setAbilities([...abilities, ability]);\n    } else {\n      setAbilities(abilities.filter((a) => a !== ability));\n    }\n  };\n\n  return (\n    <Popover\n      open={isOpen}\n      onOpenChange={(open) => (open ? onOpen() : onClose())}\n    >\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"h-6 text-xs\"\n          onClick={(e) => e.stopPropagation()}\n        >\n          <Plus className=\"h-3 w-3 mr-1\" />\n          {t('models.addModel')}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent\n        className=\"w-80\"\n        align=\"end\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        <Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>\n          <TabsList className=\"grid w-full grid-cols-2\">\n            <TabsTrigger value=\"llm\">\n              <MessageSquareText className=\"h-4 w-4 mr-1\" />\n              {t('models.chat')}\n            </TabsTrigger>\n            <TabsTrigger value=\"embedding\">\n              <Cpu className=\"h-4 w-4 mr-1\" />\n              {t('models.embedding')}\n            </TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"llm\" className=\"space-y-3 mt-3\">\n            <div className=\"space-y-2\">\n              <Label>{t('models.modelName')}</Label>\n              <Input\n                placeholder={t('models.modelName')}\n                value={name}\n                onChange={(e) => setName(e.target.value)}\n              />\n            </div>\n            <div className=\"space-y-2\">\n              <Label>{t('models.abilities')}</Label>\n              <div className=\"flex gap-4\">\n                <div className=\"flex items-center gap-2\">\n                  <Checkbox\n                    id=\"add-vision\"\n                    checked={abilities.includes('vision')}\n                    onCheckedChange={(checked) =>\n                      toggleAbility('vision', checked as boolean)\n                    }\n                  />\n                  <Label htmlFor=\"add-vision\" className=\"text-sm\">\n                    <Eye className=\"h-3 w-3 inline mr-1\" />\n                    {t('models.visionAbility')}\n                  </Label>\n                </div>\n                <div className=\"flex items-center gap-2\">\n                  <Checkbox\n                    id=\"add-func-call\"\n                    checked={abilities.includes('func_call')}\n                    onCheckedChange={(checked) =>\n                      toggleAbility('func_call', checked as boolean)\n                    }\n                  />\n                  <Label htmlFor=\"add-func-call\" className=\"text-sm\">\n                    <Wrench className=\"h-3 w-3 inline mr-1\" />\n                    {t('models.functionCallAbility')}\n                  </Label>\n                </div>\n              </div>\n            </div>\n            <ExtraArgsEditor args={extraArgs} onChange={setExtraArgs} />\n            <div className=\"flex gap-2\">\n              <Button\n                className=\"flex-1\"\n                size=\"sm\"\n                onClick={handleAdd}\n                disabled={isSubmitting || isTesting}\n              >\n                {isSubmitting ? t('common.saving') : t('common.add')}\n              </Button>\n              <Button\n                className=\"flex-1\"\n                size=\"sm\"\n                variant=\"outline\"\n                onClick={handleTest}\n                disabled={isSubmitting || isTesting}\n              >\n                {isTesting ? (\n                  t('common.loading')\n                ) : testResult?.success ? (\n                  <>\n                    <Check className=\"h-4 w-4 mr-1 text-green-500\" />\n                    {(testResult.duration / 1000).toFixed(1)}s\n                  </>\n                ) : (\n                  t('common.test')\n                )}\n              </Button>\n            </div>\n          </TabsContent>\n\n          <TabsContent value=\"embedding\" className=\"space-y-3 mt-3\">\n            <div className=\"space-y-2\">\n              <Label>{t('models.modelName')}</Label>\n              <Input\n                placeholder={t('models.modelName')}\n                value={name}\n                onChange={(e) => setName(e.target.value)}\n              />\n            </div>\n            <ExtraArgsEditor args={extraArgs} onChange={setExtraArgs} />\n            <div className=\"flex gap-2\">\n              <Button\n                className=\"flex-1\"\n                size=\"sm\"\n                onClick={handleAdd}\n                disabled={isSubmitting || isTesting}\n              >\n                {isSubmitting ? t('common.saving') : t('common.add')}\n              </Button>\n              <Button\n                className=\"flex-1\"\n                size=\"sm\"\n                variant=\"outline\"\n                onClick={handleTest}\n                disabled={isSubmitting || isTesting}\n              >\n                {isTesting ? (\n                  t('common.loading')\n                ) : testResult?.success ? (\n                  <>\n                    <Check className=\"h-4 w-4 mr-1 text-green-500\" />\n                    {(testResult.duration / 1000).toFixed(1)}s\n                  </>\n                ) : (\n                  t('common.test')\n                )}\n              </Button>\n            </div>\n          </TabsContent>\n        </Tabs>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/models-dialog/components/ExtraArgsEditor.tsx",
    "content": "'use client';\n\nimport { Plus, X } from 'lucide-react';\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 { useTranslation } from 'react-i18next';\nimport { ExtraArg } from '../types';\n\ninterface ExtraArgsEditorProps {\n  args: ExtraArg[];\n  onChange: (args: ExtraArg[]) => void;\n  disabled?: boolean;\n}\n\nexport default function ExtraArgsEditor({\n  args,\n  onChange,\n  disabled = false,\n}: ExtraArgsEditorProps) {\n  const { t } = useTranslation();\n\n  const handleAdd = () => {\n    onChange([...args, { key: '', type: 'string', value: '' }]);\n  };\n\n  const handleRemove = (index: number) => {\n    onChange(args.filter((_, i) => i !== index));\n  };\n\n  const handleUpdate = (\n    index: number,\n    field: keyof ExtraArg,\n    value: string,\n  ) => {\n    const newArgs = [...args];\n    newArgs[index] = { ...newArgs[index], [field]: value };\n    onChange(newArgs);\n  };\n\n  return (\n    <div className=\"space-y-2\">\n      <div className=\"flex items-center justify-between\">\n        <Label>{t('models.extraParameters')}</Label>\n        {!disabled && (\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"h-6 text-xs\"\n            onClick={handleAdd}\n          >\n            <Plus className=\"h-3 w-3 mr-1\" />\n            {t('models.addParameter')}\n          </Button>\n        )}\n      </div>\n      {args.length === 0 ? (\n        <p className=\"text-sm text-muted-foreground\">{t('common.none')}</p>\n      ) : (\n        args.map((arg, index) => (\n          <div key={index} className=\"flex gap-2 items-center\">\n            <Input\n              placeholder={t('models.keyName')}\n              value={arg.key}\n              className=\"flex-1\"\n              disabled={disabled}\n              onChange={(e) => handleUpdate(index, 'key', e.target.value)}\n            />\n            <Select\n              value={arg.type}\n              disabled={disabled}\n              onValueChange={(value) => handleUpdate(index, 'type', value)}\n            >\n              <SelectTrigger className=\"w-24\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"string\">{t('models.string')}</SelectItem>\n                <SelectItem value=\"number\">{t('models.number')}</SelectItem>\n                <SelectItem value=\"boolean\">{t('models.boolean')}</SelectItem>\n              </SelectContent>\n            </Select>\n            <Input\n              placeholder={t('models.value')}\n              value={arg.value}\n              className=\"flex-1\"\n              disabled={disabled}\n              onChange={(e) => handleUpdate(index, 'value', e.target.value)}\n            />\n            {!disabled && (\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"h-8 w-8 flex-shrink-0\"\n                onClick={() => handleRemove(index)}\n              >\n                <X className=\"h-4 w-4\" />\n              </Button>\n            )}\n          </div>\n        ))\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/models-dialog/components/ModelItem.tsx",
    "content": "'use client';\n\nimport { useState, useEffect } from 'react';\nimport { Trash2, Eye, Wrench, Check } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport { Badge } from '@/components/ui/badge';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { useTranslation } from 'react-i18next';\nimport { LLMModel, EmbeddingModel } from '@/app/infra/entities/api';\nimport { ExtraArg, ModelType, TestResult } from '../types';\nimport ExtraArgsEditor from './ExtraArgsEditor';\nimport { userInfo } from '@/app/infra/http';\n\ninterface ModelItemProps {\n  model: LLMModel | EmbeddingModel;\n  modelType: ModelType;\n  isLangBotModels: boolean;\n  editModelPopoverOpen: string | null;\n  deleteConfirmOpen: string | null;\n  onOpenEditModel: (modelId: string) => void;\n  onCloseEditModel: () => void;\n  onOpenDeleteConfirm: (modelId: string) => void;\n  onCloseDeleteConfirm: () => void;\n  onDeleteModel: () => void;\n  onUpdateModel: (\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  onTestModel: (\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  isSubmitting: boolean;\n  isTesting: boolean;\n  testResult: TestResult | null;\n  onResetTestResult: () => void;\n}\n\nfunction convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {\n  if (!extraArgs) return [];\n  return Object.entries(extraArgs).map(([key, value]) => {\n    let type: 'string' | 'number' | 'boolean' = 'string';\n    if (typeof value === 'number') type = 'number';\n    else if (typeof value === 'boolean') type = 'boolean';\n    return { key, type, value: String(value) };\n  });\n}\n\nexport default function ModelItem({\n  model,\n  modelType,\n  isLangBotModels,\n  editModelPopoverOpen,\n  deleteConfirmOpen,\n  onOpenEditModel,\n  onCloseEditModel,\n  onOpenDeleteConfirm,\n  onCloseDeleteConfirm,\n  onDeleteModel,\n  onUpdateModel,\n  onTestModel,\n  isSubmitting,\n  isTesting,\n  testResult,\n  onResetTestResult,\n}: ModelItemProps) {\n  const { t } = useTranslation();\n\n  const [editName, setEditName] = useState(model.name);\n  const [editAbilities, setEditAbilities] = useState<string[]>(\n    modelType === 'llm' ? (model as LLMModel).abilities || [] : [],\n  );\n  const [editExtraArgs, setEditExtraArgs] = useState<ExtraArg[]>(\n    convertExtraArgsToArray(model.extra_args),\n  );\n\n  const isEditOpen = editModelPopoverOpen === model.uuid;\n  const isDeleteOpen = deleteConfirmOpen === model.uuid;\n\n  // Reset form when popover opens\n  useEffect(() => {\n    if (isEditOpen) {\n      setEditName(model.name);\n      setEditAbilities(\n        modelType === 'llm' ? (model as LLMModel).abilities || [] : [],\n      );\n      setEditExtraArgs(convertExtraArgsToArray(model.extra_args));\n      onResetTestResult();\n    }\n  }, [isEditOpen]);\n\n  const handleSave = async () => {\n    await onUpdateModel(editName, editAbilities, editExtraArgs);\n  };\n\n  const handleTest = async () => {\n    await onTestModel(editName, editAbilities, editExtraArgs);\n  };\n\n  const toggleAbility = (ability: string, checked: boolean) => {\n    if (checked) {\n      setEditAbilities([...editAbilities, ability]);\n    } else {\n      setEditAbilities(editAbilities.filter((a) => a !== ability));\n    }\n  };\n\n  // Check if popover should be disabled (space models when not logged in)\n  const isPopoverDisabled =\n    isLangBotModels && userInfo?.account_type !== 'space';\n\n  return (\n    <Popover\n      open={isEditOpen && !isPopoverDisabled}\n      onOpenChange={(open) => {\n        if (isPopoverDisabled) return;\n        if (open) {\n          onOpenEditModel(model.uuid);\n        } else {\n          onCloseEditModel();\n        }\n      }}\n    >\n      <PopoverTrigger asChild>\n        <div\n          className={`flex items-center justify-between py-2 px-3 rounded-md border bg-background ${\n            isPopoverDisabled\n              ? 'cursor-not-allowed opacity-60'\n              : 'hover:bg-accent cursor-pointer'\n          }`}\n        >\n          <div className=\"flex items-center gap-2 flex-wrap\">\n            <span className=\"text-sm font-medium\">{model.name}</span>\n            <Badge variant=\"secondary\" className=\"text-xs\">\n              {modelType === 'llm' ? t('models.chat') : t('models.embedding')}\n            </Badge>\n            {modelType === 'llm' &&\n              (model as LLMModel).abilities?.includes('vision') && (\n                <Badge variant=\"outline\" className=\"text-xs gap-1\">\n                  <Eye className=\"h-3 w-3\" />\n                </Badge>\n              )}\n            {modelType === 'llm' &&\n              (model as LLMModel).abilities?.includes('func_call') && (\n                <Badge variant=\"outline\" className=\"text-xs gap-1\">\n                  <Wrench className=\"h-3 w-3\" />\n                </Badge>\n              )}\n          </div>\n          {!isLangBotModels && (\n            <Popover\n              open={isDeleteOpen}\n              onOpenChange={(open) =>\n                open ? onOpenDeleteConfirm(model.uuid) : onCloseDeleteConfirm()\n              }\n            >\n              <PopoverTrigger asChild>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  className=\"h-7 w-7 flex-shrink-0\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                  }}\n                >\n                  <Trash2 className=\"h-4 w-4 text-muted-foreground hover:text-destructive\" />\n                </Button>\n              </PopoverTrigger>\n              <PopoverContent\n                className=\"w-64\"\n                align=\"end\"\n                onClick={(e) => e.stopPropagation()}\n              >\n                <div className=\"space-y-3\">\n                  <p className=\"text-sm\">{t('models.deleteConfirmation')}</p>\n                  <div className=\"flex gap-2 justify-end\">\n                    <Button\n                      variant=\"outline\"\n                      size=\"sm\"\n                      onClick={() => onCloseDeleteConfirm()}\n                    >\n                      {t('common.cancel')}\n                    </Button>\n                    <Button\n                      variant=\"destructive\"\n                      size=\"sm\"\n                      onClick={() => {\n                        onDeleteModel();\n                        onCloseDeleteConfirm();\n                      }}\n                    >\n                      {t('common.delete')}\n                    </Button>\n                  </div>\n                </div>\n              </PopoverContent>\n            </Popover>\n          )}\n        </div>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-80\" align=\"start\">\n        <div className=\"space-y-3\">\n          <div className=\"space-y-2\">\n            <Label>{t('models.modelName')}</Label>\n            <Input\n              placeholder={t('models.modelName')}\n              value={editName}\n              onChange={(e) => setEditName(e.target.value)}\n              disabled={isLangBotModels}\n            />\n          </div>\n\n          {modelType === 'llm' && (\n            <div className=\"space-y-2\">\n              <Label>{t('models.abilities')}</Label>\n              <div className=\"flex gap-4\">\n                <div className=\"flex items-center gap-2\">\n                  <Checkbox\n                    id={`edit-vision-${model.uuid}`}\n                    checked={editAbilities.includes('vision')}\n                    disabled={isLangBotModels}\n                    onCheckedChange={(checked) =>\n                      toggleAbility('vision', checked as boolean)\n                    }\n                  />\n                  <Label\n                    htmlFor={`edit-vision-${model.uuid}`}\n                    className=\"text-sm\"\n                  >\n                    <Eye className=\"h-3 w-3 inline mr-1\" />\n                    {t('models.visionAbility')}\n                  </Label>\n                </div>\n                <div className=\"flex items-center gap-2\">\n                  <Checkbox\n                    id={`edit-func-call-${model.uuid}`}\n                    checked={editAbilities.includes('func_call')}\n                    disabled={isLangBotModels}\n                    onCheckedChange={(checked) =>\n                      toggleAbility('func_call', checked as boolean)\n                    }\n                  />\n                  <Label\n                    htmlFor={`edit-func-call-${model.uuid}`}\n                    className=\"text-sm\"\n                  >\n                    <Wrench className=\"h-3 w-3 inline mr-1\" />\n                    {t('models.functionCallAbility')}\n                  </Label>\n                </div>\n              </div>\n            </div>\n          )}\n\n          <ExtraArgsEditor\n            args={editExtraArgs}\n            onChange={setEditExtraArgs}\n            disabled={isLangBotModels}\n          />\n\n          <div className=\"flex gap-2\">\n            {!isLangBotModels && (\n              <Button\n                className=\"flex-1\"\n                size=\"sm\"\n                onClick={handleSave}\n                disabled={isSubmitting || isTesting}\n              >\n                {isSubmitting ? t('common.saving') : t('common.save')}\n              </Button>\n            )}\n            <Button\n              className={isLangBotModels ? 'w-full' : 'flex-1'}\n              size=\"sm\"\n              variant=\"outline\"\n              onClick={handleTest}\n              disabled={isSubmitting || isTesting}\n            >\n              {isTesting ? (\n                t('common.loading')\n              ) : testResult?.success ? (\n                <>\n                  <Check className=\"h-4 w-4 mr-1 text-green-500\" />\n                  {(testResult.duration / 1000).toFixed(1)}s\n                </>\n              ) : (\n                t('common.test')\n              )}\n            </Button>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/models-dialog/components/ProviderCard.tsx",
    "content": "'use client';\n\nimport { useState } from 'react';\nimport {\n  Plus,\n  ChevronDown,\n  ChevronRight,\n  Trash2,\n  Settings,\n  LogIn,\n} from 'lucide-react';\nimport { httpClient, systemInfo } from '@/app/infra/http/HttpClient';\nimport { ModelProvider } from '@/app/infra/entities/api';\nimport { Button } from '@/components/ui/button';\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from '@/components/ui/collapsible';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Badge } from '@/components/ui/badge';\nimport { useTranslation } from 'react-i18next';\nimport langbotIcon from '@/app/assets/langbot-logo.webp';\nimport { ExtraArg, ModelType, TestResult, ProviderModels } from '../types';\nimport ModelItem from './ModelItem';\nimport AddModelPopover from './AddModelPopover';\n\ninterface ProviderCardProps {\n  provider: ModelProvider;\n  isLangBotModels?: boolean;\n  isExpanded: boolean;\n  isLoading: boolean;\n  models?: ProviderModels;\n  accountType: 'local' | 'space';\n  spaceCredits: number | null;\n  // Popover states\n  addModelPopoverOpen: string | null;\n  editModelPopoverOpen: string | null;\n  deleteConfirmOpen: string | null;\n  // Handlers\n  onToggle: () => void;\n  onEditProvider: () => void;\n  onDeleteProvider: () => void;\n  onSpaceLogin: () => void;\n  onOpenAddModel: () => void;\n  onCloseAddModel: () => void;\n  onAddModel: (\n    modelType: ModelType,\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  onOpenEditModel: (modelId: string) => void;\n  onCloseEditModel: () => void;\n  onUpdateModel: (\n    modelId: string,\n    modelType: ModelType,\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  onOpenDeleteConfirm: (modelId: string) => void;\n  onCloseDeleteConfirm: () => void;\n  onDeleteModel: (modelId: string, modelType: ModelType) => Promise<void>;\n  onTestModel: (\n    name: string,\n    modelType: ModelType,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  isSubmitting: boolean;\n  isTesting: boolean;\n  testResult: TestResult | null;\n  onResetTestResult: () => void;\n}\n\nfunction maskApiKey(key: string): string {\n  if (!key) return '';\n  if (key.length <= 8) return '****';\n  return `${key.slice(0, 4)}...${key.slice(-4)}`;\n}\n\nexport default function ProviderCard({\n  provider,\n  isLangBotModels = false,\n  isExpanded,\n  isLoading,\n  models,\n  accountType,\n  spaceCredits,\n  addModelPopoverOpen,\n  editModelPopoverOpen,\n  deleteConfirmOpen,\n  onToggle,\n  onEditProvider,\n  onDeleteProvider,\n  onSpaceLogin,\n  onOpenAddModel,\n  onCloseAddModel,\n  onAddModel,\n  onOpenEditModel,\n  onCloseEditModel,\n  onUpdateModel,\n  onOpenDeleteConfirm,\n  onCloseDeleteConfirm,\n  onDeleteModel,\n  onTestModel,\n  isSubmitting,\n  isTesting,\n  testResult,\n  onResetTestResult,\n}: ProviderCardProps) {\n  const { t } = useTranslation();\n  const [deleteProviderConfirmOpen, setDeleteProviderConfirmOpen] =\n    useState(false);\n\n  const canDelete =\n    !isLangBotModels &&\n    (provider.llm_count || 0) === 0 &&\n    (provider.embedding_count || 0) === 0;\n  const totalModels =\n    (provider.llm_count || 0) + (provider.embedding_count || 0);\n\n  return (\n    <Card className=\"mb-2\">\n      <Collapsible open={isExpanded} onOpenChange={onToggle}>\n        <CardHeader className=\"py-0 px-4\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-2 flex-1\">\n              {isLangBotModels ? (\n                <div className=\"w-9 h-9 rounded-lg overflow-hidden flex-shrink-0\">\n                  <img\n                    src={langbotIcon.src}\n                    alt=\"LangBot\"\n                    className=\"w-full h-full object-cover\"\n                  />\n                </div>\n              ) : (\n                <img\n                  src={httpClient.getProviderRequesterIconURL(\n                    provider.requester,\n                  )}\n                  alt={provider.name}\n                  className=\"h-9 w-9 rounded-lg\"\n                />\n              )}\n              <div className=\"flex-1 min-w-0\">\n                <div className=\"flex items-center gap-2\">\n                  <CardTitle className=\"text-base\">{provider.name}</CardTitle>\n                  <Badge variant=\"outline\" className=\"text-xs\">\n                    {t('models.modelsCount', { count: totalModels })}\n                  </Badge>\n                </div>\n                <p className=\"text-xs text-muted-foreground truncate\">\n                  {isLangBotModels ? (\n                    t('models.langbotModelsDescription')\n                  ) : (\n                    <>\n                      {provider.base_url}\n                      {provider.base_url &&\n                        provider.api_keys?.length > 0 &&\n                        ' · '}\n                      {provider.api_keys?.length > 0 &&\n                        maskApiKey(provider.api_keys[0])}\n                    </>\n                  )}\n                </p>\n              </div>\n            </div>\n            <div className=\"flex items-center gap-1 ml-2\">\n              {isLangBotModels && accountType !== 'space' && (\n                <Button\n                  variant=\"outline\"\n                  size=\"sm\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    onSpaceLogin();\n                  }}\n                >\n                  <LogIn className=\"h-4 w-4 mr-1\" />\n                  {t('models.loginWithSpace')}\n                </Button>\n              )}\n              {isLangBotModels &&\n                accountType === 'space' &&\n                spaceCredits !== null && (\n                  <div className=\"flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2\">\n                    <span>\n                      {(spaceCredits / 5000).toFixed(2)} {t('models.credits')}\n                    </span>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      className=\"h-5 w-5\"\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        window.open(\n                          `${systemInfo.cloud_service_url}/profile?tab=billing`,\n                          '_blank',\n                        );\n                      }}\n                    >\n                      <Plus className=\"h-3 w-3\" />\n                    </Button>\n                  </div>\n                )}\n              {!isLangBotModels && (\n                <>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    className=\"h-8 w-8\"\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      onEditProvider();\n                    }}\n                  >\n                    <Settings className=\"h-4 w-4\" />\n                  </Button>\n                  {canDelete && (\n                    <Popover\n                      open={deleteProviderConfirmOpen}\n                      onOpenChange={setDeleteProviderConfirmOpen}\n                    >\n                      <PopoverTrigger asChild>\n                        <Button\n                          variant=\"ghost\"\n                          size=\"icon\"\n                          className=\"h-8 w-8\"\n                          onClick={(e) => {\n                            e.stopPropagation();\n                          }}\n                        >\n                          <Trash2 className=\"h-4 w-4 text-destructive\" />\n                        </Button>\n                      </PopoverTrigger>\n                      <PopoverContent\n                        className=\"w-64\"\n                        align=\"end\"\n                        onClick={(e) => e.stopPropagation()}\n                      >\n                        <div className=\"space-y-3\">\n                          <p className=\"text-sm\">\n                            {t('models.deleteProviderConfirmation')}\n                          </p>\n                          <div className=\"flex gap-2 justify-end\">\n                            <Button\n                              variant=\"outline\"\n                              size=\"sm\"\n                              onClick={() =>\n                                setDeleteProviderConfirmOpen(false)\n                              }\n                            >\n                              {t('common.cancel')}\n                            </Button>\n                            <Button\n                              variant=\"destructive\"\n                              size=\"sm\"\n                              onClick={() => {\n                                onDeleteProvider();\n                                setDeleteProviderConfirmOpen(false);\n                              }}\n                            >\n                              {t('common.delete')}\n                            </Button>\n                          </div>\n                        </div>\n                      </PopoverContent>\n                    </Popover>\n                  )}\n                </>\n              )}\n            </div>\n          </div>\n          <div className=\"flex items-center justify-between mt-2\">\n            {totalModels > 0 ? (\n              <CollapsibleTrigger className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer\">\n                {isExpanded ? (\n                  <ChevronDown className=\"h-3 w-3\" />\n                ) : (\n                  <ChevronRight className=\"h-3 w-3\" />\n                )}\n                <span>\n                  {isExpanded\n                    ? t('models.collapseModels')\n                    : t('models.expandModels')}\n                </span>\n              </CollapsibleTrigger>\n            ) : (\n              <div />\n            )}\n            {!isLangBotModels && (\n              <AddModelPopover\n                isOpen={addModelPopoverOpen === provider.uuid}\n                onOpen={onOpenAddModel}\n                onClose={onCloseAddModel}\n                onAddModel={onAddModel}\n                onTestModel={onTestModel}\n                isSubmitting={isSubmitting}\n                isTesting={isTesting}\n                testResult={testResult}\n                onResetTestResult={onResetTestResult}\n              />\n            )}\n          </div>\n        </CardHeader>\n        <CollapsibleContent>\n          <CardContent className=\"px-4 mt-2\">\n            {isLoading ? (\n              <p className=\"text-sm text-muted-foreground text-center py-4\">\n                {t('common.loading')}...\n              </p>\n            ) : models ? (\n              <div className=\"space-y-2\">\n                {models.llm.map((model) => (\n                  <ModelItem\n                    key={model.uuid}\n                    model={model}\n                    modelType=\"llm\"\n                    isLangBotModels={isLangBotModels}\n                    editModelPopoverOpen={editModelPopoverOpen}\n                    deleteConfirmOpen={deleteConfirmOpen}\n                    onOpenEditModel={onOpenEditModel}\n                    onCloseEditModel={onCloseEditModel}\n                    onOpenDeleteConfirm={onOpenDeleteConfirm}\n                    onCloseDeleteConfirm={onCloseDeleteConfirm}\n                    onDeleteModel={() => onDeleteModel(model.uuid, 'llm')}\n                    onUpdateModel={(name, abilities, extraArgs) =>\n                      onUpdateModel(\n                        model.uuid,\n                        'llm',\n                        name,\n                        abilities,\n                        extraArgs,\n                      )\n                    }\n                    onTestModel={(name, abilities, extraArgs) =>\n                      onTestModel(name, 'llm', abilities, extraArgs)\n                    }\n                    isSubmitting={isSubmitting}\n                    isTesting={isTesting}\n                    testResult={testResult}\n                    onResetTestResult={onResetTestResult}\n                  />\n                ))}\n                {models.embedding.map((model) => (\n                  <ModelItem\n                    key={model.uuid}\n                    model={model}\n                    modelType=\"embedding\"\n                    isLangBotModels={isLangBotModels}\n                    editModelPopoverOpen={editModelPopoverOpen}\n                    deleteConfirmOpen={deleteConfirmOpen}\n                    onOpenEditModel={onOpenEditModel}\n                    onCloseEditModel={onCloseEditModel}\n                    onOpenDeleteConfirm={onOpenDeleteConfirm}\n                    onCloseDeleteConfirm={onCloseDeleteConfirm}\n                    onDeleteModel={() => onDeleteModel(model.uuid, 'embedding')}\n                    onUpdateModel={(name, abilities, extraArgs) =>\n                      onUpdateModel(\n                        model.uuid,\n                        'embedding',\n                        name,\n                        abilities,\n                        extraArgs,\n                      )\n                    }\n                    onTestModel={(name, abilities, extraArgs) =>\n                      onTestModel(name, 'embedding', abilities, extraArgs)\n                    }\n                    isSubmitting={isSubmitting}\n                    isTesting={isTesting}\n                    testResult={testResult}\n                    onResetTestResult={onResetTestResult}\n                  />\n                ))}\n                {models.llm.length === 0 && models.embedding.length === 0 && (\n                  <p className=\"text-sm text-muted-foreground text-center py-4\">\n                    {t('models.noModels')}\n                  </p>\n                )}\n              </div>\n            ) : (\n              <p className=\"text-sm text-muted-foreground text-center py-4\">\n                {t('models.noModels')}\n              </p>\n            )}\n          </CardContent>\n        </CollapsibleContent>\n      </Collapsible>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/models-dialog/components/index.ts",
    "content": "export { default as ExtraArgsEditor } from './ExtraArgsEditor';\nexport { default as ModelItem } from './ModelItem';\nexport { default as AddModelPopover } from './AddModelPopover';\nexport { default as ProviderCard } from './ProviderCard';\n"
  },
  {
    "path": "web/src/app/home/components/models-dialog/types.ts",
    "content": "import {\n  LLMModel,\n  EmbeddingModel,\n  ModelProvider,\n} from '@/app/infra/entities/api';\n\nexport type ExtraArg = {\n  key: string;\n  type: 'string' | 'number' | 'boolean';\n  value: string;\n};\n\nexport type ModelType = 'llm' | 'embedding';\n\nexport interface ProviderModels {\n  llm: LLMModel[];\n  embedding: EmbeddingModel[];\n}\n\nexport interface TestResult {\n  success: boolean;\n  duration: number;\n}\n\nexport interface ModelItemProps {\n  model: LLMModel | EmbeddingModel;\n  modelType: ModelType;\n  providerUuid: string;\n  isLangBotModels: boolean;\n  isEditOpen: boolean;\n  isDeleteOpen: boolean;\n  onEditOpen: () => void;\n  onEditClose: () => void;\n  onDeleteOpen: () => void;\n  onDeleteClose: () => void;\n  onDelete: () => void;\n  onUpdate: (\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  onTest: (\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  isSubmitting: boolean;\n  isTesting: boolean;\n  testResult: TestResult | null;\n}\n\nexport interface ProviderCardProps {\n  provider: ModelProvider;\n  isLangBotModels?: boolean;\n  isExpanded: boolean;\n  isLoading: boolean;\n  models?: ProviderModels;\n  accountType: 'local' | 'space';\n  spaceCredits: number | null;\n  requesterNameList: { label: string; value: string }[];\n  // Popover states\n  addModelPopoverOpen: string | null;\n  editModelPopoverOpen: string | null;\n  deleteConfirmOpen: string | null;\n  // Handlers\n  onToggle: () => void;\n  onEditProvider: () => void;\n  onDeleteProvider: () => void;\n  onSpaceLogin: () => void;\n  onOpenAddModel: () => void;\n  onCloseAddModel: () => void;\n  onAddModel: (\n    modelType: ModelType,\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  onOpenEditModel: (modelId: string) => void;\n  onCloseEditModel: () => void;\n  onUpdateModel: (\n    modelId: string,\n    modelType: ModelType,\n    name: string,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  onOpenDeleteConfirm: (modelId: string) => void;\n  onCloseDeleteConfirm: () => void;\n  onDeleteModel: (modelId: string, modelType: ModelType) => Promise<void>;\n  onTestModel: (\n    name: string,\n    modelType: ModelType,\n    abilities: string[],\n    extraArgs: ExtraArg[],\n  ) => Promise<void>;\n  isSubmitting: boolean;\n  isTesting: boolean;\n  testResult: TestResult | null;\n  onResetTestResult: () => void;\n}\n\nexport const LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions';\n"
  },
  {
    "path": "web/src/app/home/components/new-version-dialog/NewVersionDialog.tsx",
    "content": "'use client';\n\nimport { useTranslation } from 'react-i18next';\nimport ReactMarkdown from 'react-markdown';\nimport remarkGfm from 'remark-gfm';\nimport rehypeRaw from 'rehype-raw';\nimport rehypeSanitize from 'rehype-sanitize';\nimport rehypeHighlight from 'rehype-highlight';\nimport i18n from 'i18next';\nimport { ExternalLink } from 'lucide-react';\n\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport '@/styles/github-markdown.css';\nimport { GitHubRelease } from '@/app/infra/http/CloudServiceClient';\n\ninterface NewVersionDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  release: GitHubRelease | null;\n}\n\nexport default function NewVersionDialog({\n  open,\n  onOpenChange,\n  release,\n}: NewVersionDialogProps) {\n  const { t } = useTranslation();\n\n  const getUpdateDocsUrl = () => {\n    const language = i18n.language;\n    if (language === 'zh-Hans' || language === 'zh-Hant') {\n      return 'https://docs.langbot.app/zh/deploy/update';\n    } else if (language === 'ja-JP') {\n      return 'https://docs.langbot.app/ja/deploy/update';\n    } else {\n      return 'https://docs.langbot.app/en/deploy/update';\n    }\n  };\n\n  const handleViewUpdateGuide = () => {\n    window.open(getUpdateDocsUrl(), '_blank');\n  };\n\n  if (!release) return null;\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-[600px] max-h-[80vh] flex flex-col\">\n        <DialogHeader className=\"flex-shrink-0\">\n          <DialogTitle className=\"flex items-center gap-2\">\n            {t('version.newVersionAvailable')}\n            <span className=\"text-primary font-mono\">{release.tag_name}</span>\n          </DialogTitle>\n        </DialogHeader>\n        <div className=\"flex-1 overflow-y-auto min-h-0 pr-2\">\n          <div className=\"markdown-body max-w-none text-sm\">\n            <ReactMarkdown\n              remarkPlugins={[remarkGfm]}\n              rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}\n              components={{\n                ul: ({ children }) => <ul className=\"list-disc\">{children}</ul>,\n                ol: ({ children }) => (\n                  <ol className=\"list-decimal\">{children}</ol>\n                ),\n                li: ({ children }) => <li className=\"ml-4\">{children}</li>,\n                a: ({ href, children }) => (\n                  <a\n                    href={href}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"text-primary hover:underline\"\n                  >\n                    {children}\n                  </a>\n                ),\n              }}\n            >\n              {release.body || t('version.noReleaseNotes')}\n            </ReactMarkdown>\n          </div>\n        </div>\n        <DialogFooter className=\"flex-shrink-0 flex flex-col sm:flex-row gap-2 pt-2\">\n          <Button\n            variant=\"outline\"\n            onClick={() => onOpenChange(false)}\n            className=\"w-full sm:w-auto\"\n          >\n            {t('common.close')}\n          </Button>\n          <Button\n            onClick={handleViewUpdateGuide}\n            className=\"w-full sm:w-auto flex items-center gap-2\"\n          >\n            {t('version.viewUpdateGuide')}\n            <ExternalLink className=\"w-4 h-4\" />\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/password-change-dialog/PasswordChangeDialog.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport { useState, useEffect } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { z } from 'zod';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from '@/components/ui/form';\nimport { Input } from '@/components/ui/input';\nimport { httpClient } from '@/app/infra/http/HttpClient';\n\ninterface PasswordChangeDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  hasPassword?: boolean;\n}\n\nexport default function PasswordChangeDialog({\n  open,\n  onOpenChange,\n  hasPassword = true,\n}: PasswordChangeDialogProps) {\n  const { t } = useTranslation();\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const getFormSchema = () =>\n    z\n      .object({\n        currentPassword: hasPassword\n          ? z.string().min(1, { message: t('common.currentPasswordRequired') })\n          : z.string().optional(),\n        newPassword: z\n          .string()\n          .min(1, { message: t('common.newPasswordRequired') }),\n        confirmNewPassword: z\n          .string()\n          .min(1, { message: t('common.confirmPasswordRequired') }),\n      })\n      .refine((data) => data.newPassword === data.confirmNewPassword, {\n        message: t('common.passwordsDoNotMatch'),\n        path: ['confirmNewPassword'],\n      });\n\n  const formSchema = getFormSchema();\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      currentPassword: '',\n      newPassword: '',\n      confirmNewPassword: '',\n    },\n  });\n\n  // Reset form when dialog opens/closes or hasPassword changes\n  useEffect(() => {\n    if (open) {\n      form.reset({\n        currentPassword: '',\n        newPassword: '',\n        confirmNewPassword: '',\n      });\n    }\n  }, [open, hasPassword, form]);\n\n  const onSubmit = async (values: z.infer<typeof formSchema>) => {\n    setIsSubmitting(true);\n    try {\n      if (hasPassword) {\n        await httpClient.changePassword(\n          values.currentPassword!,\n          values.newPassword,\n        );\n        toast.success(t('common.changePasswordSuccess'));\n      } else {\n        await httpClient.setPassword(values.newPassword, undefined);\n        toast.success(t('account.passwordSetSuccess'));\n      }\n      form.reset();\n      onOpenChange(false);\n    } catch {\n      toast.error(t('common.changePasswordFailed'));\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>\n            {hasPassword\n              ? t('common.changePassword')\n              : t('account.setPassword')}\n          </DialogTitle>\n        </DialogHeader>\n        <Form {...form}>\n          <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\n            {hasPassword && (\n              <FormField\n                control={form.control}\n                name=\"currentPassword\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t('common.currentPassword')}</FormLabel>\n                    <FormControl>\n                      <Input\n                        type=\"password\"\n                        placeholder={t('common.enterCurrentPassword')}\n                        {...field}\n                      />\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n            )}\n            <FormField\n              control={form.control}\n              name=\"newPassword\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>{t('common.newPassword')}</FormLabel>\n                  <FormControl>\n                    <Input\n                      type=\"password\"\n                      placeholder={t('common.enterNewPassword')}\n                      {...field}\n                    />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n            <FormField\n              control={form.control}\n              name=\"confirmNewPassword\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>{t('common.confirmNewPassword')}</FormLabel>\n                  <FormControl>\n                    <Input\n                      type=\"password\"\n                      placeholder={t('common.enterConfirmPassword')}\n                      {...field}\n                    />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n            <DialogFooter>\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                onClick={() => onOpenChange(false)}\n                disabled={isSubmitting}\n              >\n                {t('common.cancel')}\n              </Button>\n              <Button type=\"submit\" disabled={isSubmitting}>\n                {isSubmitting ? t('common.saving') : t('common.save')}\n              </Button>\n            </DialogFooter>\n          </form>\n        </Form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/components/survey/SurveyWidget.tsx",
    "content": "'use client';\n\nimport React, { useState, useEffect, useCallback } from 'react';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport type {\n  SurveyQuestion,\n  SurveyOption,\n} from '@/app/infra/http/BackendClient';\nimport { X, ChevronRight, ChevronLeft, MessageSquare } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\n\n/**\n * Get i18n text from a Record<string, string> based on browser locale.\n */\nfunction getI18nText(obj?: Record<string, string> | null): string {\n  if (!obj) return '';\n  const lang = typeof navigator !== 'undefined' ? navigator.language : 'en';\n  if (lang.startsWith('zh'))\n    return obj['zh_Hans'] || obj['en_US'] || Object.values(obj)[0] || '';\n  if (lang.startsWith('ja'))\n    return obj['ja_JP'] || obj['en_US'] || Object.values(obj)[0] || '';\n  return obj['en_US'] || Object.values(obj)[0] || '';\n}\n\ninterface SurveyData {\n  survey_id: string;\n  version: number;\n  title: Record<string, string>;\n  description: Record<string, string>;\n  questions: SurveyQuestion[];\n}\n\nexport default function SurveyWidget() {\n  const [survey, setSurvey] = useState<SurveyData | null>(null);\n  const [visible, setVisible] = useState(false);\n  const [currentStep, setCurrentStep] = useState(0);\n  const [answers, setAnswers] = useState<Record<string, unknown>>({});\n  const [otherInputs, setOtherInputs] = useState<Record<string, string>>({});\n  const [submitted, setSubmitted] = useState(false);\n  const [collapsed, setCollapsed] = useState(false);\n\n  // Poll for pending survey\n  useEffect(() => {\n    let timer: NodeJS.Timeout;\n    let cancelled = false;\n\n    const checkSurvey = async () => {\n      try {\n        const resp = await httpClient.getSurveyPending();\n        if (!cancelled && resp?.survey) {\n          setSurvey(resp.survey);\n          setVisible(true);\n        }\n      } catch {\n        // Silently ignore\n      }\n    };\n\n    // Check after 5 seconds, then every 60 seconds\n    timer = setTimeout(() => {\n      checkSurvey();\n      timer = setInterval(checkSurvey, 60000) as unknown as NodeJS.Timeout;\n    }, 5000);\n\n    return () => {\n      cancelled = true;\n      clearTimeout(timer);\n      clearInterval(timer);\n    };\n  }, []);\n\n  const handleDismiss = useCallback(async () => {\n    if (survey) {\n      try {\n        await httpClient.dismissSurvey(survey.survey_id);\n      } catch {\n        /* ignore */\n      }\n    }\n    setVisible(false);\n  }, [survey]);\n\n  const handleSubmit = useCallback(async () => {\n    if (!survey) return;\n\n    // Merge \"other\" text inputs into answers\n    const finalAnswers = { ...answers };\n    for (const [qId, text] of Object.entries(otherInputs)) {\n      if (text.trim()) {\n        const current = finalAnswers[qId];\n        if (Array.isArray(current)) {\n          // Replace 'other' with the text\n          finalAnswers[qId] = (current as string[]).map((v) =>\n            v === 'other' ? `other:${text}` : v,\n          );\n        } else if (current === 'other') {\n          finalAnswers[qId] = `other:${text}`;\n        }\n      }\n    }\n\n    try {\n      await httpClient.submitSurveyResponse(\n        survey.survey_id,\n        finalAnswers,\n        true,\n      );\n      setSubmitted(true);\n      setTimeout(() => setVisible(false), 2000);\n    } catch {\n      /* ignore */\n    }\n  }, [survey, answers, otherInputs]);\n\n  const setAnswer = useCallback((qId: string, value: unknown) => {\n    setAnswers((prev) => ({ ...prev, [qId]: value }));\n  }, []);\n\n  if (!visible || !survey) return null;\n\n  const questions = survey.questions || [];\n  const totalSteps = questions.length;\n  const currentQuestion = questions[currentStep];\n\n  if (submitted) {\n    return (\n      <div className=\"fixed bottom-6 right-6 z-50 w-80 bg-card border rounded-xl shadow-lg p-6 animate-in slide-in-from-bottom-4\">\n        <div className=\"text-center\">\n          <div className=\"text-3xl mb-2\">🎉</div>\n          <p className=\"text-sm font-medium\">\n            {getI18nText({\n              zh_Hans: '感谢你的反馈！',\n              en_US: 'Thanks for your feedback!',\n            })}\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  if (collapsed) {\n    return (\n      <button\n        onClick={() => setCollapsed(false)}\n        className=\"fixed bottom-6 right-6 z-50 w-12 h-12 bg-primary text-primary-foreground rounded-full shadow-lg flex items-center justify-center hover:scale-105 transition-transform\"\n      >\n        <MessageSquare className=\"w-5 h-5\" />\n      </button>\n    );\n  }\n\n  return (\n    <div className=\"fixed bottom-6 right-6 z-50 w-[340px] bg-card border rounded-xl shadow-lg animate-in slide-in-from-bottom-4\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between px-4 py-3 border-b\">\n        <div className=\"flex items-center gap-2\">\n          <MessageSquare className=\"w-4 h-4 text-primary\" />\n          <span className=\"text-sm font-medium\">\n            {getI18nText(survey.title)}\n          </span>\n        </div>\n        <div className=\"flex items-center gap-1\">\n          <button\n            onClick={() => setCollapsed(true)}\n            className=\"p-1 hover:bg-accent rounded\"\n          >\n            <ChevronRight className=\"w-4 h-4 text-muted-foreground\" />\n          </button>\n          <button\n            onClick={handleDismiss}\n            className=\"p-1 hover:bg-accent rounded\"\n          >\n            <X className=\"w-4 h-4 text-muted-foreground\" />\n          </button>\n        </div>\n      </div>\n\n      {/* Progress */}\n      <div className=\"px-4 pt-3\">\n        <div className=\"flex gap-1\">\n          {questions.map((_, i) => (\n            <div\n              key={i}\n              className={`h-1 flex-1 rounded-full transition-colors ${\n                i <= currentStep ? 'bg-primary' : 'bg-secondary'\n              }`}\n            />\n          ))}\n        </div>\n        <span className=\"text-xs text-muted-foreground mt-1 block\">\n          {currentStep + 1} / {totalSteps}\n        </span>\n      </div>\n\n      {/* Question */}\n      <div className=\"px-4 py-3\">\n        <p className=\"text-sm font-medium mb-1\">\n          {getI18nText(currentQuestion?.title)}\n        </p>\n        {currentQuestion?.subtitle && (\n          <p className=\"text-xs text-muted-foreground mb-3\">\n            {getI18nText(currentQuestion.subtitle)}\n          </p>\n        )}\n\n        <div className=\"space-y-2 max-h-[260px] overflow-y-auto\">\n          {currentQuestion?.type === 'single_select' &&\n            currentQuestion.options && (\n              <SingleSelectField\n                options={currentQuestion.options}\n                value={answers[currentQuestion.id] as string}\n                onChange={(v) => setAnswer(currentQuestion.id, v)}\n                otherText={otherInputs[currentQuestion.id] || ''}\n                onOtherChange={(t) =>\n                  setOtherInputs((prev) => ({\n                    ...prev,\n                    [currentQuestion.id]: t,\n                  }))\n                }\n              />\n            )}\n\n          {currentQuestion?.type === 'multi_select' &&\n            currentQuestion.options && (\n              <MultiSelectField\n                options={currentQuestion.options}\n                value={(answers[currentQuestion.id] as string[]) || []}\n                onChange={(v) => setAnswer(currentQuestion.id, v)}\n                otherText={otherInputs[currentQuestion.id] || ''}\n                onOtherChange={(t) =>\n                  setOtherInputs((prev) => ({\n                    ...prev,\n                    [currentQuestion.id]: t,\n                  }))\n                }\n              />\n            )}\n\n          {currentQuestion?.type === 'text' && (\n            <textarea\n              className=\"w-full h-20 text-sm border rounded-lg p-2 bg-background resize-none focus:outline-none focus:ring-1 focus:ring-primary\"\n              placeholder={getI18nText(currentQuestion.placeholder)}\n              maxLength={currentQuestion.max_length || 500}\n              value={(answers[currentQuestion.id] as string) || ''}\n              onChange={(e) => setAnswer(currentQuestion.id, e.target.value)}\n            />\n          )}\n        </div>\n      </div>\n\n      {/* Footer */}\n      <div className=\"flex items-center justify-between px-4 py-3 border-t\">\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={() => setCurrentStep(Math.max(0, currentStep - 1))}\n          disabled={currentStep === 0}\n        >\n          <ChevronLeft className=\"w-4 h-4\" />\n        </Button>\n\n        <div className=\"flex gap-2\">\n          {!currentQuestion?.required && currentStep < totalSteps - 1 && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={() => setCurrentStep(currentStep + 1)}\n            >\n              {getI18nText({ zh_Hans: '跳过', en_US: 'Skip' })}\n            </Button>\n          )}\n\n          {currentStep < totalSteps - 1 ? (\n            <Button\n              size=\"sm\"\n              onClick={() => setCurrentStep(currentStep + 1)}\n              disabled={\n                currentQuestion?.required && !answers[currentQuestion?.id]\n              }\n            >\n              {getI18nText({ zh_Hans: '下一题', en_US: 'Next' })}\n            </Button>\n          ) : (\n            <Button size=\"sm\" onClick={handleSubmit}>\n              {getI18nText({ zh_Hans: '提交', en_US: 'Submit' })}\n            </Button>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\n// ---- Sub-components for flat radio/checkbox style ----\n\nfunction SingleSelectField({\n  options,\n  value,\n  onChange,\n  otherText,\n  onOtherChange,\n}: {\n  options: SurveyOption[];\n  value?: string;\n  onChange: (v: string) => void;\n  otherText: string;\n  onOtherChange: (t: string) => void;\n}) {\n  return (\n    <div className=\"space-y-1.5\">\n      {options.map((opt) => (\n        <div key={opt.id}>\n          <button\n            onClick={() => onChange(opt.id)}\n            className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors ${\n              value === opt.id\n                ? 'border-primary bg-primary/5 text-primary'\n                : 'border-border hover:bg-accent'\n            }`}\n          >\n            {getI18nText(opt.label)}\n          </button>\n          {opt.has_input && value === opt.id && (\n            <input\n              type=\"text\"\n              className=\"mt-1 w-full text-sm border rounded-lg px-3 py-1.5 bg-background focus:outline-none focus:ring-1 focus:ring-primary\"\n              placeholder=\"...\"\n              value={otherText}\n              onChange={(e) => onOtherChange(e.target.value)}\n            />\n          )}\n        </div>\n      ))}\n    </div>\n  );\n}\n\nfunction MultiSelectField({\n  options,\n  value,\n  onChange,\n  otherText,\n  onOtherChange,\n}: {\n  options: SurveyOption[];\n  value: string[];\n  onChange: (v: string[]) => void;\n  otherText: string;\n  onOtherChange: (t: string) => void;\n}) {\n  const toggle = (id: string) => {\n    if (value.includes(id)) {\n      onChange(value.filter((v) => v !== id));\n    } else {\n      onChange([...value, id]);\n    }\n  };\n\n  return (\n    <div className=\"space-y-1.5\">\n      {options.map((opt) => {\n        const selected = value.includes(opt.id);\n        return (\n          <div key={opt.id}>\n            <button\n              onClick={() => toggle(opt.id)}\n              className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors flex items-center gap-2 ${\n                selected\n                  ? 'border-primary bg-primary/5 text-primary'\n                  : 'border-border hover:bg-accent'\n              }`}\n            >\n              <Checkbox checked={selected} className=\"pointer-events-none\" />\n              {getI18nText(opt.label)}\n            </button>\n            {opt.has_input && selected && (\n              <input\n                type=\"text\"\n                className=\"mt-1 w-full text-sm border rounded-lg px-3 py-1.5 bg-background focus:outline-none focus:ring-1 focus:ring-primary\"\n                placeholder=\"...\"\n                value={otherText}\n                onChange={(e) => onOtherChange(e.target.value)}\n              />\n            )}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/KBDetailDialog.tsx",
    "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarProvider,\n} from '@/components/ui/sidebar';\nimport { Button } from '@/components/ui/button';\nimport { useTranslation } from 'react-i18next';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { KnowledgeBase } from '@/app/infra/entities/api';\nimport { CustomApiError } from '@/app/infra/entities/common';\nimport { toast } from 'sonner';\nimport KBForm from '@/app/home/knowledge/components/kb-form/KBForm';\nimport KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';\nimport KBRetrieveGeneric from '@/app/home/knowledge/components/kb-retrieve/KBRetrieveGeneric';\n\ninterface KBDetailDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  kbId?: string;\n  onFormCancel: () => void;\n  onKbDeleted: () => void;\n  onNewKbCreated: (kbId: string) => void;\n  onKbUpdated: (kbId: string) => void;\n}\n\nexport default function KBDetailDialog({\n  open,\n  onOpenChange,\n  kbId: propKbId,\n  onFormCancel,\n  onKbDeleted,\n  onNewKbCreated,\n  onKbUpdated,\n}: KBDetailDialogProps) {\n  const { t } = useTranslation();\n  const [kbId, setKbId] = useState<string | undefined>(propKbId);\n  const [activeMenu, setActiveMenu] = useState('metadata');\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n  const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);\n\n  useEffect(() => {\n    setKbId(propKbId);\n    setActiveMenu('metadata');\n    if (propKbId) {\n      loadKbInfo(propKbId);\n    } else {\n      setKbInfo(null);\n    }\n  }, [propKbId, open]);\n\n  async function loadKbInfo(id: string) {\n    try {\n      const resp = await httpClient.getKnowledgeBase(id);\n      setKbInfo(resp.base);\n    } catch (e) {\n      console.error('Failed to load KB info:', e);\n      toast.error(\n        t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,\n      );\n    }\n  }\n\n  // Check if this KB supports document management\n  const hasDocumentCapability = (): boolean => {\n    if (!kbInfo || !kbInfo.knowledge_engine) {\n      return false;\n    }\n    return (\n      kbInfo.knowledge_engine.capabilities?.includes('doc_ingestion') ?? false\n    );\n  };\n\n  // Build menu based on KB capabilities\n  const menu = [\n    {\n      key: 'metadata',\n      label: t('knowledge.metadata'),\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z\"></path>\n        </svg>\n      ),\n    },\n    // Show documents only if capability is present\n    ...(hasDocumentCapability()\n      ? [\n          {\n            key: 'documents',\n            label: t('knowledge.documents'),\n            icon: (\n              <svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                viewBox=\"0 0 24 24\"\n                fill=\"currentColor\"\n              >\n                <path d=\"M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z\"></path>\n              </svg>\n            ),\n          },\n        ]\n      : []),\n    {\n      key: 'retrieve',\n      label: t('knowledge.retrieve'),\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M18.031 16.617l4.283 4.282-1.415 1.415-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9 9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617zm-2.006-.742A6.977 6.977 0 0 0 18 11c0-3.868-3.133-7-7-7-3.868 0-7 3.132-7 7 0 3.867 3.132 7 7 7a6.977 6.977 0 0 0 4.875-1.975l.15-.15z\"></path>\n        </svg>\n      ),\n    },\n  ];\n\n  const confirmDelete = async () => {\n    try {\n      await httpClient.deleteKnowledgeBase(kbId ?? '');\n      onKbDeleted();\n    } catch (e) {\n      console.error('Failed to delete KB:', e);\n      toast.error(\n        t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,\n      );\n    } finally {\n      setShowDeleteConfirm(false);\n    }\n  };\n\n  // Retrieve function\n  const retrieveFunction = async (id: string, query: string) => {\n    return await httpClient.retrieveKnowledgeBase(id, query);\n  };\n\n  if (!kbId) {\n    // New KB creation\n    return (\n      <Dialog open={open} onOpenChange={onOpenChange}>\n        <DialogContent className=\"overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex\">\n          <main className=\"flex flex-1 flex-col h-[70vh]\">\n            <DialogHeader className=\"px-6 pt-6 pb-4 shrink-0\">\n              <DialogTitle>{t('knowledge.createKnowledgeBase')}</DialogTitle>\n            </DialogHeader>\n            <div className=\"flex-1 overflow-y-auto px-6 pb-6\">\n              <KBForm\n                initKbId={undefined}\n                onNewKbCreated={onNewKbCreated}\n                onKbUpdated={onKbUpdated}\n              />\n            </div>\n            <DialogFooter className=\"px-6 py-4 border-t shrink-0\">\n              <div className=\"flex justify-end gap-2\">\n                <Button type=\"submit\" form=\"kb-form\">\n                  {t('common.save')}\n                </Button>\n                <Button type=\"button\" variant=\"outline\" onClick={onFormCancel}>\n                  {t('common.cancel')}\n                </Button>\n              </div>\n            </DialogFooter>\n          </main>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={onOpenChange}>\n        <DialogContent className=\"overflow-hidden p-0 !max-w-[50rem] max-h-[75vh] flex\">\n          <SidebarProvider className=\"items-start w-full flex\">\n            <Sidebar\n              collapsible=\"none\"\n              className=\"hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white dark:bg-black\"\n            >\n              <SidebarContent>\n                <SidebarGroup>\n                  <SidebarGroupContent>\n                    <SidebarMenu>\n                      {menu.map((item) => (\n                        <SidebarMenuItem key={item.key}>\n                          <SidebarMenuButton\n                            asChild\n                            isActive={activeMenu === item.key}\n                            onClick={() => setActiveMenu(item.key)}\n                          >\n                            <a href=\"#\">\n                              {item.icon}\n                              <span>{item.label}</span>\n                            </a>\n                          </SidebarMenuButton>\n                        </SidebarMenuItem>\n                      ))}\n                    </SidebarMenu>\n                  </SidebarGroupContent>\n                </SidebarGroup>\n              </SidebarContent>\n            </Sidebar>\n            <main className=\"flex flex-1 flex-col h-[75vh] min-w-0 overflow-x-hidden\">\n              <DialogHeader className=\"px-6 pt-6 pb-4 shrink-0\">\n                <DialogTitle>\n                  {activeMenu === 'metadata'\n                    ? t('knowledge.editKnowledgeBase')\n                    : activeMenu === 'documents'\n                      ? t('knowledge.editDocument')\n                      : t('knowledge.retrieveTest')}\n                </DialogTitle>\n              </DialogHeader>\n              <div className=\"flex-1 overflow-y-auto px-6 pb-6\">\n                {activeMenu === 'metadata' && (\n                  <KBForm\n                    initKbId={kbId}\n                    onNewKbCreated={onNewKbCreated}\n                    onKbUpdated={onKbUpdated}\n                  />\n                )}\n                {activeMenu === 'documents' && hasDocumentCapability() && (\n                  <KBDoc\n                    kbId={kbId}\n                    ragEngineName={kbInfo?.knowledge_engine?.name}\n                    ragEngineCapabilities={\n                      kbInfo?.knowledge_engine?.capabilities\n                    }\n                  />\n                )}\n                {activeMenu === 'retrieve' && (\n                  <KBRetrieveGeneric\n                    kbId={kbId}\n                    retrieveFunction={retrieveFunction}\n                  />\n                )}\n              </div>\n              {activeMenu === 'metadata' && (\n                <DialogFooter className=\"px-6 py-4 border-t shrink-0\">\n                  <div className=\"flex justify-end gap-2\">\n                    <Button\n                      type=\"button\"\n                      variant=\"destructive\"\n                      onClick={() => setShowDeleteConfirm(true)}\n                    >\n                      {t('common.delete')}\n                    </Button>\n                    <Button type=\"submit\" form=\"kb-form\">\n                      {t('common.save')}\n                    </Button>\n                    <Button\n                      type=\"button\"\n                      variant=\"outline\"\n                      onClick={onFormCancel}\n                    >\n                      {t('common.cancel')}\n                    </Button>\n                  </div>\n                </DialogFooter>\n              )}\n            </main>\n          </SidebarProvider>\n        </DialogContent>\n      </Dialog>\n\n      {/* Delete confirmation dialog */}\n      <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>{t('common.confirmDelete')}</DialogTitle>\n          </DialogHeader>\n          <div className=\"py-4\">\n            {t('knowledge.deleteKnowledgeBaseConfirmation')}\n          </div>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setShowDeleteConfirm(false)}\n            >\n              {t('common.cancel')}\n            </Button>\n            <Button variant=\"destructive\" onClick={confirmDelete}>\n              {t('common.confirmDelete')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-card/KBCard.module.css",
    "content": ".cardContainer {\n  width: 100%;\n  height: 10rem;\n  background-color: #fff;\n  border-radius: 10px;\n  box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);\n  padding: 1rem;\n  cursor: pointer;\n  display: flex;\n  flex-direction: row;\n  justify-content: space-between;\n  gap: 0.5rem;\n  transition: all 0.2s ease;\n}\n\n:global(.dark) .cardContainer {\n  background-color: #1f1f22;\n  box-shadow: 0;\n}\n\n.cardContainer:hover {\n  box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);\n}\n\n:global(.dark) .cardContainer:hover {\n  box-shadow: 0;\n}\n\n.basicInfoContainer {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  gap: 0.5rem;\n  min-width: 0;\n}\n\n.iconEmoji {\n  width: 3rem;\n  height: 3rem;\n  border-radius: 0.5rem;\n  background-color: #f5f5f5;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 1.75rem;\n  flex-shrink: 0;\n}\n\n:global(.dark) .iconEmoji {\n  background-color: #2a2a2d;\n}\n\n.iconBasicInfoContainer {\n  display: flex;\n  flex-direction: row;\n  gap: 0.75rem;\n  align-items: flex-start;\n  min-width: 0;\n  flex: 1;\n}\n\n.basicInfoNameContainer {\n  display: flex;\n  flex-direction: column;\n  gap: 0.2rem;\n  min-width: 0;\n  flex: 1;\n}\n\n.basicInfoNameText {\n  font-size: 1.4rem;\n  font-weight: 500;\n  color: #1a1a1a;\n}\n\n:global(.dark) .basicInfoNameText {\n  color: #f0f0f0;\n}\n\n.basicInfoDescriptionText {\n  font-size: 0.9rem;\n  font-weight: 400;\n  display: -webkit-box;\n  -webkit-line-clamp: 3;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  color: #b1b1b1;\n}\n\n:global(.dark) .basicInfoDescriptionText {\n  color: #888888;\n}\n\n.basicInfoLastUpdatedTimeContainer {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  gap: 0.5rem;\n}\n\n.basicInfoUpdateTimeIcon {\n  width: 1.2rem;\n  height: 1.2rem;\n  color: #626262;\n}\n\n:global(.dark) .basicInfoUpdateTimeIcon {\n  color: #a0a0a0;\n}\n\n.basicInfoUpdateTimeText {\n  font-size: 1rem;\n  font-weight: 400;\n  color: #626262;\n}\n\n:global(.dark) .basicInfoUpdateTimeText {\n  color: #a0a0a0;\n}\n\n.operationContainer {\n  display: flex;\n  flex-direction: column;\n  align-items: flex-end;\n  justify-content: space-between;\n  gap: 0.5rem;\n  width: 8rem;\n}\n\n.operationDefaultBadge {\n  display: flex;\n  flex-direction: row;\n  gap: 0.5rem;\n}\n\n.operationDefaultBadgeIcon {\n  width: 1.2rem;\n  height: 1.2rem;\n  color: #ffcd27;\n}\n\n:global(.dark) .operationDefaultBadgeIcon {\n  color: #fbbf24;\n}\n\n.operationDefaultBadgeText {\n  font-size: 1rem;\n  font-weight: 400;\n  color: #ffcd27;\n}\n\n:global(.dark) .operationDefaultBadgeText {\n  color: #fbbf24;\n}\n\n.bigText {\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  font-size: 1.4rem;\n  font-weight: bold;\n  max-width: 100%;\n}\n\n.debugButtonIcon {\n  width: 1.2rem;\n  height: 1.2rem;\n}\n\n.engineBadge {\n  font-size: 0.75rem;\n  line-height: 1rem;\n  padding: 0.125rem 0.5rem;\n  border-radius: 9999px;\n  background-color: #f3e8ff;\n  color: #7e22ce;\n  white-space: nowrap;\n}\n\n:global(.dark) .engineBadge {\n  background-color: #581c87;\n  color: #d8b4fe;\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-card/KBCard.tsx",
    "content": "import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';\nimport { useTranslation } from 'react-i18next';\nimport styles from './KBCard.module.css';\n\nexport default function KBCard({ kbCardVO }: { kbCardVO: KnowledgeBaseVO }) {\n  const { t } = useTranslation();\n\n  return (\n    <div className={`${styles.cardContainer}`}>\n      <div className={`${styles.basicInfoContainer}`}>\n        <div className={`${styles.iconBasicInfoContainer}`}>\n          <div className={`${styles.iconEmoji}`}>{kbCardVO.emoji || '📚'}</div>\n          <div className={`${styles.basicInfoNameContainer}`}>\n            <div className=\"flex items-center gap-2\">\n              <div className={`${styles.basicInfoNameText} ${styles.bigText}`}>\n                {kbCardVO.name}\n              </div>\n              {/* Engine badge */}\n              <span className={styles.engineBadge}>\n                {kbCardVO.getEngineName()}\n              </span>\n            </div>\n            <div className={`${styles.basicInfoDescriptionText}`}>\n              {kbCardVO.description}\n            </div>\n          </div>\n        </div>\n\n        <div className={`${styles.basicInfoLastUpdatedTimeContainer}`}>\n          <svg\n            className={`${styles.basicInfoUpdateTimeIcon}`}\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"currentColor\"\n          >\n            <path d=\"M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z\"></path>\n          </svg>\n          <div className={`${styles.basicInfoUpdateTimeText}`}>\n            {t('knowledge.updateTime')}\n            {kbCardVO.lastUpdatedTimeAgo}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-card/KBCardVO.ts",
    "content": "import { KnowledgeEngineInfo } from '@/app/infra/entities/api';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\n\nexport interface IKnowledgeBaseVO {\n  id: string;\n  name: string;\n  description: string;\n  lastUpdatedTimeAgo: string;\n  emoji?: string;\n  ragEngine?: KnowledgeEngineInfo;\n  ragEnginePluginId?: string;\n}\n\nexport class KnowledgeBaseVO implements IKnowledgeBaseVO {\n  id: string;\n  name: string;\n  description: string;\n  lastUpdatedTimeAgo: string;\n  emoji?: string;\n  ragEngine?: KnowledgeEngineInfo;\n  ragEnginePluginId?: string;\n\n  constructor(props: IKnowledgeBaseVO) {\n    this.id = props.id;\n    this.name = props.name;\n    this.description = props.description;\n    this.lastUpdatedTimeAgo = props.lastUpdatedTimeAgo;\n    this.emoji = props.emoji;\n    this.ragEngine = props.ragEngine;\n    this.ragEnginePluginId = props.ragEnginePluginId;\n  }\n\n  /**\n   * Check if this KB supports document management\n   */\n  hasDocumentCapability(): boolean {\n    if (!this.ragEngine) {\n      return false;\n    }\n    return this.ragEngine.capabilities.includes('doc_ingestion');\n  }\n\n  /**\n   * Get display name for the Knowledge Engine\n   */\n  getEngineName(): string {\n    if (!this.ragEngine) {\n      return 'Unknown';\n    }\n    return extractI18nObject(this.ragEngine.name);\n  }\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx",
    "content": "import React, { useCallback, useEffect, useState } from 'react';\nimport { Card, CardContent } from '@/components/ui/card';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { Button } from '@/components/ui/button';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport { ParserInfo } from '@/app/infra/entities/api';\nimport { CustomApiError, I18nObject } from '@/app/infra/entities/common';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\n\ninterface FileUploadZoneProps {\n  kbId: string;\n  ragEngineName?: I18nObject;\n  ragEngineCapabilities?: string[];\n  onUploadSuccess: () => void;\n  onUploadError: (error: string) => void;\n}\n\nexport default function FileUploadZone({\n  kbId,\n  ragEngineName,\n  ragEngineCapabilities,\n  onUploadSuccess,\n  onUploadError,\n}: FileUploadZoneProps) {\n  const { t } = useTranslation();\n  const [isDragOver, setIsDragOver] = useState(false);\n  const [isUploading, setIsUploading] = useState(false);\n\n  // Parser selection state\n  const [pendingFile, setPendingFile] = useState<File | null>(null);\n  const [availableParsers, setAvailableParsers] = useState<ParserInfo[]>([]);\n  const [selectedParser, setSelectedParser] = useState<string>('builtin');\n  const [loadingParsers, setLoadingParsers] = useState(false);\n\n  // Whether the Knowledge Engine natively supports document parsing.\n  // This is a coarse-grained capability check rather than per-MIME-type filtering.\n  // Fine-grained MIME type declaration (e.g. supported_parse_mime_types on the engine)\n  // would require changes across the SDK, backend, and frontend prop chain;\n  // using an engine-level capability flag keeps the change minimal.\n  const ragEngineCanParse =\n    ragEngineCapabilities?.includes('doc_parsing') ?? false;\n\n  // When a file is selected, check for available parsers\n  useEffect(() => {\n    if (!pendingFile) return;\n\n    const mimeType = pendingFile.type || undefined;\n    setLoadingParsers(true);\n    httpClient\n      .listParsers(mimeType)\n      .then((resp) => {\n        const parsers = resp.parsers || [];\n        setAvailableParsers(parsers);\n        if (ragEngineCanParse) {\n          setSelectedParser('builtin');\n        } else if (parsers.length > 0) {\n          setSelectedParser(parsers[0].plugin_id);\n        } else {\n          setSelectedParser('');\n        }\n      })\n      .catch(() => {\n        setAvailableParsers([]);\n      })\n      .finally(() => {\n        setLoadingParsers(false);\n      });\n  }, [pendingFile, ragEngineCanParse]);\n\n  const doUpload = useCallback(\n    async (file: File, parserPluginId?: string) => {\n      setIsUploading(true);\n      const toastId = toast.loading(t('knowledge.documentsTab.uploadingFile'));\n\n      try {\n        // Step 1: Upload file to server\n        const uploadResult = await httpClient.uploadDocumentFile(file);\n\n        // Step 2: Associate file with knowledge base (with optional parser)\n        await httpClient.uploadKnowledgeBaseFile(\n          kbId,\n          uploadResult.file_id,\n          parserPluginId,\n        );\n\n        toast.success(t('knowledge.documentsTab.uploadSuccess'), {\n          id: toastId,\n        });\n        onUploadSuccess();\n      } catch (error) {\n        console.error('File upload failed:', error);\n        const errorMessage =\n          t('knowledge.documentsTab.uploadError') +\n          (error as CustomApiError).msg;\n        toast.error(errorMessage, { id: toastId });\n        onUploadError(errorMessage);\n      } finally {\n        setIsUploading(false);\n        setPendingFile(null);\n        setAvailableParsers([]);\n        setSelectedParser('builtin');\n      }\n    },\n    [kbId, onUploadSuccess, onUploadError, t],\n  );\n\n  const handleFileSelected = useCallback(\n    async (file: File) => {\n      if (isUploading) return;\n\n      // Check file size (10MB limit)\n      const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB\n      if (file.size > MAX_FILE_SIZE) {\n        toast.error(t('knowledge.documentsTab.fileSizeExceeded'));\n        return;\n      }\n\n      // Set loadingParsers=true BEFORE pendingFile so both state updates\n      // batch together in the same render. This prevents the auto-upload\n      // effect from firing before parser fetch completes.\n      setLoadingParsers(true);\n      setPendingFile(file);\n    },\n    [isUploading, t],\n  );\n\n  // Auto-upload if Knowledge Engine can parse and no external parsers available\n  useEffect(() => {\n    if (\n      pendingFile &&\n      !loadingParsers &&\n      ragEngineCanParse &&\n      availableParsers.length === 0\n    ) {\n      doUpload(pendingFile);\n    }\n  }, [\n    pendingFile,\n    loadingParsers,\n    ragEngineCanParse,\n    availableParsers,\n    doUpload,\n  ]);\n\n  const handleConfirmUpload = useCallback(() => {\n    if (!pendingFile) return;\n    const parserPluginId =\n      selectedParser === 'builtin' ? undefined : selectedParser;\n    doUpload(pendingFile, parserPluginId);\n  }, [pendingFile, selectedParser, doUpload]);\n\n  const handleCancelUpload = useCallback(() => {\n    setPendingFile(null);\n    setAvailableParsers([]);\n    setSelectedParser('builtin');\n  }, []);\n\n  const handleDragOver = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragOver(true);\n  }, []);\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragOver(false);\n  }, []);\n\n  const handleDrop = useCallback(\n    (e: React.DragEvent) => {\n      e.preventDefault();\n      setIsDragOver(false);\n\n      const files = Array.from(e.dataTransfer.files);\n      if (files.length > 0) {\n        handleFileSelected(files[0]);\n      }\n    },\n    [handleFileSelected],\n  );\n\n  const handleFileSelect = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const files = e.target.files;\n      if (files && files.length > 0) {\n        handleFileSelected(files[0]);\n      }\n      // Reset the input so the same file can be selected again\n      e.target.value = '';\n    },\n    [handleFileSelected],\n  );\n\n  // Show parser selection UI when there are choices to make, or when no parser is available\n  const showParserSelector =\n    pendingFile &&\n    !loadingParsers &&\n    (availableParsers.length > 0 || !ragEngineCanParse);\n\n  const noParserAvailable = !ragEngineCanParse && availableParsers.length === 0;\n\n  return (\n    <Card className=\"mb-4\">\n      <CardContent className=\"p-4\">\n        {showParserSelector ? (\n          <div className=\"space-y-3\">\n            <p className=\"text-sm font-medium text-gray-900 dark:text-gray-100\">\n              {pendingFile.name}\n            </p>\n            {noParserAvailable ? (\n              <div className=\"rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3\">\n                <p className=\"text-sm text-yellow-800 dark:text-yellow-200\">\n                  {t('knowledge.documentsTab.noParserAvailable')}\n                </p>\n              </div>\n            ) : (\n              <div className=\"space-y-2\">\n                <label className=\"text-sm text-gray-600 dark:text-gray-400\">\n                  {t('knowledge.documentsTab.selectParser')}\n                </label>\n                <Select\n                  value={selectedParser}\n                  onValueChange={setSelectedParser}\n                >\n                  <SelectTrigger className=\"w-full\">\n                    <SelectValue />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {ragEngineCanParse && (\n                      <SelectItem value=\"builtin\">\n                        {ragEngineName\n                          ? extractI18nObject(ragEngineName)\n                          : t('knowledge.documentsTab.builtInParser')}\n                      </SelectItem>\n                    )}\n                    {availableParsers.map((parser) => (\n                      <SelectItem\n                        key={parser.plugin_id}\n                        value={parser.plugin_id}\n                      >\n                        {extractI18nObject(parser.name)}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n              </div>\n            )}\n            <div className=\"flex justify-end gap-2\">\n              <Button variant=\"outline\" size=\"sm\" onClick={handleCancelUpload}>\n                {t('knowledge.documentsTab.cancelUpload')}\n              </Button>\n              {!noParserAvailable && (\n                <Button size=\"sm\" onClick={handleConfirmUpload}>\n                  {t('knowledge.documentsTab.confirmUpload')}\n                </Button>\n              )}\n            </div>\n          </div>\n        ) : (\n          <div\n            className={`\n              relative border-2 border-dashed rounded-lg p-4 text-center transition-colors\n              ${\n                isDragOver\n                  ? 'border-blue-500 bg-blue-50'\n                  : 'border-gray-300 hover:border-gray-400'\n              }\n              ${isUploading || loadingParsers ? 'opacity-50 pointer-events-none' : ''}\n            `}\n            onDragOver={handleDragOver}\n            onDragLeave={handleDragLeave}\n            onDrop={handleDrop}\n          >\n            <input\n              type=\"file\"\n              id=\"file-upload\"\n              className=\"hidden\"\n              onChange={handleFileSelect}\n              accept=\".pdf,.doc,.docx,.txt,.md,.html,.zip\"\n              disabled={isUploading || loadingParsers}\n            />\n\n            <label htmlFor=\"file-upload\" className=\"cursor-pointer block\">\n              <div className=\"space-y-2\">\n                <div className=\"mx-auto w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center\">\n                  <svg\n                    className=\"w-5 h-5 text-gray-400\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    viewBox=\"0 0 24 24\"\n                  >\n                    <path\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth={2}\n                      d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12\"\n                    />\n                  </svg>\n                </div>\n\n                <div>\n                  <p className=\"text-base font-medium text-gray-900 dark:text-gray-100\">\n                    {isUploading\n                      ? t('knowledge.documentsTab.uploading')\n                      : t('knowledge.documentsTab.dragAndDrop')}\n                  </p>\n                  <p className=\"text-xs text-gray-500 mt-1 dark:text-gray-400\">\n                    {t('knowledge.documentsTab.supportedFormats')}\n                  </p>\n                </div>\n              </div>\n            </label>\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-docs/KBDoc.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { KnowledgeBaseFile } from '@/app/infra/entities/api';\nimport { I18nObject, CustomApiError } from '@/app/infra/entities/common';\nimport { columns, DocumentFile } from './documents/columns';\nimport { DataTable } from './documents/data-table';\nimport FileUploadZone from './FileUploadZone';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\n\nexport default function KBDoc({\n  kbId,\n  ragEngineName,\n  ragEngineCapabilities,\n}: {\n  kbId: string;\n  ragEngineName?: I18nObject;\n  ragEngineCapabilities?: string[];\n}) {\n  const [documentsList, setDocumentsList] = useState<DocumentFile[]>([]);\n  const { t } = useTranslation();\n  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);\n\n  const getDocumentsList = useCallback(async () => {\n    const resp = await httpClient.getKnowledgeBaseFiles(kbId);\n    const files = resp.files.map((file: KnowledgeBaseFile) => ({\n      uuid: file.uuid,\n      name: file.file_name,\n      status: file.status,\n    }));\n    setDocumentsList(files);\n    return files;\n  }, [kbId]);\n\n  const startPolling = useCallback(() => {\n    if (intervalRef.current) return;\n    intervalRef.current = setInterval(() => {\n      getDocumentsList().then((files) => {\n        const allDone =\n          files.length > 0 &&\n          files.every(\n            (doc: DocumentFile) =>\n              doc.status === 'completed' || doc.status === 'failed',\n          );\n        if (allDone && intervalRef.current) {\n          clearInterval(intervalRef.current);\n          intervalRef.current = null;\n        }\n      });\n    }, 5000);\n  }, [getDocumentsList]);\n\n  useEffect(() => {\n    getDocumentsList().then((files) => {\n      const hasProcessing = files.some(\n        (doc: DocumentFile) =>\n          doc.status !== 'completed' && doc.status !== 'failed',\n      );\n      if (hasProcessing) {\n        startPolling();\n      }\n    });\n\n    return () => {\n      if (intervalRef.current) {\n        clearInterval(intervalRef.current);\n        intervalRef.current = null;\n      }\n    };\n  }, [kbId, getDocumentsList, startPolling]);\n\n  const handleUploadSuccess = () => {\n    getDocumentsList();\n    startPolling();\n  };\n\n  const handleUploadError = (error: string) => {\n    console.error('Upload failed:', error);\n  };\n\n  const handleDelete = (id: string) => {\n    httpClient\n      .deleteKnowledgeBaseFile(kbId, id)\n      .then(() => {\n        getDocumentsList();\n        toast.success(t('knowledge.documentsTab.fileDeleteSuccess'));\n      })\n      .catch((error) => {\n        console.error('Delete failed:', error);\n        toast.error(\n          t('knowledge.documentsTab.fileDeleteFailed') +\n            (error as CustomApiError).msg,\n        );\n      });\n  };\n\n  return (\n    <div className=\"container mx-auto py-2\">\n      <FileUploadZone\n        kbId={kbId}\n        ragEngineName={ragEngineName}\n        ragEngineCapabilities={ragEngineCapabilities}\n        onUploadSuccess={handleUploadSuccess}\n        onUploadError={handleUploadError}\n      />\n      <DataTable columns={columns(handleDelete, t)} data={documentsList} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-docs/documents/columns.tsx",
    "content": "'use client';\n\nimport { ColumnDef } from '@tanstack/react-table';\nimport { MoreHorizontal } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport { Badge } from '@/components/ui/badge';\nimport { TFunction } from 'i18next';\n\nexport type DocumentFile = {\n  uuid: string;\n  name: string;\n  status: string;\n};\n\nexport const columns = (\n  onDelete: (id: string) => void,\n  t: TFunction,\n): ColumnDef<DocumentFile>[] => {\n  return [\n    {\n      accessorKey: 'name',\n      header: t('knowledge.documentsTab.name'),\n    },\n    {\n      accessorKey: 'status',\n      header: t('knowledge.documentsTab.status'),\n      cell: ({ row }) => {\n        const document = row.original;\n\n        switch (document.status) {\n          case 'processing':\n            return (\n              <Badge variant=\"secondary\">\n                {t('knowledge.documentsTab.processing')}\n              </Badge>\n            );\n          case 'completed':\n            return (\n              <Badge variant=\"outline\" className=\"bg-blue-500 text-white\">\n                {t('knowledge.documentsTab.completed')}\n              </Badge>\n            );\n          case 'failed':\n            return (\n              <Badge variant=\"outline\" className=\"bg-yellow-500 text-white\">\n                {t('knowledge.documentsTab.failed')}\n              </Badge>\n            );\n          default:\n            return (\n              <Badge variant=\"outline\" className=\"bg-gray-500 text-white\">\n                {document.status}\n              </Badge>\n            );\n        }\n      },\n    },\n    {\n      id: 'actions',\n      cell: ({ row }) => {\n        const document = row.original;\n\n        return (\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button variant=\"ghost\" className=\"h-8 w-8 p-0\">\n                <span className=\"sr-only\">\n                  {t('knowledge.documentsTab.actions')}\n                </span>\n                <MoreHorizontal className=\"h-4 w-4\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent\n              align=\"end\"\n              className=\"bg-white dark:bg-[#2a2a2e]\"\n            >\n              <DropdownMenuLabel>\n                {t('knowledge.documentsTab.actions')}\n              </DropdownMenuLabel>\n\n              <DropdownMenuItem onClick={() => onDelete(document.uuid)}>\n                {t('knowledge.documentsTab.delete')}\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        );\n      },\n    },\n  ];\n};\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-docs/documents/data-table.tsx",
    "content": "'use client';\n\nimport {\n  ColumnDef,\n  flexRender,\n  getCoreRowModel,\n  useReactTable,\n} from '@tanstack/react-table';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@/components/ui/table';\nimport { useTranslation } from 'react-i18next';\n\ninterface DataTableProps<TData, TValue> {\n  columns: ColumnDef<TData, TValue>[];\n  data: TData[];\n}\n\nexport function DataTable<TData, TValue>({\n  columns,\n  data,\n}: DataTableProps<TData, TValue>) {\n  const { t } = useTranslation();\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n  });\n\n  return (\n    <div className=\"rounded-md border\">\n      <Table>\n        <TableHeader>\n          {table.getHeaderGroups().map((headerGroup) => (\n            <TableRow key={headerGroup.id}>\n              {headerGroup.headers.map((header) => {\n                return (\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              })}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <TableBody>\n          {table.getRowModel().rows?.length ? (\n            table.getRowModel().rows.map((row) => (\n              <TableRow\n                key={row.id}\n                data-state={row.getIsSelected() && 'selected'}\n              >\n                {row.getVisibleCells().map((cell) => (\n                  <TableCell key={cell.id}>\n                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                  </TableCell>\n                ))}\n              </TableRow>\n            ))\n          ) : (\n            <TableRow>\n              <TableCell colSpan={columns.length} className=\"h-24 text-center\">\n                {t('knowledge.documentsTab.noResults')}\n              </TableCell>\n            </TableRow>\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-form/ChooseEntity.ts",
    "content": "export interface IEmbeddingModelEntity {\n  label: string;\n  value: string;\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-form/KBForm.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react';\nimport Link from 'next/link';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { z } from 'zod';\nimport { useTranslation } from 'react-i18next';\nimport { Input } from '@/components/ui/input';\nimport EmojiPicker from '@/components/ui/emoji-picker';\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n  FormDescription,\n} from '@/components/ui/form';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';\nimport { CustomApiError } from '@/app/infra/entities/common';\nimport { toast } from 'sonner';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\nimport DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';\nimport { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';\nimport {\n  DynamicFormItemConfig,\n  getDefaultValues,\n  parseDynamicFormItemType,\n} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';\nimport { UUID } from 'uuidjs';\n\nconst getFormSchema = (t: (key: string) => string) =>\n  z.object({\n    name: z.string().min(1, { message: t('knowledge.kbNameRequired') }),\n    description: z\n      .string()\n      .min(1, { message: t('knowledge.kbDescriptionRequired') }),\n    emoji: z.string().optional(),\n    ragEngineId: z\n      .string()\n      .min(1, { message: t('knowledge.knowledgeEngineRequired') }),\n  });\n\n/**\n * Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]\n * Same pattern as ExternalKBForm uses for retriever config\n */\nfunction parseCreationSchema(\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  schemaItems: any | any[] | undefined,\n): IDynamicFormItemSchema[] {\n  if (!schemaItems) return [];\n\n  // Handle wrapped schema (e.g. { schema: [...] }) which might be returned by the API\n  const items = Array.isArray(schemaItems) ? schemaItems : schemaItems.schema;\n\n  if (!items || !Array.isArray(items)) return [];\n\n  return items.map(\n    (item) =>\n      new DynamicFormItemConfig({\n        default: item.default,\n        id: UUID.generate(),\n        label: item.label,\n        description: item.description,\n        name: item.name,\n        required: item.required,\n        type: parseDynamicFormItemType(item.type),\n        options: item.options,\n        show_if: item.show_if,\n      }),\n  );\n}\n\nexport default function KBForm({\n  initKbId,\n  onNewKbCreated,\n  onKbUpdated,\n}: {\n  initKbId?: string;\n  onNewKbCreated: (kbId: string) => void;\n  onKbUpdated: (kbId: string) => void;\n}) {\n  const { t } = useTranslation();\n  const [ragEngines, setRagEngines] = useState<KnowledgeEngine[]>([]);\n  const [selectedEngineId, setSelectedEngineId] = useState<string>('');\n  const [configSettings, setConfigSettings] = useState<Record<string, unknown>>(\n    {},\n  );\n  const [retrievalSettings, setRetrievalSettings] = useState<\n    Record<string, unknown>\n  >({});\n  const [isEditing, setIsEditing] = useState(false);\n  const [loading, setLoading] = useState(true);\n\n  const formSchema = getFormSchema(t);\n\n  const form = useForm<z.infer<typeof formSchema>>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      name: '',\n      description: t('knowledge.defaultDescription'),\n      emoji: '📚',\n      ragEngineId: '',\n    },\n  });\n\n  // Get selected engine details\n  const selectedEngine = ragEngines.find(\n    (e) => e.plugin_id === selectedEngineId,\n  );\n\n  useEffect(() => {\n    loadRagEngines().then(() => {\n      if (initKbId) {\n        loadKbConfig(initKbId);\n      }\n    });\n  }, []);\n\n  // Auto-select first engine when engines are loaded and no selection\n  useEffect(() => {\n    if (ragEngines.length > 0 && !selectedEngineId && !isEditing) {\n      const firstEngine = ragEngines[0];\n      setSelectedEngineId(firstEngine.plugin_id);\n      form.setValue('ragEngineId', firstEngine.plugin_id);\n      // Initialize config settings with defaults\n      const formItems = parseCreationSchema(firstEngine.creation_schema);\n      if (formItems.length > 0) {\n        setConfigSettings(getDefaultValues(formItems));\n      }\n      const retrievalItems = parseCreationSchema(firstEngine.retrieval_schema);\n      if (retrievalItems.length > 0) {\n        setRetrievalSettings(getDefaultValues(retrievalItems));\n      }\n    }\n  }, [ragEngines, selectedEngineId, isEditing]);\n\n  const loadRagEngines = async () => {\n    setLoading(true);\n    try {\n      const resp = await httpClient.getKnowledgeEngines();\n      setRagEngines(resp.engines);\n    } catch (err) {\n      console.error('Failed to load Knowledge Engines:', err);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const loadKbConfig = async (kbId: string) => {\n    try {\n      setIsEditing(true);\n\n      const res = await httpClient.getKnowledgeBase(kbId);\n      const kb = res.base;\n\n      const engineId = kb.knowledge_engine_plugin_id || '';\n      setSelectedEngineId(engineId);\n\n      form.setValue('name', kb.name);\n      form.setValue('description', kb.description);\n      form.setValue('emoji', kb.emoji || '📚');\n      form.setValue('ragEngineId', engineId);\n\n      setConfigSettings(kb.creation_settings || {});\n      setRetrievalSettings(kb.retrieval_settings || {});\n    } catch (err) {\n      console.error('Failed to load KB config:', err);\n    }\n  };\n\n  const handleEngineChange = (engineId: string) => {\n    setSelectedEngineId(engineId);\n    form.setValue('ragEngineId', engineId);\n\n    // Find engine and initialize config settings with defaults from schema\n    const engine = ragEngines.find((e) => e.plugin_id === engineId);\n    if (engine) {\n      const formItems = parseCreationSchema(engine.creation_schema);\n      if (formItems.length > 0) {\n        setConfigSettings(getDefaultValues(formItems));\n      } else {\n        setConfigSettings({});\n      }\n      const retrievalItems = parseCreationSchema(engine.retrieval_schema);\n      if (retrievalItems.length > 0) {\n        setRetrievalSettings(getDefaultValues(retrievalItems));\n      } else {\n        setRetrievalSettings({});\n      }\n    }\n  };\n\n  const onSubmit = (data: z.infer<typeof formSchema>) => {\n    const kbData: KnowledgeBase = {\n      name: data.name,\n      description: data.description,\n      emoji: data.emoji,\n      knowledge_engine_plugin_id: selectedEngineId,\n      creation_settings: configSettings,\n      retrieval_settings: retrievalSettings,\n    };\n\n    if (initKbId) {\n      // Update knowledge base\n      httpClient\n        .updateKnowledgeBase(initKbId, kbData)\n        .then((res) => {\n          onKbUpdated(res.uuid);\n          toast.success(t('knowledge.updateKnowledgeBaseSuccess'));\n        })\n        .catch((err) => {\n          console.error('update knowledge base failed', err);\n          toast.error(\n            t('knowledge.updateKnowledgeBaseFailed') +\n              (err as CustomApiError).msg,\n          );\n        });\n    } else {\n      // Create knowledge base\n      httpClient\n        .createKnowledgeBase(kbData)\n        .then((res) => {\n          onNewKbCreated(res.uuid);\n        })\n        .catch((err) => {\n          console.error('create knowledge base failed', err);\n          toast.error(\n            t('knowledge.createKnowledgeBaseFailed') +\n              (err as CustomApiError).msg,\n          );\n        });\n    }\n  };\n\n  // Convert creation schema to dynamic form items (same as ExternalKBForm)\n  // Memoize to avoid regenerating UUIDs on every render, which would cause\n  // DynamicFormComponent's useEffect to re-fire and trigger an infinite loop.\n  const configFormItems = useMemo(\n    () => parseCreationSchema(selectedEngine?.creation_schema),\n    [selectedEngine?.creation_schema],\n  );\n\n  // Convert retrieval schema to dynamic form items\n  const retrievalFormItems = useMemo(\n    () => parseCreationSchema(selectedEngine?.retrieval_schema),\n    [selectedEngine?.retrieval_schema],\n  );\n\n  // Show loading state\n  if (loading) {\n    return (\n      <div className=\"flex items-center justify-center py-8\">\n        <p className=\"text-muted-foreground\">{t('common.loading')}</p>\n      </div>\n    );\n  }\n\n  // Show message if no engines available\n  if (ragEngines.length === 0) {\n    return (\n      <div className=\"flex flex-col items-center justify-center py-8 space-y-4\">\n        <p className=\"text-muted-foreground\">\n          {t('knowledge.noEnginesAvailable')}\n        </p>\n        <Link\n          href=\"/home/plugins\"\n          className=\"text-sm text-primary hover:underline\"\n        >\n          {t('knowledge.installEngineHint')}\n        </Link>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      <Form {...form}>\n        <form\n          onSubmit={form.handleSubmit(onSubmit)}\n          id=\"kb-form\"\n          className=\"space-y-8\"\n        >\n          <div className=\"space-y-4\">\n            {/* Knowledge Engine Selector */}\n            <FormField\n              control={form.control}\n              name=\"ragEngineId\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>\n                    {t('knowledge.knowledgeEngine')}\n                    <span className=\"text-red-500\">*</span>\n                  </FormLabel>\n                  <FormControl>\n                    <Select\n                      disabled={isEditing}\n                      onValueChange={(value) => {\n                        field.onChange(value);\n                        handleEngineChange(value);\n                      }}\n                      value={field.value}\n                    >\n                      <SelectTrigger className=\"w-full bg-[#ffffff] dark:bg-[#2a2a2e]\">\n                        <SelectValue\n                          placeholder={t('knowledge.selectKnowledgeEngine')}\n                        />\n                      </SelectTrigger>\n                      <SelectContent className=\"fixed z-[1000]\">\n                        {ragEngines.map((engine) => (\n                          <SelectItem\n                            key={engine.plugin_id}\n                            value={engine.plugin_id}\n                          >\n                            {extractI18nObject(engine.name)}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                  </FormControl>\n                  {selectedEngine?.description && (\n                    <FormDescription>\n                      {extractI18nObject(selectedEngine.description)}\n                    </FormDescription>\n                  )}\n                  {isEditing && (\n                    <FormDescription>\n                      {t('knowledge.cannotChangeKnowledgeEngine')}\n                    </FormDescription>\n                  )}\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n\n            {/* Name and Emoji in same row */}\n            <div className=\"flex gap-4 items-start\">\n              <FormField\n                control={form.control}\n                name=\"name\"\n                render={({ field }) => (\n                  <FormItem className=\"flex-1\">\n                    <FormLabel>\n                      {t('knowledge.kbName')}\n                      <span className=\"text-red-500\">*</span>\n                    </FormLabel>\n                    <FormControl>\n                      <Input {...field} />\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n              <FormField\n                control={form.control}\n                name=\"emoji\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t('common.icon')}</FormLabel>\n                    <FormControl>\n                      <EmojiPicker\n                        value={field.value}\n                        onChange={field.onChange}\n                      />\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n            </div>\n\n            {/* Description */}\n            <FormField\n              control={form.control}\n              name=\"description\"\n              render={({ field }) => (\n                <FormItem>\n                  <FormLabel>\n                    {t('knowledge.kbDescription')}\n                    <span className=\"text-red-500\">*</span>\n                  </FormLabel>\n                  <FormControl>\n                    <Input {...field} />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n\n            {/* Engine specific fields (dynamic form from creation_schema) */}\n            {configFormItems.length > 0 && (\n              <div className=\"space-y-4 pt-2 border-t\">\n                <div className=\"text-sm font-medium text-muted-foreground\">\n                  {t('knowledge.engineSettings')}\n                </div>\n                <div>\n                  <DynamicFormComponent\n                    itemConfigList={configFormItems}\n                    initialValues={configSettings as Record<string, object>}\n                    onSubmit={(val) =>\n                      setConfigSettings(val as Record<string, unknown>)\n                    }\n                    isEditing={isEditing}\n                    externalDependentValues={retrievalSettings}\n                  />\n                </div>\n              </div>\n            )}\n\n            {/* Retrieval settings (dynamic form from retrieval_schema) */}\n            {retrievalFormItems.length > 0 && (\n              <div className=\"space-y-4 pt-2 border-t\">\n                <div className=\"text-sm font-medium text-muted-foreground\">\n                  {t('knowledge.retrievalSettings')}\n                </div>\n                <div>\n                  <DynamicFormComponent\n                    itemConfigList={retrievalFormItems}\n                    initialValues={retrievalSettings as Record<string, object>}\n                    onSubmit={(val) =>\n                      setRetrievalSettings(val as Record<string, unknown>)\n                    }\n                    externalDependentValues={configSettings}\n                  />\n                </div>\n              </div>\n            )}\n          </div>\n        </form>\n      </Form>\n    </>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog.tsx",
    "content": "'use client';\n\nimport { useState } from 'react';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport { useTranslation } from 'react-i18next';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';\nimport { toast } from 'sonner';\nimport { Loader2 } from 'lucide-react';\n\ninterface KBMigrationDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  internalKbCount: number;\n  externalKbCount: number;\n  onMigrationComplete: () => void;\n}\n\nexport default function KBMigrationDialog({\n  open,\n  onOpenChange,\n  internalKbCount,\n  externalKbCount,\n  onMigrationComplete,\n}: KBMigrationDialogProps) {\n  const { t } = useTranslation();\n  const [dismissing, setDismissing] = useState(false);\n\n  const asyncTask = useAsyncTask({\n    onSuccess: () => {\n      toast.success(t('knowledge.migration.success'));\n      onOpenChange(false);\n      onMigrationComplete();\n    },\n    onError: (error) => {\n      toast.error(`${t('knowledge.migration.error')}${error}`);\n    },\n  });\n\n  const handleMigration = async (installPlugin: boolean) => {\n    try {\n      const resp = await httpClient.executeRagMigration(installPlugin);\n      asyncTask.startTask(resp.task_id);\n    } catch {\n      toast.error(t('knowledge.migration.error'));\n    }\n  };\n\n  const handleDismiss = async () => {\n    setDismissing(true);\n    try {\n      await httpClient.dismissRagMigration();\n      onOpenChange(false);\n    } catch {\n      toast.error(t('knowledge.migration.dismissError'));\n    } finally {\n      setDismissing(false);\n    }\n  };\n\n  const isRunning = asyncTask.status === AsyncTaskStatus.RUNNING;\n  const isError = asyncTask.status === AsyncTaskStatus.ERROR;\n  const totalCount = internalKbCount + externalKbCount;\n\n  return (\n    <Dialog\n      open={open}\n      onOpenChange={(v) => {\n        if (!isRunning) onOpenChange(v);\n      }}\n    >\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>{t('knowledge.migration.title')}</DialogTitle>\n          <DialogDescription>\n            {t('knowledge.migration.description')}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"py-4 space-y-3\">\n          {!isRunning && !isError && (\n            <p className=\"text-sm text-muted-foreground\">\n              {t('knowledge.migration.detected', {\n                total: totalCount,\n                internal: internalKbCount,\n                external: externalKbCount,\n              })}\n            </p>\n          )}\n\n          {isRunning && (\n            <div className=\"flex items-center gap-3\">\n              <Loader2 className=\"h-5 w-5 animate-spin text-primary\" />\n              <p className=\"text-sm\">{t('knowledge.migration.running')}</p>\n            </div>\n          )}\n\n          {isError && (\n            <div className=\"space-y-2\">\n              <p className=\"text-sm text-destructive\">\n                {t('knowledge.migration.error')}\n              </p>\n              {asyncTask.error && (\n                <p className=\"text-xs text-muted-foreground bg-muted p-2 rounded\">\n                  {asyncTask.error}\n                </p>\n              )}\n            </div>\n          )}\n        </div>\n\n        <DialogFooter className=\"flex flex-col gap-2 sm:flex-col\">\n          {!isRunning && !isError && (\n            <>\n              <Button onClick={() => handleMigration(true)} className=\"w-full\">\n                {t('knowledge.migration.startWithInstall')}\n              </Button>\n              <Button\n                variant=\"outline\"\n                onClick={() => handleMigration(false)}\n                className=\"w-full\"\n              >\n                {t('knowledge.migration.startDataOnly')}\n              </Button>\n              <p className=\"text-xs text-muted-foreground text-center\">\n                {t('knowledge.migration.dataOnlyHint')}\n              </p>\n            </>\n          )}\n          {isError && (\n            <Button onClick={() => handleMigration(true)} className=\"w-full\">\n              {t('knowledge.migration.retry')}\n            </Button>\n          )}\n          {!isRunning && (\n            <Button\n              variant=\"ghost\"\n              onClick={handleDismiss}\n              disabled={dismissing}\n              className=\"w-full text-destructive hover:text-destructive\"\n            >\n              {t('knowledge.migration.dismiss')}\n            </Button>\n          )}\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/components/kb-retrieve/KBRetrieveGeneric.tsx",
    "content": "'use client';\n\nimport React, { useState } from 'react';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { useTranslation } from 'react-i18next';\nimport { RetrieveResult } from '@/app/infra/entities/api';\nimport { CustomApiError } from '@/app/infra/entities/common';\nimport { toast } from 'sonner';\n\ninterface KBRetrieveGenericProps {\n  kbId: string;\n  retrieveFunction: (\n    kbId: string,\n    query: string,\n  ) => Promise<{ results: RetrieveResult[] }>;\n  getResultTitle?: (result: RetrieveResult) => string;\n}\n\n/**\n * Generic knowledge base retrieve component\n * Supports both builtin and external knowledge bases\n */\nexport default function KBRetrieveGeneric({\n  kbId,\n  retrieveFunction,\n  getResultTitle,\n}: KBRetrieveGenericProps) {\n  const { t } = useTranslation();\n  const [query, setQuery] = useState('');\n  const [results, setResults] = useState<RetrieveResult[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  const handleRetrieve = async () => {\n    if (!query.trim()) return;\n\n    setLoading(true);\n    try {\n      setResults([]);\n      const response = await retrieveFunction(kbId, query);\n      setResults(response.results);\n    } catch (error) {\n      console.error('Retrieve failed:', error);\n      toast.error(t('knowledge.retrieveError') + (error as CustomApiError).msg);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const getTitle = (result: RetrieveResult): string => {\n    if (getResultTitle) {\n      return getResultTitle(result);\n    }\n    // Default: use document_name from metadata, fallback to file_id or id\n    return (\n      (result.metadata.document_name as string) ||\n      (result.metadata.file_id as string) ||\n      result.id\n    );\n  };\n\n  /**\n   * Extract text content from the content array\n   * The content array may contain multiple items with type 'text'\n   */\n  const extractTextFromContent = (result: RetrieveResult): string => {\n    // First try to get content from the new format\n    if (result.content && Array.isArray(result.content)) {\n      const textParts = result.content\n        .filter((item) => item.type === 'text' && item.text)\n        .map((item) => item.text);\n\n      if (textParts.length > 0) {\n        return textParts.join('\\n\\n');\n      }\n    }\n\n    return '';\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex gap-2\">\n        <Input\n          value={query}\n          onChange={(e) => setQuery(e.target.value)}\n          placeholder={t('knowledge.queryPlaceholder')}\n          onKeyPress={(e) => e.key === 'Enter' && handleRetrieve()}\n        />\n        <Button onClick={handleRetrieve} disabled={loading || !query.trim()}>\n          {t('knowledge.query')}\n        </Button>\n      </div>\n\n      <div className=\"space-y-3\">\n        {results.length === 0 && !loading && (\n          <p className=\"text-muted-foreground\">{t('knowledge.noResults')}</p>\n        )}\n\n        {loading ? (\n          <p className=\"text-muted-foreground\">{t('common.loading')}</p>\n        ) : (\n          results.map((result) => (\n            <Card key={result.id} className=\"w-full\">\n              <CardHeader className=\"pb-3\">\n                <CardTitle className=\"text-sm font-medium flex justify-between items-center\">\n                  <span>{getTitle(result)}</span>\n                  <span className=\"text-xs text-muted-foreground\">\n                    {t('knowledge.distance')}:{' '}\n                    {(result.distance ?? 0).toFixed(4)}\n                  </span>\n                </CardTitle>\n              </CardHeader>\n              <CardContent>\n                <p className=\"text-sm whitespace-pre-wrap\">\n                  {extractTextFromContent(result)}\n                </p>\n              </CardContent>\n            </Card>\n          ))\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/knowledgeBase.module.css",
    "content": ".configPageContainer {\n  width: 100%;\n  height: 100%;\n}\n\n.knowledgeListContainer {\n  width: 100%;\n  padding-left: 0.8rem;\n  padding-right: 0.8rem;\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));\n  gap: 2rem;\n  justify-items: stretch;\n  align-items: start;\n}\n"
  },
  {
    "path": "web/src/app/home/knowledge/page.tsx",
    "content": "'use client';\n\nimport CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';\nimport styles from './knowledgeBase.module.css';\nimport { useTranslation } from 'react-i18next';\nimport { useEffect, useState } from 'react';\nimport { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';\nimport KBCard from '@/app/home/knowledge/components/kb-card/KBCard';\nimport KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';\nimport KBMigrationDialog from '@/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { KnowledgeBase } from '@/app/infra/entities/api';\n\nexport default function KnowledgePage() {\n  const { t } = useTranslation();\n  const [knowledgeBaseList, setKnowledgeBaseList] = useState<KnowledgeBaseVO[]>(\n    [],\n  );\n  const [selectedKbId, setSelectedKbId] = useState<string>('');\n  const [detailDialogOpen, setDetailDialogOpen] = useState(false);\n\n  // Migration dialog state\n  const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);\n  const [migrationInternalCount, setMigrationInternalCount] = useState(0);\n  const [migrationExternalCount, setMigrationExternalCount] = useState(0);\n\n  useEffect(() => {\n    getKnowledgeBaseList();\n    checkMigrationStatus();\n  }, []);\n\n  async function checkMigrationStatus() {\n    try {\n      const resp = await httpClient.getRagMigrationStatus();\n      if (resp.needed) {\n        setMigrationInternalCount(resp.internal_kb_count);\n        setMigrationExternalCount(resp.external_kb_count);\n        setMigrationDialogOpen(true);\n      }\n    } catch {\n      // Silently ignore - migration check is non-critical\n    }\n  }\n\n  async function getKnowledgeBaseList() {\n    const resp = await httpClient.getKnowledgeBases();\n\n    const currentTime = new Date();\n\n    const kbs = resp.bases.map((kb: KnowledgeBase) => {\n      const lastUpdatedTimeAgo = Math.floor(\n        (currentTime.getTime() -\n          new Date(kb.updated_at ?? currentTime.getTime()).getTime()) /\n          1000 /\n          60 /\n          60 /\n          24,\n      );\n\n      const lastUpdatedTimeAgoText =\n        lastUpdatedTimeAgo > 0\n          ? ` ${lastUpdatedTimeAgo} ${t('knowledge.daysAgo')}`\n          : t('knowledge.today');\n\n      return new KnowledgeBaseVO({\n        id: kb.uuid || '',\n        name: kb.name,\n        description: kb.description,\n        emoji: kb.emoji,\n        lastUpdatedTimeAgo: lastUpdatedTimeAgoText,\n        ragEngine: kb.knowledge_engine,\n        ragEnginePluginId: kb.knowledge_engine_plugin_id,\n      });\n    });\n\n    setKnowledgeBaseList(kbs);\n  }\n\n  const handleKBCardClick = (kbId: string) => {\n    setSelectedKbId(kbId);\n    setDetailDialogOpen(true);\n  };\n\n  const handleCreateKBClick = () => {\n    setSelectedKbId('');\n    setDetailDialogOpen(true);\n  };\n\n  const handleFormCancel = () => {\n    setDetailDialogOpen(false);\n  };\n\n  const handleKbDeleted = () => {\n    getKnowledgeBaseList();\n    setDetailDialogOpen(false);\n  };\n\n  const handleNewKbCreated = (newKbId: string) => {\n    getKnowledgeBaseList();\n    setSelectedKbId(newKbId);\n    setDetailDialogOpen(true);\n  };\n\n  const handleKbUpdated = () => {\n    getKnowledgeBaseList();\n  };\n\n  const handleMigrationComplete = () => {\n    getKnowledgeBaseList();\n  };\n\n  return (\n    <div>\n      <KBMigrationDialog\n        open={migrationDialogOpen}\n        onOpenChange={setMigrationDialogOpen}\n        internalKbCount={migrationInternalCount}\n        externalKbCount={migrationExternalCount}\n        onMigrationComplete={handleMigrationComplete}\n      />\n\n      <KBDetailDialog\n        open={detailDialogOpen}\n        onOpenChange={setDetailDialogOpen}\n        kbId={selectedKbId || undefined}\n        onFormCancel={handleFormCancel}\n        onKbDeleted={handleKbDeleted}\n        onNewKbCreated={handleNewKbCreated}\n        onKbUpdated={handleKbUpdated}\n      />\n\n      <div className={styles.knowledgeListContainer}>\n        <CreateCardComponent\n          width={'100%'}\n          height={'10rem'}\n          plusSize={'90px'}\n          onClick={handleCreateKBClick}\n        />\n\n        {knowledgeBaseList.map((kb) => {\n          return (\n            <div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>\n              <KBCard kbCardVO={kb} />\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/layout.module.css",
    "content": "/* 主布局容器 */\n.homeLayoutContainer {\n  width: 100vw;\n  height: 100vh;\n  display: flex;\n  flex-direction: row;\n  background-color: #eee;\n}\n\n:global(.dark) .homeLayoutContainer {\n  background-color: #0a0a0b;\n}\n\n/* 侧边栏区域 */\n.sidebar {\n  background-color: #eee;\n}\n\n:global(.dark) .sidebar {\n  background-color: #0a0a0b;\n}\n\n/* 主内容区域 */\n.main {\n  background-color: #fafafa;\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  /* height: 100vh; */\n  width: calc(100% - 1.2rem);\n  height: calc(100% - 1.2rem);\n  overflow: hidden;\n  border-radius: 1.5rem 0 0 1.5rem;\n  margin-left: 0.6rem;\n  margin-top: 0.6rem;\n  box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.05);\n}\n\n:global(.dark) .main {\n  background-color: #151518;\n  box-shadow: 0 0 6px 0 rgba(255, 255, 255, 0.05);\n}\n\n.mainContent {\n  padding: 1.5rem;\n  padding-left: 2rem;\n  flex: 1;\n  overflow-y: auto;\n  background-color: #fafafa;\n}\n\n:global(.dark) .mainContent {\n  background-color: #151518;\n}\n"
  },
  {
    "path": "web/src/app/home/layout.tsx",
    "content": "'use client';\n\nimport styles from './layout.module.css';\nimport HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';\nimport HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';\nimport SurveyWidget from '@/app/home/components/survey/SurveyWidget';\nimport React, {\n  useState,\n  useCallback,\n  useMemo,\n  useEffect,\n  Suspense,\n} from 'react';\nimport { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';\nimport { I18nObject } from '@/app/infra/entities/common';\nimport { userInfo, initializeUserInfo } from '@/app/infra/http';\n\nexport default function HomeLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  const [title, setTitle] = useState<string>('');\n  const [subtitle, setSubtitle] = useState<string>('');\n  const [helpLink, setHelpLink] = useState<I18nObject>({\n    en_US: '',\n    zh_Hans: '',\n  });\n\n  // Initialize user info if not already initialized\n  useEffect(() => {\n    if (!userInfo) {\n      initializeUserInfo();\n    }\n  }, []);\n\n  const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {\n    setTitle(child.name);\n    setSubtitle(child.description);\n    setHelpLink(child.helpLink);\n  }, []);\n\n  // Memoize the main content area to prevent re-renders when sidebar state changes\n  const mainContent = useMemo(() => children, [children]);\n\n  return (\n    <div className={styles.homeLayoutContainer}>\n      <aside className={styles.sidebar}>\n        <Suspense fallback={<div />}>\n          <HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />\n        </Suspense>\n      </aside>\n\n      <div className={styles.main}>\n        <HomeTitleBar title={title} subtitle={subtitle} helpLink={helpLink} />\n\n        <main className={styles.mainContent}>{mainContent}</main>\n\n        <SurveyWidget />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/loading.tsx",
    "content": "import { LoadingSpinner } from '@/components/ui/loading-spinner';\n\nexport default function Loading() {\n  return (\n    <div className=\"flex h-full items-center justify-center\">\n      <LoadingSpinner size=\"lg\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/components/ExportDropdown.tsx",
    "content": "'use client';\n\nimport React, { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Download,\n  FileText,\n  Database,\n  AlertCircle,\n  Users,\n  Layers,\n} from 'lucide-react';\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 { backendClient } from '@/app/infra/http';\nimport { FilterState } from '../types/monitoring';\n\nexport type ExportType =\n  | 'messages'\n  | 'llm-calls'\n  | 'embedding-calls'\n  | 'errors'\n  | 'sessions';\n\ninterface ExportDropdownProps {\n  filterState: FilterState;\n}\n\nexport function ExportDropdown({ filterState }: ExportDropdownProps) {\n  const { t } = useTranslation();\n  const [exporting, setExporting] = useState<ExportType | null>(null);\n\n  const getDateRangeParams = (): { startTime: string; endTime: string } => {\n    const now = new Date();\n    let startTime: Date;\n    let endTime: Date = now;\n\n    switch (filterState.timeRange) {\n      case 'lastHour':\n        startTime = new Date(now.getTime() - 60 * 60 * 1000);\n        break;\n      case 'last6Hours':\n        startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);\n        break;\n      case 'last24Hours':\n        startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);\n        break;\n      case 'last7Days':\n        startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);\n        break;\n      case 'last30Days':\n        startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);\n        break;\n      case 'custom':\n        if (filterState.customDateRange) {\n          startTime = filterState.customDateRange.from;\n          endTime = filterState.customDateRange.to;\n        } else {\n          startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);\n        }\n        break;\n      default:\n        startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);\n    }\n\n    return {\n      startTime: startTime.toISOString(),\n      endTime: endTime.toISOString(),\n    };\n  };\n\n  const handleExport = async (type: ExportType) => {\n    setExporting(type);\n    try {\n      const { startTime, endTime } = getDateRangeParams();\n      const params = new URLSearchParams({\n        type,\n        startTime,\n        endTime,\n      });\n\n      if (filterState.selectedBots.length > 0) {\n        filterState.selectedBots.forEach((botId) => {\n          params.append('botId', botId);\n        });\n      }\n\n      if (filterState.selectedPipelines.length > 0) {\n        filterState.selectedPipelines.forEach((pipelineId) => {\n          params.append('pipelineId', pipelineId);\n        });\n      }\n\n      // Use backendClient's downloadFile method for blob response\n      const response = await backendClient.downloadFile(\n        `/api/v1/monitoring/export?${params.toString()}`,\n      );\n\n      // Get filename from content-disposition header\n      const contentDisposition = response.headers['content-disposition'];\n      let filename = `monitoring-${type}-${Date.now()}.csv`;\n      if (contentDisposition) {\n        const filenameMatch = contentDisposition.match(\n          /filename=\"?([^\";\\n]+)\"?/,\n        );\n        if (filenameMatch) {\n          filename = filenameMatch[1];\n        }\n      }\n\n      // Create download link\n      const blob = new Blob([response.data], {\n        type: 'text/csv;charset=utf-8;',\n      });\n      const url = window.URL.createObjectURL(blob);\n      const link = document.createElement('a');\n      link.href = url;\n      link.setAttribute('download', filename);\n      document.body.appendChild(link);\n      link.click();\n      document.body.removeChild(link);\n      window.URL.revokeObjectURL(url);\n    } catch (error) {\n      console.error('Failed to export data:', error);\n    } finally {\n      setExporting(null);\n    }\n  };\n\n  const exportOptions: {\n    type: ExportType;\n    label: string;\n    icon: React.ReactNode;\n  }[] = [\n    {\n      type: 'messages',\n      label: t('monitoring.export.messages'),\n      icon: <FileText className=\"w-4 h-4 mr-2\" />,\n    },\n    {\n      type: 'llm-calls',\n      label: t('monitoring.export.llmCalls'),\n      icon: <Database className=\"w-4 h-4 mr-2\" />,\n    },\n    {\n      type: 'embedding-calls',\n      label: t('monitoring.export.embeddingCalls'),\n      icon: <Layers className=\"w-4 h-4 mr-2\" />,\n    },\n    {\n      type: 'errors',\n      label: t('monitoring.export.errors'),\n      icon: <AlertCircle className=\"w-4 h-4 mr-2\" />,\n    },\n    {\n      type: 'sessions',\n      label: t('monitoring.export.sessions'),\n      icon: <Users className=\"w-4 h-4 mr-2\" />,\n    },\n  ];\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className=\"bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0\"\n          disabled={exporting !== null}\n        >\n          {exporting ? (\n            <>\n              <svg\n                className=\"w-4 h-4 mr-2 animate-spin\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n              >\n                <circle\n                  className=\"opacity-25\"\n                  cx=\"12\"\n                  cy=\"12\"\n                  r=\"10\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"4\"\n                />\n                <path\n                  className=\"opacity-75\"\n                  fill=\"currentColor\"\n                  d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n                />\n              </svg>\n              {t('monitoring.export.exporting')}\n            </>\n          ) : (\n            <>\n              <Download className=\"w-4 h-4 mr-2\" />\n              {t('monitoring.exportData')}\n            </>\n          )}\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-48\">\n        <DropdownMenuLabel>{t('monitoring.export.title')}</DropdownMenuLabel>\n        <DropdownMenuSeparator />\n        {exportOptions.map((option) => (\n          <DropdownMenuItem\n            key={option.type}\n            onClick={() => handleExport(option.type)}\n            disabled={exporting !== null}\n            className=\"cursor-pointer\"\n          >\n            {option.icon}\n            {option.label}\n          </DropdownMenuItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/components/MessageContentRenderer.tsx",
    "content": "'use client';\n\nimport React, { useState } from 'react';\nimport {\n  MessageChainComponent,\n  Image as ImageComponent,\n  Plain,\n  At,\n  Voice,\n  Quote,\n} from '@/app/infra/entities/message';\nimport ImagePreviewDialog from '@/app/home/pipelines/components/debug-dialog/ImagePreviewDialog';\n\ninterface MessageContentRendererProps {\n  content: string;\n  maxLines?: number;\n}\n\nexport function MessageContentRenderer({\n  content,\n  maxLines = 3,\n}: MessageContentRendererProps) {\n  const [previewImageUrl, setPreviewImageUrl] = useState<string>('');\n  const [showImagePreview, setShowImagePreview] = useState(false);\n\n  // Try to parse content as message_chain JSON\n  const parseContent = (content: string): MessageChainComponent[] | null => {\n    try {\n      const parsed = JSON.parse(content);\n      if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].type) {\n        return parsed as MessageChainComponent[];\n      }\n      return null;\n    } catch {\n      return null;\n    }\n  };\n\n  const renderMessageComponent = (\n    component: MessageChainComponent,\n    index: number,\n  ) => {\n    switch (component.type) {\n      case 'Plain':\n        return <span key={index}>{(component as Plain).text}</span>;\n\n      case 'At': {\n        const atComponent = component as At;\n        const displayName =\n          atComponent.display || atComponent.target?.toString() || '';\n        return (\n          <span\n            key={index}\n            className=\"inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm\"\n          >\n            @{displayName}\n          </span>\n        );\n      }\n\n      case 'AtAll':\n        return (\n          <span\n            key={index}\n            className=\"inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm\"\n          >\n            @All\n          </span>\n        );\n\n      case 'Image': {\n        const img = component as ImageComponent;\n        const imageUrl = img.url || (img.base64 ? img.base64 : '');\n\n        if (!imageUrl) {\n          return (\n            <span\n              key={index}\n              className=\"inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm\"\n            >\n              [Image]\n            </span>\n          );\n        }\n\n        return (\n          <span key={index} className=\"inline-block align-middle mx-1\">\n            <img\n              src={imageUrl}\n              alt=\"Image\"\n              className=\"w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity border border-gray-200 dark:border-gray-700\"\n              onClick={(e) => {\n                e.stopPropagation();\n                setPreviewImageUrl(imageUrl);\n                setShowImagePreview(true);\n              }}\n            />\n          </span>\n        );\n      }\n\n      case 'File': {\n        const file = component as MessageChainComponent & { name?: string };\n        return (\n          <span\n            key={index}\n            className=\"inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm\"\n          >\n            <svg\n              className=\"w-3.5 h-3.5 mr-1\"\n              fill=\"currentColor\"\n              viewBox=\"0 0 20 20\"\n            >\n              <path d=\"M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z\" />\n            </svg>\n            {file.name || 'File'}\n          </span>\n        );\n      }\n\n      case 'Voice': {\n        const voice = component as Voice;\n        return (\n          <span\n            key={index}\n            className=\"inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm\"\n          >\n            <svg\n              className=\"w-3.5 h-3.5 mr-1\"\n              fill=\"currentColor\"\n              viewBox=\"0 0 20 20\"\n            >\n              <path d=\"M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z\" />\n            </svg>\n            Voice{voice.length ? ` ${voice.length}s` : ''}\n          </span>\n        );\n      }\n\n      case 'Quote': {\n        const quote = component as Quote;\n        return (\n          <span\n            key={index}\n            className=\"inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 text-sm border-l-2 border-gray-400\"\n          >\n            {quote.origin\n              ?.filter((c) => (c as MessageChainComponent).type === 'Plain')\n              .map((c) => (c as MessageChainComponent as Plain).text)\n              .join('') || '[Quote]'}\n          </span>\n        );\n      }\n\n      case 'Source':\n        return null;\n\n      default:\n        return (\n          <span\n            key={index}\n            className=\"inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm\"\n          >\n            [{component.type}]\n          </span>\n        );\n    }\n  };\n\n  const messageChain = parseContent(content);\n\n  // Determine line clamp class\n  const lineClampClass =\n    maxLines === 2\n      ? 'line-clamp-2'\n      : maxLines === 3\n        ? 'line-clamp-3'\n        : maxLines === 4\n          ? 'line-clamp-4'\n          : '';\n\n  if (messageChain) {\n    // Filter out Source components as they render to null\n    const visibleComponents = messageChain.filter(\n      (component) => component.type !== 'Source',\n    );\n\n    // If no visible components, show placeholder\n    if (visibleComponents.length === 0) {\n      return (\n        <span className=\"text-gray-400 dark:text-gray-500 italic\">\n          [Empty message]\n        </span>\n      );\n    }\n\n    // Render as message chain\n    return (\n      <>\n        <div className={`${lineClampClass}`}>\n          {messageChain.map((component, index) =>\n            renderMessageComponent(component, index),\n          )}\n        </div>\n        <ImagePreviewDialog\n          open={showImagePreview}\n          imageUrl={previewImageUrl}\n          onClose={() => setShowImagePreview(false)}\n        />\n      </>\n    );\n  }\n\n  // Handle empty plain text\n  if (\n    !content ||\n    content.trim() === '' ||\n    content === '[]' ||\n    content === '\"\"'\n  ) {\n    return (\n      <span className=\"text-gray-400 dark:text-gray-500 italic\">\n        [Empty message]\n      </span>\n    );\n  }\n\n  // Render as plain text\n  return <span className={lineClampClass}>{content}</span>;\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/components/MessageDetailsCard.tsx",
    "content": "'use client';\n\nimport React, { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { MessageDetails } from '../types/monitoring';\n\ninterface MessageDetailsCardProps {\n  details: MessageDetails;\n}\n\nexport function MessageDetailsCard({ details }: MessageDetailsCardProps) {\n  const { t } = useTranslation();\n\n  // Parse query variables JSON string\n  const queryVariables = useMemo(() => {\n    if (!details.message?.variables) return null;\n    try {\n      return JSON.parse(details.message.variables);\n    } catch {\n      return null;\n    }\n  }, [details.message?.variables]);\n\n  return (\n    <div className=\"space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4\">\n      {/* Context Info Section */}\n      {details.message && (\n        <div className=\"bg-gray-50 dark:bg-gray-800 rounded-lg p-3\">\n          <h4 className=\"text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center\">\n            <svg\n              className=\"w-4 h-4 mr-2\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 7H13V9H11V7ZM11 11H13V17H11V11Z\"></path>\n            </svg>\n            {t('monitoring.messageList.viewDetails')}\n          </h4>\n\n          {/* Metadata Grid */}\n          <div className=\"grid grid-cols-2 md:grid-cols-4 gap-2 text-xs\">\n            {details.message.platform && (\n              <div className=\"bg-white dark:bg-gray-900 rounded p-2\">\n                <div className=\"text-gray-500 dark:text-gray-400\">\n                  {t('monitoring.messageList.platform')}\n                </div>\n                <div className=\"font-medium text-gray-900 dark:text-white\">\n                  {details.message.platform}\n                </div>\n              </div>\n            )}\n            {details.message.userId && (\n              <div className=\"bg-white dark:bg-gray-900 rounded p-2\">\n                <div className=\"text-gray-500 dark:text-gray-400\">\n                  {t('monitoring.messageList.user')}\n                </div>\n                <div className=\"font-medium text-gray-900 dark:text-white truncate\">\n                  {details.message.userId}\n                </div>\n              </div>\n            )}\n            {details.message.runnerName && (\n              <div className=\"bg-white dark:bg-gray-900 rounded p-2\">\n                <div className=\"text-gray-500 dark:text-gray-400\">\n                  {t('monitoring.messageList.runner')}\n                </div>\n                <div className=\"font-medium text-gray-900 dark:text-white\">\n                  {details.message.runnerName}\n                </div>\n              </div>\n            )}\n            <div className=\"bg-white dark:bg-gray-900 rounded p-2\">\n              <div className=\"text-gray-500 dark:text-gray-400\">\n                {t('monitoring.messageList.level')}\n              </div>\n              <div\n                className={`font-medium ${\n                  details.message.level === 'error'\n                    ? 'text-red-600 dark:text-red-400'\n                    : details.message.level === 'warning'\n                      ? 'text-yellow-600 dark:text-yellow-400'\n                      : 'text-gray-900 dark:text-white'\n                }`}\n              >\n                {details.message.level.toUpperCase()}\n              </div>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* LLM Calls Section */}\n      {details.llmCalls && details.llmCalls.length > 0 && (\n        <div className=\"bg-gray-50 dark:bg-gray-800 rounded-lg p-3\">\n          <h4 className=\"text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center\">\n            <svg\n              className=\"w-4 h-4 mr-2\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M12 2C17.52 2 22 6.48 22 12C22 17.52 17.52 22 12 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2ZM12 20C16.42 20 20 16.42 20 12C20 7.58 16.42 4 12 4C7.58 4 4 7.58 4 12C4 16.42 7.58 20 12 20ZM13 12V7H11V14H17V12H13Z\"></path>\n            </svg>\n            {t('monitoring.llmCalls.title')} ({details.llmCalls.length})\n          </h4>\n\n          {/* LLM Stats Summary */}\n          <div className=\"grid grid-cols-3 gap-2 mb-3\">\n            <div className=\"bg-blue-50 dark:bg-blue-900/30 rounded p-2\">\n              <div className=\"text-xs text-blue-600 dark:text-blue-400\">\n                {t('monitoring.llmCalls.totalTokens')}\n              </div>\n              <div className=\"text-lg font-semibold text-blue-900 dark:text-blue-100\">\n                {details.llmStats.totalTokens.toLocaleString()}\n              </div>\n            </div>\n            <div className=\"bg-green-50 dark:bg-green-900/30 rounded p-2\">\n              <div className=\"text-xs text-green-600 dark:text-green-400\">\n                {t('monitoring.llmCalls.avgDuration')}\n              </div>\n              <div className=\"text-lg font-semibold text-green-900 dark:text-green-100\">\n                {details.llmStats.averageDurationMs}ms\n              </div>\n            </div>\n            <div className=\"bg-purple-50 dark:bg-purple-900/30 rounded p-2\">\n              <div className=\"text-xs text-purple-600 dark:text-purple-400\">\n                {t('monitoring.llmCalls.calls')}\n              </div>\n              <div className=\"text-lg font-semibold text-purple-900 dark:text-purple-100\">\n                {details.llmStats.totalCalls}\n              </div>\n            </div>\n          </div>\n\n          {/* Individual LLM Calls */}\n          <div className=\"space-y-2\">\n            {details.llmCalls.map((call, index) => (\n              <div\n                key={call.id}\n                className=\"bg-white dark:bg-gray-900 rounded p-2 text-sm\"\n              >\n                <div className=\"flex justify-between items-start mb-2\">\n                  <div>\n                    <span className=\"font-medium text-gray-900 dark:text-white\">\n                      #{index + 1} {call.modelName}\n                    </span>\n                    <span\n                      className={`ml-2 text-xs px-2 py-0.5 rounded ${\n                        call.status === 'success'\n                          ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'\n                          : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'\n                      }`}\n                    >\n                      {call.status}\n                    </span>\n                  </div>\n                  <span className=\"text-xs text-gray-500 dark:text-gray-400\">\n                    {call.duration}ms\n                  </span>\n                </div>\n                <div className=\"grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-400\">\n                  <div>\n                    <span className=\"text-gray-500 dark:text-gray-500\">\n                      In:\n                    </span>{' '}\n                    {call.tokens.input.toLocaleString()}\n                  </div>\n                  <div>\n                    <span className=\"text-gray-500 dark:text-gray-500\">\n                      Out:\n                    </span>{' '}\n                    {call.tokens.output.toLocaleString()}\n                  </div>\n                  <div>\n                    <span className=\"text-gray-500 dark:text-gray-500\">\n                      Total:\n                    </span>{' '}\n                    {call.tokens.total.toLocaleString()}\n                  </div>\n                </div>\n                {call.errorMessage && (\n                  <div className=\"mt-2 text-xs text-red-600 dark:text-red-400\">\n                    {call.errorMessage}\n                  </div>\n                )}\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {/* Errors Section */}\n      {details.errors && details.errors.length > 0 && (\n        <div className=\"bg-gray-50 dark:bg-gray-800 rounded-lg p-3\">\n          <h4 className=\"text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center\">\n            <svg\n              className=\"w-4 h-4 mr-2\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z\"></path>\n            </svg>\n            {t('monitoring.errors.title')} ({details.errors.length})\n          </h4>\n          <div className=\"space-y-2\">\n            {details.errors.map((error) => (\n              <div\n                key={error.id}\n                className=\"bg-red-50 dark:bg-red-900/20 rounded p-2 text-sm\"\n              >\n                <div className=\"font-medium text-red-900 dark:text-red-300 mb-1\">\n                  {error.errorType}\n                </div>\n                <div className=\"text-red-700 dark:text-red-400 text-xs mb-2\">\n                  {error.errorMessage}\n                </div>\n                {error.stackTrace && (\n                  <details className=\"text-xs\">\n                    <summary className=\"cursor-pointer text-red-600 dark:text-red-500 hover:text-red-800 dark:hover:text-red-300\">\n                      {t('monitoring.errors.stackTrace')}\n                    </summary>\n                    <pre className=\"mt-2 p-2 bg-red-100 dark:bg-red-900/40 rounded overflow-x-auto text-xs\">\n                      {error.stackTrace}\n                    </pre>\n                  </details>\n                )}\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {/* Query Variables Section - Only show for non-local-agent runners */}\n      {queryVariables &&\n        Object.keys(queryVariables).length > 0 &&\n        details.message?.runnerName !== 'local-agent' && (\n          <div className=\"bg-gray-50 dark:bg-gray-800 rounded-lg p-3\">\n            <h4 className=\"text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center\">\n              <svg\n                className=\"w-4 h-4 mr-2\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                viewBox=\"0 0 24 24\"\n                fill=\"currentColor\"\n              >\n                <path d=\"M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V9.7C6 10.7065 5.41099 11.5849 4.55132 12C5.41099 12.4151 6 13.2935 6 14.3V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V14.3C18 13.2935 18.589 12.4151 19.4487 12C18.589 11.5849 18 10.7065 18 9.7V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z\"></path>\n              </svg>\n              {t('monitoring.queryVariables.title')}\n            </h4>\n            <div className=\"grid grid-cols-2 md:grid-cols-4 gap-2 text-xs\">\n              {Object.entries(queryVariables).map(([key, value]) => (\n                <div\n                  key={key}\n                  className=\"bg-white dark:bg-gray-900 rounded p-2\"\n                >\n                  <div className=\"text-gray-500 dark:text-gray-400\">{key}</div>\n                  <div\n                    className=\"font-medium text-gray-900 dark:text-white truncate\"\n                    title={\n                      typeof value === 'string' ? value : JSON.stringify(value)\n                    }\n                  >\n                    {value === null || value === undefined ? (\n                      <span className=\"text-gray-400 italic\">null</span>\n                    ) : typeof value === 'string' ? (\n                      value || (\n                        <span className=\"text-gray-400 italic\">empty</span>\n                      )\n                    ) : (\n                      JSON.stringify(value)\n                    )}\n                  </div>\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n\n      {/* No data message */}\n      {(!details.llmCalls || details.llmCalls.length === 0) &&\n        (!details.errors || details.errors.length === 0) &&\n        (details.message?.runnerName === 'local-agent' ||\n          !queryVariables ||\n          Object.keys(queryVariables).length === 0) && (\n          <div className=\"text-sm text-gray-500 dark:text-gray-400 text-center py-4\">\n            {t('monitoring.messageDetails.noData')}\n          </div>\n        )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/components/filters/MonitoringFilters.tsx",
    "content": "'use client';\n\nimport React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { backendClient } from '@/app/infra/http';\nimport { TimeRangeOption } from '../../types/monitoring';\n\ninterface MonitoringFiltersProps {\n  selectedBots: string[];\n  selectedPipelines: string[];\n  timeRange: TimeRangeOption;\n  onBotsChange: (bots: string[]) => void;\n  onPipelinesChange: (pipelines: string[]) => void;\n  onTimeRangeChange: (timeRange: TimeRangeOption) => void;\n}\n\ninterface Bot {\n  uuid: string;\n  name: string;\n}\n\ninterface Pipeline {\n  uuid: string;\n  name: string;\n}\n\nexport default function MonitoringFilters({\n  selectedBots,\n  selectedPipelines,\n  timeRange,\n  onBotsChange,\n  onPipelinesChange,\n  onTimeRangeChange,\n}: MonitoringFiltersProps) {\n  const { t } = useTranslation();\n  const [bots, setBots] = useState<Bot[]>([]);\n  const [pipelines, setPipelines] = useState<Pipeline[]>([]);\n  const [loadingBots, setLoadingBots] = useState(false);\n  const [loadingPipelines, setLoadingPipelines] = useState(false);\n\n  // Fetch bots list\n  useEffect(() => {\n    const fetchBots = async () => {\n      setLoadingBots(true);\n      try {\n        const response = await backendClient.getBots();\n        // Filter out bots without uuid and map to local Bot interface\n        const validBots = (response.bots || [])\n          .filter((bot): bot is typeof bot & { uuid: string } => !!bot.uuid)\n          .map((bot) => ({ uuid: bot.uuid, name: bot.name }));\n        setBots(validBots);\n      } catch (error) {\n        console.error('Failed to fetch bots:', error);\n      } finally {\n        setLoadingBots(false);\n      }\n    };\n\n    fetchBots();\n  }, []);\n\n  // Fetch pipelines list\n  useEffect(() => {\n    const fetchPipelines = async () => {\n      setLoadingPipelines(true);\n      try {\n        const response = await backendClient.getPipelines();\n        // Filter out pipelines without uuid and map to local Pipeline interface\n        const validPipelines = (response.pipelines || [])\n          .filter(\n            (pipeline): pipeline is typeof pipeline & { uuid: string } =>\n              !!pipeline.uuid,\n          )\n          .map((pipeline) => ({ uuid: pipeline.uuid, name: pipeline.name }));\n        setPipelines(validPipelines);\n      } catch (error) {\n        console.error('Failed to fetch pipelines:', error);\n      } finally {\n        setLoadingPipelines(false);\n      }\n    };\n\n    fetchPipelines();\n  }, []);\n\n  const handleBotChange = (value: string) => {\n    if (value === 'all') {\n      onBotsChange([]);\n    } else {\n      onBotsChange([value]);\n    }\n  };\n\n  const handlePipelineChange = (value: string) => {\n    if (value === 'all') {\n      onPipelinesChange([]);\n    } else {\n      onPipelinesChange([value]);\n    }\n  };\n\n  const handleTimeRangeChange = (value: string) => {\n    onTimeRangeChange(value as TimeRangeOption);\n  };\n\n  return (\n    <div className=\"flex flex-wrap items-center gap-6\">\n      {/* Bot Filter */}\n      <div className=\"flex items-center gap-2\">\n        <label className=\"text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap\">\n          {t('monitoring.filters.bot')}\n        </label>\n        <Select\n          value={selectedBots.length === 0 ? 'all' : selectedBots[0]}\n          onValueChange={handleBotChange}\n          disabled={loadingBots}\n        >\n          <SelectTrigger className=\"bg-white dark:bg-[#2a2a2e] h-9 w-[140px]\">\n            <SelectValue\n              placeholder={\n                loadingBots\n                  ? t('monitoring.filters.loading')\n                  : t('monitoring.filters.selectBot')\n              }\n            />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"all\">\n              {t('monitoring.filters.allBots')}\n            </SelectItem>\n            {bots.map((bot) => (\n              <SelectItem key={bot.uuid} value={bot.uuid}>\n                {bot.name}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n\n      {/* Pipeline Filter */}\n      <div className=\"flex items-center gap-2\">\n        <label className=\"text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap\">\n          {t('monitoring.filters.pipeline')}\n        </label>\n        <Select\n          value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}\n          onValueChange={handlePipelineChange}\n          disabled={loadingPipelines}\n        >\n          <SelectTrigger className=\"bg-white dark:bg-[#2a2a2e] h-9 w-[140px]\">\n            <SelectValue\n              placeholder={\n                loadingPipelines\n                  ? t('monitoring.filters.loading')\n                  : t('monitoring.filters.selectPipeline')\n              }\n            />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"all\">\n              {t('monitoring.filters.allPipelines')}\n            </SelectItem>\n            {pipelines.map((pipeline) => (\n              <SelectItem key={pipeline.uuid} value={pipeline.uuid}>\n                {pipeline.name}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n\n      {/* Time Range Filter */}\n      <div className=\"flex items-center gap-2\">\n        <label className=\"text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap\">\n          {t('monitoring.filters.timeRange')}\n        </label>\n        <Select value={timeRange} onValueChange={handleTimeRangeChange}>\n          <SelectTrigger className=\"bg-white dark:bg-[#2a2a2e] h-9 w-[150px]\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"lastHour\">\n              {t('monitoring.filters.lastHour')}\n            </SelectItem>\n            <SelectItem value=\"last6Hours\">\n              {t('monitoring.filters.last6Hours')}\n            </SelectItem>\n            <SelectItem value=\"last24Hours\">\n              {t('monitoring.filters.last24Hours')}\n            </SelectItem>\n            <SelectItem value=\"last7Days\">\n              {t('monitoring.filters.last7Days')}\n            </SelectItem>\n            <SelectItem value=\"last30Days\">\n              {t('monitoring.filters.last30Days')}\n            </SelectItem>\n          </SelectContent>\n        </Select>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/components/overview-cards/MetricCard.tsx",
    "content": "'use client';\n\nimport React from 'react';\nimport { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';\n\ninterface MetricCardProps {\n  title: string;\n  value: string | number;\n  icon: React.ReactNode;\n  trend?: {\n    value: number;\n    direction: 'up' | 'down';\n  };\n  loading?: boolean;\n}\n\nexport default function MetricCard({\n  title,\n  value,\n  icon,\n  trend,\n  loading,\n}: MetricCardProps) {\n  if (loading) {\n    return (\n      <Card className=\"bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all duration-300\">\n        <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-3\">\n          <CardTitle className=\"text-sm font-medium text-gray-600 dark:text-gray-400\">\n            {title}\n          </CardTitle>\n          <div className=\"h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center\">\n            <div className=\"h-5 w-5 text-blue-600 dark:text-blue-400\">\n              {icon}\n            </div>\n          </div>\n        </CardHeader>\n        <CardContent>\n          <div className=\"h-9 w-28 bg-gray-200 dark:bg-gray-700 animate-pulse rounded\"></div>\n          <div className=\"h-4 w-20 bg-gray-100 dark:bg-gray-800 animate-pulse rounded mt-2\"></div>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  return (\n    <Card className=\"bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all duration-300 group\">\n      <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-3\">\n        <CardTitle className=\"text-sm font-medium text-gray-600 dark:text-gray-400\">\n          {title}\n        </CardTitle>\n        <div className=\"h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center group-hover:scale-110 transition-transform duration-300\">\n          <div className=\"h-5 w-5 text-blue-600 dark:text-blue-400\">{icon}</div>\n        </div>\n      </CardHeader>\n      <CardContent>\n        <div className=\"text-3xl font-bold text-gray-900 dark:text-white mb-2\">\n          {value}\n        </div>\n        {trend && (\n          <div className=\"flex items-center gap-1.5\">\n            <span\n              className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${\n                trend.direction === 'up'\n                  ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'\n                  : 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'\n              }`}\n            >\n              <svg className=\"w-3 h-3\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                {trend.direction === 'up' ? (\n                  <path\n                    fillRule=\"evenodd\"\n                    d=\"M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z\"\n                    clipRule=\"evenodd\"\n                  />\n                ) : (\n                  <path\n                    fillRule=\"evenodd\"\n                    d=\"M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z\"\n                    clipRule=\"evenodd\"\n                  />\n                )}\n              </svg>\n              {Math.abs(trend.value)}%\n            </span>\n            <span className=\"text-xs text-gray-500 dark:text-gray-400\">\n              vs previous period\n            </span>\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx",
    "content": "'use client';\n\nimport React from 'react';\nimport { useTranslation } from 'react-i18next';\nimport MetricCard from './MetricCard';\nimport TrafficChart from './TrafficChart';\nimport {\n  OverviewMetrics,\n  MonitoringMessage,\n  LLMCall,\n} from '../../types/monitoring';\n\ninterface OverviewCardsProps {\n  metrics: OverviewMetrics | null;\n  messages?: MonitoringMessage[];\n  llmCalls?: LLMCall[];\n  loading?: boolean;\n}\n\nexport default function OverviewCards({\n  metrics,\n  messages = [],\n  llmCalls = [],\n  loading,\n}: OverviewCardsProps) {\n  const { t } = useTranslation();\n\n  const cards = [\n    {\n      title: t('monitoring.totalMessages'),\n      value: metrics?.totalMessages || 0,\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM4 18.3851L5.76282 17H20V5H4V18.3851Z\"></path>\n        </svg>\n      ),\n      trend: metrics?.trends\n        ? {\n            value: metrics.trends.messages,\n            direction: (metrics.trends.messages >= 0 ? 'up' : 'down') as\n              | 'up'\n              | 'down',\n          }\n        : undefined,\n    },\n    {\n      title: t('monitoring.modelCallsCount'),\n      value: metrics?.modelCalls || 0,\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899Z\"></path>\n        </svg>\n      ),\n      trend: metrics?.trends\n        ? {\n            value: metrics.trends.llmCalls,\n            direction: (metrics.trends.llmCalls >= 0 ? 'up' : 'down') as\n              | 'up'\n              | 'down',\n          }\n        : undefined,\n    },\n    {\n      title: t('monitoring.successRate'),\n      value: metrics ? `${metrics.successRate}%` : '0%',\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M10 15.172L19.192 5.979L20.607 7.393L10 18L3.636 11.636L5.05 10.222L10 15.172Z\"></path>\n        </svg>\n      ),\n      trend: metrics?.trends\n        ? {\n            value: metrics.trends.successRate,\n            direction: (metrics.trends.successRate >= 0 ? 'up' : 'down') as\n              | 'up'\n              | 'down',\n          }\n        : undefined,\n    },\n    {\n      title: t('monitoring.activeSessions'),\n      value: metrics?.activeSessions || 0,\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.7519 23 22H21C21 19.3742 19.4041 17.1096 17.1582 16.2466L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z\"></path>\n        </svg>\n      ),\n      trend: metrics?.trends\n        ? {\n            value: metrics.trends.sessions,\n            direction: (metrics.trends.sessions >= 0 ? 'up' : 'down') as\n              | 'up'\n              | 'down',\n          }\n        : undefined,\n    },\n  ];\n\n  return (\n    <div className=\"space-y-6\">\n      {/* Metric Cards */}\n      <div className=\"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6\">\n        {cards.map((card, index) => (\n          <MetricCard\n            key={index}\n            title={card.title}\n            value={card.value}\n            icon={card.icon}\n            trend={card.trend}\n            loading={loading}\n          />\n        ))}\n      </div>\n\n      {/* Traffic Chart */}\n      <TrafficChart messages={messages} llmCalls={llmCalls} loading={loading} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx",
    "content": "'use client';\n\nimport React, { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  AreaChart,\n  Area,\n  XAxis,\n  YAxis,\n  CartesianGrid,\n  Tooltip,\n  ResponsiveContainer,\n  Legend,\n} from 'recharts';\nimport { MonitoringMessage, LLMCall } from '../../types/monitoring';\n\ninterface TrafficChartProps {\n  messages: MonitoringMessage[];\n  llmCalls: LLMCall[];\n  loading?: boolean;\n}\n\ninterface ChartDataPoint {\n  time: string;\n  timestamp: number;\n  messages: number;\n  llmCalls: number;\n}\n\nexport default function TrafficChart({\n  messages,\n  llmCalls,\n  loading,\n}: TrafficChartProps) {\n  const { t } = useTranslation();\n\n  const chartData = useMemo(() => {\n    if (!messages.length && !llmCalls.length) {\n      return [];\n    }\n\n    // Combine all timestamps and find the range\n    const allTimestamps = [\n      ...messages.map((m) => m.timestamp.getTime()),\n      ...llmCalls.map((c) => c.timestamp.getTime()),\n    ];\n\n    if (allTimestamps.length === 0) return [];\n\n    const minTime = Math.min(...allTimestamps);\n    const maxTime = Math.max(...allTimestamps);\n    const timeRange = maxTime - minTime;\n\n    // Determine bucket size based on time range\n    let bucketSize: number;\n    let formatTime: (date: Date) => string;\n\n    if (timeRange <= 60 * 60 * 1000) {\n      // <= 1 hour: 5-minute buckets\n      bucketSize = 5 * 60 * 1000;\n      formatTime = (date) =>\n        date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n    } else if (timeRange <= 6 * 60 * 60 * 1000) {\n      // <= 6 hours: 15-minute buckets\n      bucketSize = 15 * 60 * 1000;\n      formatTime = (date) =>\n        date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n    } else if (timeRange <= 24 * 60 * 60 * 1000) {\n      // <= 24 hours: 1-hour buckets\n      bucketSize = 60 * 60 * 1000;\n      formatTime = (date) =>\n        date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n    } else if (timeRange <= 7 * 24 * 60 * 60 * 1000) {\n      // <= 7 days: 4-hour buckets\n      bucketSize = 4 * 60 * 60 * 1000;\n      formatTime = (date) =>\n        `${date.toLocaleDateString([], {\n          month: 'short',\n          day: 'numeric',\n        })} ${date.toLocaleTimeString([], { hour: '2-digit' })}`;\n    } else {\n      // > 7 days: 1-day buckets\n      bucketSize = 24 * 60 * 60 * 1000;\n      formatTime = (date) =>\n        date.toLocaleDateString([], { month: 'short', day: 'numeric' });\n    }\n\n    // Create buckets\n    const buckets: Map<number, ChartDataPoint> = new Map();\n    const startBucket = Math.floor(minTime / bucketSize) * bucketSize;\n    const endBucket = Math.ceil(maxTime / bucketSize) * bucketSize;\n\n    for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) {\n      buckets.set(bucket, {\n        time: formatTime(new Date(bucket)),\n        timestamp: bucket,\n        messages: 0,\n        llmCalls: 0,\n      });\n    }\n\n    // Count messages per bucket\n    messages.forEach((msg) => {\n      const bucket =\n        Math.floor(msg.timestamp.getTime() / bucketSize) * bucketSize;\n      const point = buckets.get(bucket);\n      if (point) {\n        point.messages++;\n      }\n    });\n\n    // Count LLM calls per bucket\n    llmCalls.forEach((call) => {\n      const bucket =\n        Math.floor(call.timestamp.getTime() / bucketSize) * bucketSize;\n      const point = buckets.get(bucket);\n      if (point) {\n        point.llmCalls++;\n      }\n    });\n\n    return Array.from(buckets.values()).sort(\n      (a, b) => a.timestamp - b.timestamp,\n    );\n  }, [messages, llmCalls]);\n\n  if (loading) {\n    return (\n      <div className=\"bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6\">\n        <div className=\"flex items-center justify-between mb-4\">\n          <div className=\"h-5 w-32 bg-gray-200 dark:bg-gray-700 animate-pulse rounded\"></div>\n          <div className=\"flex gap-4\">\n            <div className=\"h-4 w-24 bg-gray-200 dark:bg-gray-700 animate-pulse rounded\"></div>\n            <div className=\"h-4 w-24 bg-gray-200 dark:bg-gray-700 animate-pulse rounded\"></div>\n          </div>\n        </div>\n        <div className=\"h-[300px] flex items-center justify-center\">\n          <div className=\"animate-pulse w-full h-full bg-gray-100 dark:bg-gray-800 rounded\"></div>\n        </div>\n      </div>\n    );\n  }\n\n  if (chartData.length === 0) {\n    return (\n      <div className=\"bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6\">\n        <h3 className=\"text-base font-semibold text-gray-800 dark:text-gray-200 mb-4\">\n          {t('monitoring.trafficChart.title')}\n        </h3>\n        <div className=\"h-[300px] flex flex-col items-center justify-center text-gray-400 dark:text-gray-500\">\n          <svg\n            className=\"w-16 h-16 mb-4 text-gray-300 dark:text-gray-600\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n          >\n            <path\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth={1.5}\n              d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"\n            />\n          </svg>\n          <p className=\"text-sm font-medium\">\n            {t('monitoring.trafficChart.noData')}\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6 hover:shadow-md transition-shadow duration-300\">\n      <h3 className=\"text-base font-semibold text-gray-800 dark:text-gray-200 mb-6\">\n        {t('monitoring.trafficChart.title')}\n      </h3>\n      <div className=\"h-[300px]\">\n        <ResponsiveContainer width=\"100%\" height=\"100%\">\n          <AreaChart\n            data={chartData}\n            margin={{ top: 10, right: 20, left: 0, bottom: 0 }}\n          >\n            <defs>\n              <linearGradient id=\"colorMessages\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"5%\" stopColor=\"#3b82f6\" stopOpacity={0.4} />\n                <stop offset=\"95%\" stopColor=\"#3b82f6\" stopOpacity={0.05} />\n              </linearGradient>\n              <linearGradient id=\"colorLLMCalls\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"5%\" stopColor=\"#8b5cf6\" stopOpacity={0.4} />\n                <stop offset=\"95%\" stopColor=\"#8b5cf6\" stopOpacity={0.05} />\n              </linearGradient>\n            </defs>\n            <CartesianGrid\n              strokeDasharray=\"3 3\"\n              stroke=\"#e5e7eb\"\n              className=\"dark:stroke-gray-700\"\n              vertical={false}\n            />\n            <XAxis\n              dataKey=\"time\"\n              tick={{ fontSize: 12, fill: '#9ca3af' }}\n              tickLine={false}\n              axisLine={{ stroke: '#e5e7eb' }}\n              dy={10}\n            />\n            <YAxis\n              tick={{ fontSize: 12, fill: '#9ca3af' }}\n              tickLine={false}\n              axisLine={{ stroke: '#e5e7eb' }}\n              width={40}\n              allowDecimals={false}\n            />\n            <Tooltip\n              contentStyle={{\n                backgroundColor: 'rgba(255, 255, 255, 0.98)',\n                border: '1px solid #e5e7eb',\n                borderRadius: '12px',\n                boxShadow:\n                  '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',\n                fontSize: '13px',\n                padding: '12px',\n              }}\n              labelStyle={{\n                fontWeight: 600,\n                marginBottom: '8px',\n                color: '#374151',\n              }}\n              itemStyle={{ padding: '4px 0' }}\n            />\n            <Legend\n              wrapperStyle={{\n                fontSize: '13px',\n                paddingTop: '16px',\n                fontWeight: 500,\n              }}\n              iconType=\"circle\"\n              iconSize={10}\n            />\n            <Area\n              type=\"monotone\"\n              dataKey=\"messages\"\n              name={t('monitoring.trafficChart.messages')}\n              stroke=\"#3b82f6\"\n              strokeWidth={2.5}\n              fillOpacity={1}\n              fill=\"url(#colorMessages)\"\n              dot={false}\n              activeDot={{ r: 6, strokeWidth: 2 }}\n            />\n            <Area\n              type=\"monotone\"\n              dataKey=\"llmCalls\"\n              name={t('monitoring.trafficChart.llmCalls')}\n              stroke=\"#8b5cf6\"\n              strokeWidth={2.5}\n              fillOpacity={1}\n              fill=\"url(#colorLLMCalls)\"\n              dot={false}\n              activeDot={{ r: 6, strokeWidth: 2 }}\n            />\n          </AreaChart>\n        </ResponsiveContainer>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/hooks/useMonitoringData.ts",
    "content": "import { useState, useEffect, useCallback, useMemo } from 'react';\nimport {\n  FilterState,\n  MonitoringData,\n  ModelCall,\n  LLMCall,\n  EmbeddingCall,\n} from '../types/monitoring';\nimport { backendClient } from '@/app/infra/http';\n\n/**\n * Custom hook for fetching and managing monitoring data\n */\nexport function useMonitoringData(filterState: FilterState) {\n  const [data, setData] = useState<MonitoringData | null>(null);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<Error | null>(null);\n\n  // Memoize filter parameters to prevent unnecessary re-renders\n  const selectedBotsStr = useMemo(\n    () => JSON.stringify(filterState.selectedBots),\n    [filterState.selectedBots],\n  );\n  const selectedPipelinesStr = useMemo(\n    () => JSON.stringify(filterState.selectedPipelines),\n    [filterState.selectedPipelines],\n  );\n  const customDateRangeStr = useMemo(\n    () => JSON.stringify(filterState.customDateRange),\n    [filterState.customDateRange],\n  );\n\n  // Convert time range to datetime strings\n  const getTimeRange = useCallback(() => {\n    const now = new Date();\n    let startTime: Date | null = null;\n\n    switch (filterState.timeRange) {\n      case 'lastHour':\n        startTime = new Date(now.getTime() - 60 * 60 * 1000);\n        break;\n      case 'last6Hours':\n        startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);\n        break;\n      case 'last24Hours':\n        startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);\n        break;\n      case 'last7Days':\n        startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);\n        break;\n      case 'last30Days':\n        startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);\n        break;\n      case 'custom':\n        if (filterState.customDateRange) {\n          startTime = filterState.customDateRange.from;\n        }\n        break;\n    }\n\n    const endTime =\n      filterState.timeRange === 'custom' && filterState.customDateRange\n        ? filterState.customDateRange.to\n        : now;\n\n    return {\n      startTime: startTime?.toISOString(),\n      endTime: endTime.toISOString(),\n    };\n  }, [filterState.timeRange, filterState.customDateRange]);\n\n  // Fetch data based on filters\n  const fetchData = useCallback(async () => {\n    setLoading(true);\n    setError(null);\n\n    try {\n      const { startTime, endTime } = getTimeRange();\n\n      const response = await backendClient.getMonitoringData({\n        botId:\n          filterState.selectedBots.length > 0\n            ? filterState.selectedBots\n            : undefined,\n        pipelineId:\n          filterState.selectedPipelines.length > 0\n            ? filterState.selectedPipelines\n            : undefined,\n        startTime,\n        endTime,\n        limit: 50,\n      });\n\n      // Transform the response to match MonitoringData interface\n      const transformedData: MonitoringData = {\n        overview: {\n          totalMessages: response.overview.total_messages,\n          llmCalls: response.overview.llm_calls,\n          embeddingCalls: response.overview.embedding_calls || 0,\n          modelCalls:\n            response.overview.model_calls || response.overview.llm_calls,\n          successRate: response.overview.success_rate,\n          activeSessions: response.overview.active_sessions,\n        },\n        messages: response.messages.map(\n          (msg: {\n            id: string;\n            timestamp: string;\n            bot_id: string;\n            bot_name: string;\n            pipeline_id: string;\n            pipeline_name: string;\n            message_content: string;\n            session_id: string;\n            status: string;\n            level: string;\n            platform?: string;\n            user_id?: string;\n            runner_name?: string;\n            variables?: string;\n          }) => ({\n            id: msg.id,\n            timestamp: new Date(msg.timestamp),\n            botId: msg.bot_id,\n            botName: msg.bot_name,\n            pipelineId: msg.pipeline_id,\n            pipelineName: msg.pipeline_name,\n            messageContent: msg.message_content,\n            sessionId: msg.session_id,\n            status: msg.status as 'success' | 'error' | 'pending',\n            level: msg.level as 'info' | 'warning' | 'error' | 'debug',\n            platform: msg.platform,\n            userId: msg.user_id,\n            runnerName: msg.runner_name,\n            variables: msg.variables,\n          }),\n        ),\n        llmCalls: response.llmCalls.map(\n          (call: {\n            id: string;\n            timestamp: string;\n            model_name: string;\n            input_tokens: number;\n            output_tokens: number;\n            total_tokens: number;\n            duration: number;\n            cost?: number;\n            status: string;\n            bot_id: string;\n            bot_name: string;\n            pipeline_id: string;\n            pipeline_name: string;\n            error_message?: string;\n            message_id?: string;\n          }) => ({\n            id: call.id,\n            timestamp: new Date(call.timestamp),\n            modelName: call.model_name,\n            tokens: {\n              input: call.input_tokens,\n              output: call.output_tokens,\n              total: call.total_tokens,\n            },\n            duration: call.duration,\n            cost: call.cost,\n            status: call.status as 'success' | 'error',\n            botId: call.bot_id,\n            botName: call.bot_name,\n            pipelineId: call.pipeline_id,\n            pipelineName: call.pipeline_name,\n            errorMessage: call.error_message,\n            messageId: call.message_id,\n          }),\n        ),\n        embeddingCalls: (response.embeddingCalls || []).map(\n          (call: {\n            id: string;\n            timestamp: string;\n            model_name: string;\n            prompt_tokens: number;\n            total_tokens: number;\n            duration: number;\n            input_count: number;\n            status: string;\n            error_message?: string;\n            knowledge_base_id?: string;\n            query_text?: string;\n            session_id?: string;\n            message_id?: string;\n            call_type?: string;\n          }) => ({\n            id: call.id,\n            timestamp: new Date(call.timestamp),\n            modelName: call.model_name,\n            promptTokens: call.prompt_tokens,\n            totalTokens: call.total_tokens,\n            duration: call.duration,\n            inputCount: call.input_count,\n            status: call.status as 'success' | 'error',\n            errorMessage: call.error_message,\n            knowledgeBaseId: call.knowledge_base_id,\n            queryText: call.query_text,\n            sessionId: call.session_id,\n            messageId: call.message_id,\n            callType: call.call_type as 'embedding' | 'retrieve' | undefined,\n          }),\n        ),\n        // Create merged modelCalls array from llmCalls and embeddingCalls\n        modelCalls: [] as ModelCall[], // Will be populated after transform\n        sessions: response.sessions.map(\n          (session: {\n            session_id: string;\n            bot_id: string;\n            bot_name: string;\n            pipeline_id: string;\n            pipeline_name: string;\n            message_count: number;\n            last_activity: string;\n            start_time: string;\n            platform?: string;\n            user_id?: string;\n          }) => ({\n            sessionId: session.session_id,\n            botId: session.bot_id,\n            botName: session.bot_name,\n            pipelineId: session.pipeline_id,\n            pipelineName: session.pipeline_name,\n            messageCount: session.message_count,\n            duration:\n              new Date(session.last_activity).getTime() -\n              new Date(session.start_time).getTime(),\n            lastActivity: new Date(session.last_activity),\n            startTime: new Date(session.start_time),\n            platform: session.platform,\n            userId: session.user_id,\n          }),\n        ),\n        errors: response.errors.map(\n          (error: {\n            id: string;\n            timestamp: string;\n            error_type: string;\n            error_message: string;\n            bot_id: string;\n            bot_name: string;\n            pipeline_id: string;\n            pipeline_name: string;\n            session_id?: string;\n            stack_trace?: string;\n            message_id?: string;\n          }) => ({\n            id: error.id,\n            timestamp: new Date(error.timestamp),\n            errorType: error.error_type,\n            errorMessage: error.error_message,\n            botId: error.bot_id,\n            botName: error.bot_name,\n            pipelineId: error.pipeline_id,\n            pipelineName: error.pipeline_name,\n            sessionId: error.session_id,\n            stackTrace: error.stack_trace,\n            messageId: error.message_id,\n          }),\n        ),\n        totalCount: {\n          messages: response.totalCount.messages,\n          llmCalls: response.totalCount.llmCalls,\n          embeddingCalls: response.totalCount.embeddingCalls || 0,\n          sessions: response.totalCount.sessions,\n          errors: response.totalCount.errors,\n        },\n      };\n\n      // Merge LLM calls and embedding calls into modelCalls\n      const llmModelCalls: ModelCall[] = transformedData.llmCalls.map(\n        (call: LLMCall): ModelCall => ({\n          id: call.id,\n          timestamp: call.timestamp,\n          modelName: call.modelName,\n          modelType: 'llm',\n          status: call.status,\n          duration: call.duration,\n          errorMessage: call.errorMessage,\n          messageId: call.messageId,\n          tokens: call.tokens,\n          cost: call.cost,\n          botId: call.botId,\n          botName: call.botName,\n          pipelineId: call.pipelineId,\n          pipelineName: call.pipelineName,\n        }),\n      );\n\n      const embeddingModelCalls: ModelCall[] =\n        transformedData.embeddingCalls.map(\n          (call: EmbeddingCall): ModelCall => ({\n            id: call.id,\n            timestamp: call.timestamp,\n            modelName: call.modelName,\n            modelType: 'embedding',\n            status: call.status,\n            duration: call.duration,\n            errorMessage: call.errorMessage,\n            messageId: call.messageId,\n            callType: call.callType,\n            promptTokens: call.promptTokens,\n            totalTokens: call.totalTokens,\n            inputCount: call.inputCount,\n            knowledgeBaseId: call.knowledgeBaseId,\n            queryText: call.queryText,\n            sessionId: call.sessionId,\n          }),\n        );\n\n      // Combine and sort by timestamp (newest first)\n      transformedData.modelCalls = [\n        ...llmModelCalls,\n        ...embeddingModelCalls,\n      ].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());\n\n      setData(transformedData);\n    } catch (err) {\n      setError(err as Error);\n      console.error('Failed to fetch monitoring data:', err);\n    } finally {\n      setLoading(false);\n    }\n  }, [getTimeRange, filterState.selectedBots, filterState.selectedPipelines]);\n\n  // Fetch data when filter state changes\n  useEffect(() => {\n    fetchData();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [\n    selectedBotsStr,\n    selectedPipelinesStr,\n    filterState.timeRange,\n    customDateRangeStr,\n  ]);\n\n  // Manual refetch function\n  const refetch = () => {\n    fetchData();\n  };\n\n  return {\n    data,\n    loading,\n    error,\n    refetch,\n  };\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/hooks/useMonitoringFilters.ts",
    "content": "import { useState } from 'react';\nimport { useSearchParams } from 'next/navigation';\nimport { FilterState, TimeRangeOption, DateRange } from '../types/monitoring';\nimport { getPresetDateRange } from '../utils/dateUtils';\n\n/**\n * Custom hook for managing monitoring filters\n */\nexport function useMonitoringFilters() {\n  const searchParams = useSearchParams();\n\n  // Initialize filters from URL params\n  const [selectedBots, setSelectedBots] = useState<string[]>(() => {\n    const botId = searchParams.get('botId');\n    return botId ? [botId] : [];\n  });\n\n  const [selectedPipelines, setSelectedPipelines] = useState<string[]>(() => {\n    const pipelineId = searchParams.get('pipelineId');\n    return pipelineId ? [pipelineId] : [];\n  });\n\n  const [timeRange, setTimeRange] = useState<TimeRangeOption>('last24Hours');\n  const [customDateRange, setCustomDateRange] = useState<DateRange | null>(\n    null,\n  );\n\n  // Get the active date range (either preset or custom)\n  const getActiveDateRange = (): DateRange | null => {\n    if (timeRange === 'custom' && customDateRange) {\n      return customDateRange;\n    }\n    return getPresetDateRange(timeRange);\n  };\n\n  // Reset all filters\n  const resetFilters = () => {\n    setSelectedBots([]);\n    setSelectedPipelines([]);\n    setTimeRange('last24Hours');\n    setCustomDateRange(null);\n  };\n\n  // Get the current filter state\n  const filterState: FilterState = {\n    selectedBots,\n    selectedPipelines,\n    timeRange,\n    customDateRange,\n  };\n\n  return {\n    selectedBots,\n    setSelectedBots,\n    selectedPipelines,\n    setSelectedPipelines,\n    timeRange,\n    setTimeRange,\n    customDateRange,\n    setCustomDateRange,\n    getActiveDateRange,\n    resetFilters,\n    filterState,\n  };\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/page.tsx",
    "content": "'use client';\n\nimport React, { Suspense, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { Button } from '@/components/ui/button';\nimport { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';\nimport OverviewCards from './components/overview-cards/OverviewCards';\nimport MonitoringFilters from './components/filters/MonitoringFilters';\nimport { ExportDropdown } from './components/ExportDropdown';\nimport { useMonitoringFilters } from './hooks/useMonitoringFilters';\nimport { useMonitoringData } from './hooks/useMonitoringData';\nimport { MessageDetailsCard } from './components/MessageDetailsCard';\nimport { MessageContentRenderer } from './components/MessageContentRenderer';\nimport { MessageDetails } from './types/monitoring';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';\n\ninterface RawMessageData {\n  id: string;\n  timestamp: string;\n  bot_id: string;\n  bot_name: string;\n  pipeline_id: string;\n  pipeline_name: string;\n  message_content: string;\n  session_id: string;\n  status: string;\n  level: string;\n  platform: string;\n  user_id: string;\n  runner_name: string;\n  variables: Record<string, unknown>;\n}\n\ninterface RawLLMCallData {\n  id: string;\n  timestamp: string;\n  model_name: string;\n  status: string;\n  duration: number;\n  error_message: string | null;\n  input_tokens: number;\n  output_tokens: number;\n  total_tokens: number;\n}\n\ninterface RawLLMStatsData {\n  total_calls: number;\n  total_input_tokens: number;\n  total_output_tokens: number;\n  total_tokens: number;\n  total_duration_ms: number;\n  average_duration_ms: number;\n}\n\ninterface RawErrorData {\n  id: string;\n  timestamp: string;\n  error_type: string;\n  error_message: string;\n  stack_trace: string | null;\n}\n\nfunction MonitoringPageContent() {\n  const { t } = useTranslation();\n  const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =\n    useMonitoringFilters();\n  const { data, loading, refetch } = useMonitoringData(filterState);\n\n  const [expandedMessageId, setExpandedMessageId] = useState<string | null>(\n    null,\n  );\n  const [messageDetails, setMessageDetails] = useState<\n    Record<string, MessageDetails>\n  >({});\n  const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(\n    {},\n  );\n\n  // State for expanded errors\n  const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);\n\n  // State for controlled tabs\n  const [activeTab, setActiveTab] = useState<string>('messages');\n\n  // Function to jump to a message record\n  const jumpToMessage = async (messageId: string) => {\n    setActiveTab('messages');\n    // Small delay to ensure tab switch completes\n    setTimeout(() => {\n      toggleMessageExpand(messageId);\n    }, 100);\n  };\n\n  const toggleMessageExpand = async (messageId: string) => {\n    if (expandedMessageId === messageId) {\n      // Collapse\n      setExpandedMessageId(null);\n    } else {\n      // Expand\n      setExpandedMessageId(messageId);\n\n      // Fetch details if not already loaded\n      if (!messageDetails[messageId]) {\n        setLoadingDetails({ ...loadingDetails, [messageId]: true });\n        try {\n          // httpClient.get() returns the inner data directly (response.data.data)\n          const result = await httpClient.get<{\n            message_id: string;\n            found: boolean;\n            message: RawMessageData | null;\n            llm_calls: RawLLMCallData[];\n            llm_stats: RawLLMStatsData;\n            errors: RawErrorData[];\n          }>(`/api/v1/monitoring/messages/${messageId}/details`);\n\n          if (result) {\n            setMessageDetails((prev) => ({\n              ...prev,\n              [messageId]: {\n                messageId: result.message_id,\n                found: result.found,\n                message: result.message\n                  ? {\n                      id: result.message.id,\n                      timestamp: new Date(result.message.timestamp),\n                      botId: result.message.bot_id,\n                      botName: result.message.bot_name,\n                      pipelineId: result.message.pipeline_id,\n                      pipelineName: result.message.pipeline_name,\n                      messageContent: result.message.message_content,\n                      sessionId: result.message.session_id,\n                      status: result.message.status,\n                      level: result.message.level,\n                      platform: result.message.platform,\n                      userId: result.message.user_id,\n                      runnerName: result.message.runner_name,\n                      variables: result.message.variables,\n                    }\n                  : undefined,\n                llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({\n                  id: call.id,\n                  timestamp: new Date(call.timestamp),\n                  modelName: call.model_name,\n                  status: call.status,\n                  duration: call.duration,\n                  errorMessage: call.error_message,\n                  tokens: {\n                    input: call.input_tokens || 0,\n                    output: call.output_tokens || 0,\n                    total: call.total_tokens || 0,\n                  },\n                })),\n                errors: result.errors.map((error: RawErrorData) => ({\n                  id: error.id,\n                  timestamp: new Date(error.timestamp),\n                  errorType: error.error_type,\n                  errorMessage: error.error_message,\n                  stackTrace: error.stack_trace,\n                })),\n                llmStats: {\n                  totalCalls: result.llm_stats.total_calls,\n                  totalInputTokens: result.llm_stats.total_input_tokens,\n                  totalOutputTokens: result.llm_stats.total_output_tokens,\n                  totalTokens: result.llm_stats.total_tokens,\n                  totalDurationMs: result.llm_stats.total_duration_ms,\n                  averageDurationMs: result.llm_stats.average_duration_ms,\n                },\n              } as MessageDetails,\n            }));\n          }\n        } catch (error) {\n          console.error('Failed to fetch message details:', error);\n        } finally {\n          setLoadingDetails({ ...loadingDetails, [messageId]: false });\n        }\n      }\n    }\n  };\n\n  const toggleErrorExpand = (errorId: string) => {\n    if (expandedErrorId === errorId) {\n      setExpandedErrorId(null);\n    } else {\n      setExpandedErrorId(errorId);\n    }\n  };\n\n  return (\n    <div className=\"w-full h-full\">\n      {/* Filters and Refresh Button - Sticky */}\n      <div className=\"sticky top-[-1.5rem] z-10 -ml-[2rem] -mr-[1.5rem] -mt-[1.5rem] pt-[1.5rem] pb-4 bg-[#fafafa] dark:bg-[#151518]\">\n        <div className=\"ml-[2rem] mr-[1.5rem] px-[0.8rem]\">\n          <div className=\"flex flex-wrap items-center justify-between gap-4 p-4 bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm\">\n            <MonitoringFilters\n              selectedBots={filterState.selectedBots}\n              selectedPipelines={filterState.selectedPipelines}\n              timeRange={filterState.timeRange}\n              onBotsChange={setSelectedBots}\n              onPipelinesChange={setSelectedPipelines}\n              onTimeRangeChange={setTimeRange}\n            />\n            <div className=\"flex items-center gap-2\">\n              <ExportDropdown filterState={filterState} />\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={refetch}\n                className=\"bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0\"\n              >\n                <svg\n                  className=\"w-4 h-4 mr-2\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"currentColor\"\n                >\n                  <path d=\"M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z\"></path>\n                </svg>\n                {t('monitoring.refreshData')}\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Content Area */}\n      <div className=\"flex flex-col gap-6 px-[0.8rem] pb-4\">\n        {/* Overview Section */}\n        <OverviewCards\n          metrics={data?.overview || null}\n          messages={data?.messages || []}\n          llmCalls={data?.llmCalls || []}\n          loading={loading}\n        />\n\n        {/* Tabs Section */}\n        <div className=\"bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden\">\n          <Tabs\n            value={activeTab}\n            onValueChange={setActiveTab}\n            className=\"w-full\"\n          >\n            <div className=\"px-6 pt-4\">\n              <TabsList className=\"bg-gray-100 dark:bg-[#1a1a1e] h-12 p-1\">\n                <TabsTrigger\n                  value=\"messages\"\n                  className=\"px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm\"\n                >\n                  {t('monitoring.tabs.messages')}\n                </TabsTrigger>\n                <TabsTrigger\n                  value=\"modelCalls\"\n                  className=\"px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm\"\n                >\n                  {t('monitoring.tabs.modelCalls')}\n                </TabsTrigger>\n                <TabsTrigger\n                  value=\"errors\"\n                  className=\"px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm\"\n                >\n                  {t('monitoring.tabs.errors')}\n                </TabsTrigger>\n              </TabsList>\n            </div>\n\n            <TabsContent value=\"messages\" className=\"p-6 m-0\">\n              <div>\n                {loading && (\n                  <div className=\"py-12 flex justify-center\">\n                    <LoadingSpinner\n                      text={t('monitoring.messageList.loading')}\n                    />\n                  </div>\n                )}\n\n                {!loading &&\n                  data &&\n                  data.messages &&\n                  data.messages.length > 0 && (\n                    <div className=\"space-y-4\">\n                      {data.messages\n                        .filter((msg) => {\n                          // Filter out messages with empty content\n                          const content = msg.messageContent?.trim();\n                          return (\n                            content && content !== '[]' && content !== '\"\"'\n                          );\n                        })\n                        .map((msg) => (\n                          <div\n                            key={msg.id}\n                            className=\"border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden hover:shadow-md transition-all duration-200\"\n                          >\n                            {/* Message Header - Always Visible */}\n                            <div\n                              className=\"p-5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors\"\n                              onClick={() => toggleMessageExpand(msg.id)}\n                            >\n                              <div className=\"flex items-start justify-between\">\n                                <div className=\"flex items-start flex-1\">\n                                  {/* Expand Icon */}\n                                  <div className=\"mr-3 mt-0.5\">\n                                    {expandedMessageId === msg.id ? (\n                                      <ChevronDown className=\"w-5 h-5 text-gray-500\" />\n                                    ) : (\n                                      <ChevronRight className=\"w-5 h-5 text-gray-500\" />\n                                    )}\n                                  </div>\n\n                                  {/* Message Info */}\n                                  <div className=\"flex-1\">\n                                    <div className=\"flex items-center gap-2 mb-1\">\n                                      <span className=\"text-xs text-gray-400 dark:text-gray-500 font-mono\">\n                                        ID: {msg.id}\n                                      </span>\n                                    </div>\n                                    <div className=\"flex items-center gap-2 mb-2\">\n                                      <span className=\"font-medium text-sm text-gray-700 dark:text-gray-300\">\n                                        {msg.botName}\n                                      </span>\n                                      <span className=\"text-gray-400\">→</span>\n                                      <span className=\"text-sm text-gray-600 dark:text-gray-400\">\n                                        {msg.pipelineName}\n                                      </span>\n                                      {msg.runnerName && (\n                                        <>\n                                          <span className=\"text-gray-400\">\n                                            →\n                                          </span>\n                                          <span className=\"text-sm text-gray-600 dark:text-gray-400\">\n                                            {msg.runnerName}\n                                          </span>\n                                        </>\n                                      )}\n                                    </div>\n                                    <div className=\"text-base text-gray-800 dark:text-gray-200\">\n                                      <MessageContentRenderer\n                                        content={msg.messageContent}\n                                        maxLines={3}\n                                      />\n                                    </div>\n                                  </div>\n                                </div>\n\n                                {/* Status and Timestamp */}\n                                <div className=\"flex flex-col items-end gap-2 ml-4\">\n                                  <span className=\"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap\">\n                                    {msg.timestamp.toLocaleString()}\n                                  </span>\n                                  <span\n                                    className={`text-xs px-2 py-1 rounded ${\n                                      msg.level === 'error'\n                                        ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'\n                                        : msg.level === 'warning'\n                                          ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'\n                                          : 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'\n                                    }`}\n                                  >\n                                    {msg.level}\n                                  </span>\n                                </div>\n                              </div>\n                            </div>\n\n                            {/* Expanded Details */}\n                            {expandedMessageId === msg.id && (\n                              <div className=\"border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900\">\n                                {loadingDetails[msg.id] && (\n                                  <div className=\"py-4 flex justify-center\">\n                                    <LoadingSpinner size=\"sm\" text=\"\" />\n                                  </div>\n                                )}\n                                {!loadingDetails[msg.id] &&\n                                  messageDetails[msg.id] && (\n                                    <MessageDetailsCard\n                                      details={messageDetails[msg.id]}\n                                    />\n                                  )}\n                              </div>\n                            )}\n                          </div>\n                        ))}\n                    </div>\n                  )}\n\n                {!loading &&\n                  (!data || !data.messages || data.messages.length === 0) && (\n                    <div className=\"text-center text-gray-500 dark:text-gray-400 py-16\">\n                      <svg\n                        className=\"w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600\"\n                        fill=\"none\"\n                        viewBox=\"0 0 24 24\"\n                        stroke=\"currentColor\"\n                      >\n                        <path\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          strokeWidth={1.5}\n                          d=\"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z\"\n                        />\n                      </svg>\n                      <p className=\"text-base font-medium mb-2\">\n                        {t('monitoring.messageList.noMessages')}\n                      </p>\n                      <p className=\"text-sm\">\n                        {t('monitoring.messageList.noMessagesDescription')}\n                      </p>\n                    </div>\n                  )}\n              </div>\n            </TabsContent>\n\n            <TabsContent value=\"modelCalls\" className=\"p-6 m-0\">\n              <div>\n                {loading && (\n                  <div className=\"py-12 flex justify-center\">\n                    <LoadingSpinner text={t('common.loading')} />\n                  </div>\n                )}\n\n                {!loading &&\n                  data &&\n                  data.modelCalls &&\n                  data.modelCalls.length > 0 && (\n                    <div className=\"space-y-4\">\n                      {data.modelCalls.map((call) => (\n                        <div\n                          key={call.id}\n                          className=\"border border-gray-200 dark:border-gray-700 rounded-xl p-5 hover:shadow-md transition-all duration-200\"\n                        >\n                          <div className=\"flex justify-between items-start mb-3\">\n                            <div className=\"flex-1\">\n                              {/* Query ID - only show if messageId exists */}\n                              {call.messageId && (\n                                <div className=\"flex items-center gap-2 mb-1\">\n                                  <span className=\"text-xs text-gray-400 dark:text-gray-500 font-mono\">\n                                    Query ID: {call.messageId}\n                                  </span>\n                                  <Button\n                                    variant=\"ghost\"\n                                    size=\"sm\"\n                                    className=\"h-5 px-1.5 text-xs\"\n                                    onClick={() =>\n                                      jumpToMessage(call.messageId!)\n                                    }\n                                  >\n                                    <ExternalLink className=\"w-3 h-3 mr-1\" />\n                                    {t(\n                                      'monitoring.messageList.viewConversation',\n                                    )}\n                                  </Button>\n                                </div>\n                              )}\n                              <div className=\"flex items-center gap-2 mb-2\">\n                                {/* Model Type Badge */}\n                                <span\n                                  className={`text-xs px-2 py-1 rounded ${\n                                    call.modelType === 'llm'\n                                      ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'\n                                      : 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'\n                                  }`}\n                                >\n                                  {call.modelType === 'llm'\n                                    ? t('monitoring.modelCalls.llmModel')\n                                    : t('monitoring.modelCalls.embeddingModel')}\n                                </span>\n                                {/* Call Type Badge for Embedding */}\n                                {call.modelType === 'embedding' &&\n                                  call.callType && (\n                                    <span\n                                      className={`text-xs px-2 py-1 rounded ${\n                                        call.callType === 'retrieve'\n                                          ? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'\n                                          : 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'\n                                      }`}\n                                    >\n                                      {call.callType === 'retrieve'\n                                        ? t(\n                                            'monitoring.modelCalls.retrieveCall',\n                                          )\n                                        : t(\n                                            'monitoring.modelCalls.embeddingCall',\n                                          )}\n                                    </span>\n                                  )}\n                                {/* Status Badge */}\n                                <span\n                                  className={`text-xs px-2 py-1 rounded ${\n                                    call.status === 'success'\n                                      ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'\n                                      : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'\n                                  }`}\n                                >\n                                  {call.status}\n                                </span>\n                              </div>\n                              {/* Model Name */}\n                              <div className=\"font-medium text-sm text-gray-700 dark:text-gray-300 mb-2\">\n                                {call.modelName}\n                              </div>\n                              {/* Context Info - only for LLM calls */}\n                              {call.modelType === 'llm' &&\n                                call.botName &&\n                                call.pipelineName && (\n                                  <div className=\"text-xs text-gray-600 dark:text-gray-400 mb-1\">\n                                    {call.botName} → {call.pipelineName}\n                                  </div>\n                                )}\n                              {/* Token Info */}\n                              <div className=\"text-xs text-gray-600 dark:text-gray-400 space-y-1\">\n                                <div className=\"flex flex-wrap gap-4\">\n                                  {call.modelType === 'llm' && call.tokens && (\n                                    <>\n                                      <span>\n                                        {t('monitoring.llmCalls.inputTokens')}:{' '}\n                                        {call.tokens.input}\n                                      </span>\n                                      <span>\n                                        {t('monitoring.llmCalls.outputTokens')}:{' '}\n                                        {call.tokens.output}\n                                      </span>\n                                      <span>\n                                        {t('monitoring.llmCalls.totalTokens')}:{' '}\n                                        {call.tokens.total}\n                                      </span>\n                                    </>\n                                  )}\n                                  {call.modelType === 'embedding' && (\n                                    <>\n                                      <span>\n                                        {t(\n                                          'monitoring.embeddingCalls.promptTokens',\n                                        )}\n                                        : {call.promptTokens}\n                                      </span>\n                                      <span>\n                                        {t(\n                                          'monitoring.embeddingCalls.totalTokens',\n                                        )}\n                                        : {call.totalTokens}\n                                      </span>\n                                      <span>\n                                        {t(\n                                          'monitoring.embeddingCalls.inputCount',\n                                        )}\n                                        : {call.inputCount}\n                                      </span>\n                                    </>\n                                  )}\n                                  <span>\n                                    {t('monitoring.llmCalls.duration')}:{' '}\n                                    {call.duration}ms\n                                  </span>\n                                  {call.cost && (\n                                    <span>\n                                      {t('monitoring.llmCalls.cost')}: $\n                                      {call.cost.toFixed(4)}\n                                    </span>\n                                  )}\n                                </div>\n                                {/* Knowledge Base Info for Embedding */}\n                                {call.modelType === 'embedding' &&\n                                  call.knowledgeBaseId && (\n                                    <div>\n                                      {t(\n                                        'monitoring.embeddingCalls.knowledgeBase',\n                                      )}\n                                      : {call.knowledgeBaseId}\n                                    </div>\n                                  )}\n                                {/* Query Text for Embedding Retrieve */}\n                                {call.modelType === 'embedding' &&\n                                  call.queryText && (\n                                    <div className=\"mt-2 p-2 bg-gray-50 dark:bg-gray-800 rounded text-sm\">\n                                      <span className=\"text-gray-500 dark:text-gray-400\">\n                                        {t(\n                                          'monitoring.embeddingCalls.queryText',\n                                        )}\n                                        :{' '}\n                                      </span>\n                                      <span className=\"text-gray-700 dark:text-gray-300\">\n                                        {call.queryText.length > 100\n                                          ? call.queryText.substring(0, 100) +\n                                            '...'\n                                          : call.queryText}\n                                      </span>\n                                    </div>\n                                  )}\n                              </div>\n                              {call.errorMessage && (\n                                <div className=\"mt-2 text-xs text-red-600 dark:text-red-400\">\n                                  Error: {call.errorMessage}\n                                </div>\n                              )}\n                            </div>\n                            <span className=\"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4\">\n                              {call.timestamp.toLocaleString()}\n                            </span>\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  )}\n\n                {!loading &&\n                  (!data ||\n                    !data.modelCalls ||\n                    data.modelCalls.length === 0) && (\n                    <div className=\"text-center text-gray-500 dark:text-gray-400 py-16\">\n                      <svg\n                        className=\"w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600\"\n                        fill=\"none\"\n                        viewBox=\"0 0 24 24\"\n                        stroke=\"currentColor\"\n                      >\n                        <path\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          strokeWidth={1.5}\n                          d=\"M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z\"\n                        />\n                      </svg>\n                      <p className=\"text-base font-medium\">\n                        {t('monitoring.modelCalls.noData')}\n                      </p>\n                    </div>\n                  )}\n              </div>\n            </TabsContent>\n\n            <TabsContent value=\"errors\" className=\"p-6 m-0\">\n              <div>\n                {loading && (\n                  <div className=\"py-12 flex justify-center\">\n                    <LoadingSpinner text={t('common.loading')} />\n                  </div>\n                )}\n\n                {!loading && data && data.errors && data.errors.length > 0 && (\n                  <div className=\"space-y-4\">\n                    {data.errors.map((error) => (\n                      <div\n                        key={error.id}\n                        className=\"border border-red-200 dark:border-red-900 rounded-xl overflow-hidden hover:shadow-md transition-all duration-200\"\n                      >\n                        {/* Error Header - Always Visible */}\n                        <div\n                          className=\"p-5 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30\"\n                          onClick={() => toggleErrorExpand(error.id)}\n                        >\n                          <div className=\"flex items-start justify-between\">\n                            <div className=\"flex items-start flex-1\">\n                              {/* Expand Icon */}\n                              <div className=\"mr-3 mt-0.5\">\n                                {expandedErrorId === error.id ? (\n                                  <ChevronDown className=\"w-5 h-5 text-red-500\" />\n                                ) : (\n                                  <ChevronRight className=\"w-5 h-5 text-red-500\" />\n                                )}\n                              </div>\n\n                              {/* Error Info */}\n                              <div className=\"flex-1\">\n                                {/* Query ID */}\n                                <div className=\"flex items-center gap-2 mb-1\">\n                                  <span className=\"text-xs text-gray-400 dark:text-gray-500 font-mono\">\n                                    Query ID: {error.messageId || '-'}\n                                  </span>\n                                  {error.messageId && (\n                                    <Button\n                                      variant=\"ghost\"\n                                      size=\"sm\"\n                                      className=\"h-5 px-1.5 text-xs\"\n                                      onClick={(e) => {\n                                        e.stopPropagation();\n                                        jumpToMessage(error.messageId!);\n                                      }}\n                                    >\n                                      <ExternalLink className=\"w-3 h-3 mr-1\" />\n                                      {t(\n                                        'monitoring.messageList.viewConversation',\n                                      )}\n                                    </Button>\n                                  )}\n                                </div>\n                                <div className=\"flex items-center gap-2 mb-2\">\n                                  <span className=\"font-medium text-sm text-red-700 dark:text-red-300\">\n                                    {error.errorType}\n                                  </span>\n                                  <span className=\"text-red-400\">→</span>\n                                  <span className=\"text-sm text-gray-600 dark:text-gray-400\">\n                                    {error.botName}\n                                  </span>\n                                  <span className=\"text-red-400\">→</span>\n                                  <span className=\"text-sm text-gray-600 dark:text-gray-400\">\n                                    {error.pipelineName}\n                                  </span>\n                                </div>\n                                <p className=\"text-sm text-red-600 dark:text-red-400 line-clamp-2\">\n                                  {error.errorMessage}\n                                </p>\n                              </div>\n                            </div>\n\n                            {/* Timestamp */}\n                            <div className=\"flex flex-col items-end gap-2 ml-4\">\n                              <span className=\"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap\">\n                                {error.timestamp.toLocaleString()}\n                              </span>\n                            </div>\n                          </div>\n                        </div>\n\n                        {/* Expanded Details */}\n                        {expandedErrorId === error.id && (\n                          <div className=\"border-t border-red-200 dark:border-red-900 p-5 bg-white dark:bg-gray-900\">\n                            <div className=\"space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4\">\n                              {/* Error Details */}\n                              <div className=\"bg-red-50 dark:bg-red-900/20 rounded-lg p-3\">\n                                <h4 className=\"text-sm font-semibold text-red-700 dark:text-red-400 mb-3\">\n                                  {t('monitoring.errors.errorMessage')}\n                                </h4>\n                                <div className=\"text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words\">\n                                  {error.errorMessage}\n                                </div>\n                              </div>\n\n                              {/* Context Info */}\n                              <div className=\"bg-gray-50 dark:bg-gray-800 rounded-lg p-3\">\n                                <h4 className=\"text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3\">\n                                  {t('monitoring.messageList.viewDetails')}\n                                </h4>\n                                <div className=\"grid grid-cols-2 md:grid-cols-3 gap-2 text-xs\">\n                                  <div className=\"bg-white dark:bg-gray-900 rounded p-2\">\n                                    <div className=\"text-gray-500 dark:text-gray-400\">\n                                      {t('monitoring.messageList.bot')}\n                                    </div>\n                                    <div className=\"font-medium text-gray-900 dark:text-white\">\n                                      {error.botName}\n                                    </div>\n                                  </div>\n                                  <div className=\"bg-white dark:bg-gray-900 rounded p-2\">\n                                    <div className=\"text-gray-500 dark:text-gray-400\">\n                                      {t('monitoring.messageList.pipeline')}\n                                    </div>\n                                    <div className=\"font-medium text-gray-900 dark:text-white\">\n                                      {error.pipelineName}\n                                    </div>\n                                  </div>\n                                  {error.sessionId && (\n                                    <div className=\"bg-white dark:bg-gray-900 rounded p-2\">\n                                      <div className=\"text-gray-500 dark:text-gray-400\">\n                                        {t('monitoring.sessions.sessionId')}\n                                      </div>\n                                      <div className=\"font-medium text-gray-900 dark:text-white truncate\">\n                                        {error.sessionId}\n                                      </div>\n                                    </div>\n                                  )}\n                                </div>\n                              </div>\n\n                              {/* Stack Trace */}\n                              {error.stackTrace && (\n                                <div className=\"bg-gray-50 dark:bg-gray-800 rounded-lg p-3\">\n                                  <h4 className=\"text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3\">\n                                    {t('monitoring.errors.stackTrace')}\n                                  </h4>\n                                  <pre className=\"text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-60 bg-white dark:bg-gray-900 p-3 rounded whitespace-pre-wrap break-words\">\n                                    {error.stackTrace}\n                                  </pre>\n                                </div>\n                              )}\n                            </div>\n                          </div>\n                        )}\n                      </div>\n                    ))}\n                  </div>\n                )}\n\n                {!loading &&\n                  (!data || !data.errors || data.errors.length === 0) && (\n                    <div className=\"text-center text-gray-500 dark:text-gray-400 py-16\">\n                      <svg\n                        className=\"w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600\"\n                        fill=\"none\"\n                        viewBox=\"0 0 24 24\"\n                        stroke=\"currentColor\"\n                      >\n                        <path\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          strokeWidth={1.5}\n                          d=\"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z\"\n                        />\n                      </svg>\n                      <p className=\"text-base font-medium text-green-600 dark:text-green-400\">\n                        {t('monitoring.errors.noErrors')}\n                      </p>\n                    </div>\n                  )}\n              </div>\n            </TabsContent>\n          </Tabs>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default function MonitoringPage() {\n  return (\n    <Suspense fallback={<LoadingPage />}>\n      <MonitoringPageContent />\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/types/monitoring.ts",
    "content": "export interface MonitoringMessage {\n  id: string;\n  timestamp: Date;\n  botId: string;\n  botName: string;\n  pipelineId: string;\n  pipelineName: string;\n  messageContent: string;\n  sessionId: string;\n  status: 'success' | 'error' | 'pending';\n  level: 'info' | 'warning' | 'error' | 'debug';\n  platform?: string;\n  userId?: string;\n  runnerName?: string;\n  variables?: string;\n}\n\nexport interface LLMCall {\n  id: string;\n  timestamp: Date;\n  modelName: string;\n  tokens: {\n    input: number;\n    output: number;\n    total: number;\n  };\n  duration: number;\n  cost?: number;\n  status: 'success' | 'error';\n  botId: string;\n  botName: string;\n  pipelineId: string;\n  pipelineName: string;\n  errorMessage?: string;\n  messageId?: string;\n}\n\nexport interface EmbeddingCall {\n  id: string;\n  timestamp: Date;\n  modelName: string;\n  promptTokens: number;\n  totalTokens: number;\n  duration: number;\n  inputCount: number;\n  status: 'success' | 'error';\n  errorMessage?: string;\n  knowledgeBaseId?: string;\n  queryText?: string;\n  sessionId?: string;\n  messageId?: string;\n  callType?: 'embedding' | 'retrieve';\n}\n\n// Unified model call type for displaying LLM and Embedding calls together\nexport interface ModelCall {\n  id: string;\n  timestamp: Date;\n  modelName: string;\n  modelType: 'llm' | 'embedding';\n  status: 'success' | 'error';\n  duration: number;\n  errorMessage?: string;\n  messageId?: string;\n  // LLM specific fields\n  tokens?: {\n    input: number;\n    output: number;\n    total: number;\n  };\n  cost?: number;\n  botId?: string;\n  botName?: string;\n  pipelineId?: string;\n  pipelineName?: string;\n  // Embedding specific fields\n  callType?: 'embedding' | 'retrieve';\n  promptTokens?: number;\n  totalTokens?: number;\n  inputCount?: number;\n  knowledgeBaseId?: string;\n  queryText?: string;\n  sessionId?: string;\n}\n\nexport interface SessionInfo {\n  sessionId: string;\n  botId: string;\n  botName: string;\n  pipelineId: string;\n  pipelineName: string;\n  messageCount: number;\n  duration: number;\n  lastActivity: Date;\n  startTime: Date;\n  platform?: string;\n  userId?: string;\n}\n\nexport interface ErrorLog {\n  id: string;\n  timestamp: Date;\n  errorType: string;\n  errorMessage: string;\n  botId: string;\n  botName: string;\n  pipelineId: string;\n  pipelineName: string;\n  sessionId?: string;\n  stackTrace?: string;\n  messageId?: string;\n}\n\nexport interface MessageDetails {\n  messageId: string;\n  found: boolean;\n  message?: MonitoringMessage;\n  llmCalls: LLMCall[];\n  llmStats: {\n    totalCalls: number;\n    totalInputTokens: number;\n    totalOutputTokens: number;\n    totalTokens: number;\n    totalDurationMs: number;\n    averageDurationMs: number;\n  };\n  errors: ErrorLog[];\n}\n\nexport interface OverviewMetrics {\n  totalMessages: number;\n  llmCalls: number;\n  embeddingCalls: number;\n  modelCalls: number;\n  successRate: number;\n  activeSessions: number;\n  trends?: {\n    messages: number;\n    llmCalls: number;\n    successRate: number;\n    sessions: number;\n  };\n}\n\nexport interface FilterState {\n  selectedBots: string[];\n  selectedPipelines: string[];\n  timeRange: TimeRangeOption;\n  customDateRange: DateRange | null;\n}\n\nexport type TimeRangeOption =\n  | 'lastHour'\n  | 'last6Hours'\n  | 'last24Hours'\n  | 'last7Days'\n  | 'last30Days'\n  | 'custom';\n\nexport interface DateRange {\n  from: Date;\n  to: Date;\n}\n\nexport interface MonitoringData {\n  overview: OverviewMetrics;\n  messages: MonitoringMessage[];\n  llmCalls: LLMCall[];\n  embeddingCalls: EmbeddingCall[];\n  modelCalls: ModelCall[];\n  sessions: SessionInfo[];\n  errors: ErrorLog[];\n  totalCount: {\n    messages: number;\n    llmCalls: number;\n    embeddingCalls: number;\n    sessions: number;\n    errors: number;\n  };\n}\n"
  },
  {
    "path": "web/src/app/home/monitoring/utils/dateUtils.ts",
    "content": "import { DateRange, TimeRangeOption } from '../types/monitoring';\n\n/**\n * Get date range based on preset time range option\n */\nexport function getPresetDateRange(option: TimeRangeOption): DateRange | null {\n  if (option === 'custom') return null;\n\n  const now = new Date();\n  const from = new Date();\n\n  switch (option) {\n    case 'lastHour':\n      from.setHours(now.getHours() - 1);\n      break;\n    case 'last6Hours':\n      from.setHours(now.getHours() - 6);\n      break;\n    case 'last24Hours':\n      from.setHours(now.getHours() - 24);\n      break;\n    case 'last7Days':\n      from.setDate(now.getDate() - 7);\n      break;\n    case 'last30Days':\n      from.setDate(now.getDate() - 30);\n      break;\n    default:\n      return null;\n  }\n\n  return { from, to: now };\n}\n\n/**\n * Format timestamp to readable string\n */\nexport function formatTimestamp(date: Date): string {\n  const now = new Date();\n  const diff = now.getTime() - date.getTime();\n  const seconds = Math.floor(diff / 1000);\n  const minutes = Math.floor(seconds / 60);\n  const hours = Math.floor(minutes / 60);\n  const days = Math.floor(hours / 24);\n\n  if (seconds < 60) return `${seconds}s ago`;\n  if (minutes < 60) return `${minutes}m ago`;\n  if (hours < 24) return `${hours}h ago`;\n  if (days < 7) return `${days}d ago`;\n\n  return date.toLocaleString();\n}\n\n/**\n * Format date to YYYY-MM-DD\n */\nexport function formatDate(date: Date): string {\n  const year = date.getFullYear();\n  const month = String(date.getMonth() + 1).padStart(2, '0');\n  const day = String(date.getDate()).padStart(2, '0');\n  return `${year}-${month}-${day}`;\n}\n\n/**\n * Format date to YYYY-MM-DD HH:MM:SS\n */\nexport function formatDateTime(date: Date): string {\n  const dateStr = formatDate(date);\n  const hours = String(date.getHours()).padStart(2, '0');\n  const minutes = String(date.getMinutes()).padStart(2, '0');\n  const seconds = String(date.getSeconds()).padStart(2, '0');\n  return `${dateStr} ${hours}:${minutes}:${seconds}`;\n}\n\n/**\n * Format duration in seconds to readable string\n */\nexport function formatDuration(seconds: number): string {\n  if (seconds < 60) return `${seconds}s`;\n  const minutes = Math.floor(seconds / 60);\n  if (minutes < 60) return `${minutes}m ${seconds % 60}s`;\n  const hours = Math.floor(minutes / 60);\n  return `${hours}h ${minutes % 60}m`;\n}\n\n/**\n * Check if date is within range\n */\nexport function isDateInRange(date: Date, range: DateRange | null): boolean {\n  if (!range) return true;\n  return date >= range.from && date <= range.to;\n}\n\n/**\n * Parse date string to Date object\n */\nexport function parseDate(dateStr: string): Date {\n  return new Date(dateStr);\n}\n"
  },
  {
    "path": "web/src/app/home/page.tsx",
    "content": "export default function Home() {\n  return <div className={``}></div>;\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/PipelineDetailDialog.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useRouter } from 'next/navigation';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarProvider,\n} from '@/components/ui/sidebar';\nimport PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';\nimport DebugDialog from './components/debug-dialog/DebugDialog';\nimport PipelineExtension from './components/pipeline-extensions/PipelineExtension';\nimport PipelineMonitoringTab from './components/monitoring-tab/PipelineMonitoringTab';\n\ninterface PipelineDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  pipelineId?: string;\n  isEditMode?: boolean;\n  isDefaultPipeline?: boolean;\n  onFinish: () => void;\n  onNewPipelineCreated?: (pipelineId: string) => void;\n  onDeletePipeline: () => void;\n  onCancel: () => void;\n}\n\ntype DialogMode = 'config' | 'debug' | 'extensions' | 'monitoring';\n\nexport default function PipelineDialog({\n  open,\n  onOpenChange,\n  pipelineId: propPipelineId,\n  isEditMode = false,\n  onFinish,\n  onNewPipelineCreated,\n  onDeletePipeline,\n  onCancel,\n}: PipelineDialogProps) {\n  const { t } = useTranslation();\n  const router = useRouter();\n  const [pipelineId, setPipelineId] = useState<string | undefined>(\n    propPipelineId,\n  );\n  const [currentMode, setCurrentMode] = useState<DialogMode>('config');\n  const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);\n\n  useEffect(() => {\n    setPipelineId(propPipelineId);\n    setCurrentMode('config');\n  }, [propPipelineId, open]);\n\n  const handleFinish = () => {\n    onFinish();\n  };\n\n  const handleNewPipelineCreated = (newPipelineId: string) => {\n    setPipelineId(newPipelineId);\n    setCurrentMode('config');\n    if (onNewPipelineCreated) {\n      onNewPipelineCreated(newPipelineId);\n    }\n  };\n\n  const menu = [\n    {\n      key: 'config',\n      label: t('pipelines.configuration'),\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z\"></path>\n        </svg>\n      ),\n    },\n    {\n      key: 'extensions',\n      label: t('pipelines.extensions.title'),\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z\"></path>\n        </svg>\n      ),\n    },\n    {\n      key: 'debug',\n      label: t('pipelines.debugChat'),\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M13 19.9C15.2822 19.4367 17 17.419 17 15V12C17 11.299 16.8564 10.6219 16.5846 10H7.41538C7.14358 10.6219 7 11.299 7 12V15C7 17.419 8.71776 19.4367 11 19.9V14H13V19.9ZM5.5358 17.6907C5.19061 16.8623 5 15.9534 5 15H2V13H5V12C5 11.3573 5.08661 10.7348 5.2488 10.1436L3.0359 8.86602L4.0359 7.13397L6.05636 8.30049C6.11995 8.19854 6.18609 8.09835 6.25469 8H17.7453C17.8139 8.09835 17.88 8.19854 17.9436 8.30049L19.9641 7.13397L20.9641 8.86602L18.7512 10.1436C18.9134 10.7348 19 11.3573 19 12V13H22V15H19C19 15.9534 18.8094 16.8623 18.4642 17.6907L20.9641 19.134L19.9641 20.866L17.4383 19.4077C16.1549 20.9893 14.1955 22 12 22C9.80453 22 7.84512 20.9893 6.56171 19.4077L4.0359 20.866L3.0359 19.134L5.5358 17.6907ZM8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6H8Z\"></path>\n        </svg>\n      ),\n    },\n    {\n      key: 'monitoring',\n      label: t('pipelines.monitoring.title'),\n      icon: (\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z\"></path>\n        </svg>\n      ),\n    },\n  ];\n\n  const getDialogTitle = () => {\n    if (currentMode === 'config') {\n      return isEditMode\n        ? t('pipelines.editPipeline')\n        : t('pipelines.createPipeline');\n    }\n    if (currentMode === 'extensions') {\n      return t('pipelines.extensions.title');\n    }\n    if (currentMode === 'monitoring') {\n      return t('pipelines.monitoring.title');\n    }\n    return t('pipelines.debugDialog.title');\n  };\n\n  // 创建新流水线时的对话框\n  if (!isEditMode) {\n    return (\n      <Dialog open={open} onOpenChange={onOpenChange}>\n        <DialogContent className=\"overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex\">\n          <main className=\"flex flex-1 flex-col h-[70vh]\">\n            <DialogHeader className=\"px-6 pt-6 pb-4 shrink-0\">\n              <DialogTitle>{t('pipelines.createPipeline')}</DialogTitle>\n            </DialogHeader>\n            <div className=\"flex-1 overflow-y-auto px-6 pb-6\">\n              <PipelineFormComponent\n                onFinish={handleFinish}\n                onNewPipelineCreated={handleNewPipelineCreated}\n                isEditMode={isEditMode}\n                pipelineId={pipelineId}\n                disableForm={false}\n                showButtons={true}\n                onDeletePipeline={onDeletePipeline}\n                onCancel={() => {\n                  onCancel();\n                }}\n              />\n            </div>\n          </main>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n\n  // 编辑流水线时的对话框\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"overflow-hidden p-0 !max-w-[80vw] h-[75vh] flex\">\n        <SidebarProvider className=\"items-start w-full flex h-full min-h-0\">\n          <Sidebar\n            collapsible=\"none\"\n            className=\"hidden md:flex h-full min-h-0 w-40 border-r bg-white dark:bg-black\"\n          >\n            <SidebarContent>\n              <SidebarGroup>\n                <SidebarGroupContent>\n                  <SidebarMenu>\n                    {menu.map((item) => (\n                      <SidebarMenuItem key={item.key}>\n                        <SidebarMenuButton\n                          asChild\n                          isActive={currentMode === item.key}\n                          onClick={() => setCurrentMode(item.key as DialogMode)}\n                        >\n                          <a href=\"#\">\n                            {item.icon}\n                            <span>{item.label}</span>\n                          </a>\n                        </SidebarMenuButton>\n                      </SidebarMenuItem>\n                    ))}\n                  </SidebarMenu>\n                </SidebarGroupContent>\n              </SidebarGroup>\n            </SidebarContent>\n          </Sidebar>\n          <main className=\"flex flex-1 flex-col h-full min-h-0\">\n            <DialogHeader\n              className=\"px-6 pt-6 pb-4 shrink-0 flex flex-row items-center justify-start\"\n              style={{ height: '4rem' }}\n            >\n              <DialogTitle>{getDialogTitle()}</DialogTitle>\n              {currentMode === 'debug' && (\n                <div className=\"flex items-center gap-2 ml-2\">\n                  <div\n                    className={`w-2.5 h-2.5 rounded-full ${\n                      isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'\n                    }`}\n                    title={\n                      isWebSocketConnected\n                        ? t('pipelines.debugDialog.connected')\n                        : t('pipelines.debugDialog.disconnected')\n                    }\n                  />\n                  <span className=\"text-sm text-gray-600 dark:text-gray-400\">\n                    {isWebSocketConnected\n                      ? t('pipelines.debugDialog.connected')\n                      : t('pipelines.debugDialog.disconnected')}\n                  </span>\n                </div>\n              )}\n            </DialogHeader>\n            <div\n              className=\"flex-1 overflow-y-auto px-6 pb-4 w-full\"\n              style={{ height: 'calc(100% - 4rem)' }}\n            >\n              {currentMode === 'config' && (\n                <PipelineFormComponent\n                  onFinish={handleFinish}\n                  onNewPipelineCreated={handleNewPipelineCreated}\n                  isEditMode={isEditMode}\n                  pipelineId={pipelineId}\n                  disableForm={false}\n                  showButtons={true}\n                  onDeletePipeline={onDeletePipeline}\n                  onCancel={() => {\n                    onCancel();\n                  }}\n                />\n              )}\n\n              {currentMode === 'extensions' && pipelineId && (\n                <PipelineExtension pipelineId={pipelineId} />\n              )}\n\n              {currentMode === 'debug' && pipelineId && (\n                <DebugDialog\n                  open={true}\n                  pipelineId={pipelineId}\n                  isEmbedded={true}\n                  onConnectionStatusChange={setIsWebSocketConnected}\n                />\n              )}\n\n              {currentMode === 'monitoring' && pipelineId && (\n                <PipelineMonitoringTab\n                  pipelineId={pipelineId}\n                  onNavigateToMonitoring={() => {\n                    router.push(`/home/monitoring?pipelineId=${pipelineId}`);\n                    onOpenChange(false);\n                  }}\n                />\n              )}\n            </div>\n          </main>\n        </SidebarProvider>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/components/debug-dialog/AtBadge.tsx",
    "content": "import { Badge } from '@/components/ui/badge';\nimport { X } from 'lucide-react';\n\ninterface AtBadgeProps {\n  targetName: string;\n  readonly?: boolean;\n  onRemove?: () => void;\n}\n\nexport default function AtBadge({\n  targetName,\n  readonly = false,\n  onRemove,\n}: AtBadgeProps) {\n  return (\n    <Badge\n      variant=\"secondary\"\n      className=\"flex items-center gap-1 px-2 py-1 text-sm bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/60\"\n    >\n      @{targetName}\n      {!readonly && onRemove && (\n        <button\n          onClick={onRemove}\n          className=\"ml-1 hover:text-blue-800 dark:hover:text-blue-200 focus:outline-none\"\n        >\n          <X className=\"h-3 w-3\" />\n        </button>\n      )}\n    </Badge>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx",
    "content": "import React, { useState, useEffect, useRef, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { DialogContent } from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Switch } from '@/components/ui/switch';\nimport { cn } from '@/lib/utils';\nimport {\n  Message,\n  MessageChainComponent,\n  Image,\n  Plain,\n  At,\n  Quote,\n  Voice,\n  Source,\n} from '@/app/infra/entities/message';\nimport { toast } from 'sonner';\nimport AtBadge from './AtBadge';\nimport { WebSocketClient } from '@/app/infra/websocket/WebSocketClient';\nimport ImagePreviewDialog from './ImagePreviewDialog';\nimport ReactMarkdown from 'react-markdown';\nimport remarkGfm from 'remark-gfm';\nimport rehypeHighlight from 'rehype-highlight';\nimport rehypeRaw from 'rehype-raw';\nimport rehypeSanitize from 'rehype-sanitize';\nimport rehypeSlug from 'rehype-slug';\nimport rehypeAutolinkHeadings from 'rehype-autolink-headings';\nimport '@/styles/github-markdown.css';\n\ninterface DebugDialogProps {\n  open: boolean;\n  pipelineId: string;\n  isEmbedded?: boolean;\n  onConnectionStatusChange?: (isConnected: boolean) => void;\n}\n\nexport default function DebugDialog({\n  open,\n  pipelineId,\n  isEmbedded = false,\n  onConnectionStatusChange,\n}: DebugDialogProps) {\n  const { t } = useTranslation();\n  const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);\n  const [sessionType, setSessionType] = useState<'person' | 'group'>('person');\n  const [messages, setMessages] = useState<Message[]>([]);\n  const [inputValue, setInputValue] = useState('');\n  const [showAtPopover, setShowAtPopover] = useState(false);\n  const [hasAt, setHasAt] = useState(false);\n  const [isHovering, setIsHovering] = useState(false);\n  const [isConnected, setIsConnected] = useState(false);\n  const [selectedImages, setSelectedImages] = useState<\n    Array<{ file: File; preview: string; fileKey?: string }>\n  >([]);\n  const [isUploading, setIsUploading] = useState(false);\n  const [previewImageUrl, setPreviewImageUrl] = useState<string>('');\n  const [showImagePreview, setShowImagePreview] = useState(false);\n  const [quotedMessage, setQuotedMessage] = useState<Message | null>(null);\n  const [rawModeMessages, setRawModeMessages] = useState<Set<string>>(\n    new Set(),\n  );\n  const [streamOutput, setStreamOutput] = useState(true);\n  const messagesEndRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const popoverRef = useRef<HTMLDivElement>(null);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const wsClientRef = useRef<WebSocketClient | null>(null);\n  const isInitializingRef = useRef<boolean>(false);\n\n  const scrollToBottom = useCallback(() => {\n    // 使用setTimeout确保在DOM更新后执行滚动\n    setTimeout(() => {\n      const scrollArea = document.querySelector('.scroll-area') as HTMLElement;\n      if (scrollArea) {\n        scrollArea.scrollTo({\n          top: scrollArea.scrollHeight,\n          behavior: 'smooth',\n        });\n      }\n      // 同时确保messagesEndRef也滚动到视图\n      messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });\n    }, 0);\n  }, []);\n\n  const loadMessages = useCallback(\n    async (pipelineId: string) => {\n      try {\n        const response = await httpClient.getWebSocketHistoryMessages(\n          pipelineId,\n          sessionType,\n        );\n        setMessages(response.messages);\n      } catch (error) {\n        console.error('Failed to load messages:', error);\n      }\n    },\n    [sessionType],\n  );\n\n  // 初始化WebSocket连接\n  const initWebSocket = useCallback(\n    async (pipelineId: string) => {\n      // 防止重复初始化\n      if (isInitializingRef.current) {\n        return;\n      }\n\n      try {\n        isInitializingRef.current = true;\n\n        // 断开旧连接\n        if (wsClientRef.current) {\n          wsClientRef.current.disconnect();\n          wsClientRef.current = null;\n        }\n\n        // 创建新连接\n        const wsClient = new WebSocketClient(pipelineId, sessionType);\n\n        wsClient\n          .onConnected(() => {\n            setIsConnected(true);\n            isInitializingRef.current = false;\n          })\n          .onMessage((wsMessage) => {\n            // 将 WebSocketMessage 转换为 Message 类型\n            const message: Message = {\n              ...wsMessage,\n              message_chain: wsMessage.message_chain as MessageChainComponent[],\n            };\n\n            setMessages((prevMessages) => {\n              // 查找是否已存在相同ID的消息\n              const existingIndex = prevMessages.findIndex(\n                (m) => m.id === message.id,\n              );\n\n              if (existingIndex >= 0) {\n                // 更新已存在的消息（流式输出）\n                const newMessages = [...prevMessages];\n                newMessages[existingIndex] = message;\n                return newMessages;\n              } else {\n                // 添加新消息\n                return [...prevMessages, message];\n              }\n            });\n          })\n          .onError((error) => {\n            console.error('WebSocket错误:', error);\n            setIsConnected(false);\n            isInitializingRef.current = false;\n            toast.error(t('pipelines.debugDialog.connectionError'));\n          })\n          .onClose(() => {\n            setIsConnected(false);\n            isInitializingRef.current = false;\n          })\n          .onBroadcast((message) => {\n            toast.info(message);\n          });\n\n        await wsClient.connect();\n        wsClientRef.current = wsClient;\n      } catch (error) {\n        console.error('WebSocket连接失败:', error);\n        setIsConnected(false);\n        isInitializingRef.current = false;\n        toast.error(t('pipelines.debugDialog.connectionFailed'));\n      }\n    },\n    [sessionType, t],\n  );\n\n  // 在useEffect中监听messages变化时滚动\n  useEffect(() => {\n    scrollToBottom();\n  }, [messages, scrollToBottom]);\n\n  // 监听 open 和 pipelineId 变化，进入时连接，离开时断开\n  useEffect(() => {\n    if (open) {\n      setSelectedPipelineId(pipelineId);\n    } else {\n      // 关闭对话框时立即断开WebSocket\n      if (wsClientRef.current) {\n        wsClientRef.current.disconnect();\n        wsClientRef.current = null;\n        setIsConnected(false);\n        isInitializingRef.current = false;\n      }\n    }\n\n    return () => {\n      // 组件卸载时断开WebSocket\n      if (wsClientRef.current) {\n        wsClientRef.current.disconnect();\n        wsClientRef.current = null;\n        isInitializingRef.current = false;\n      }\n    };\n  }, [open, pipelineId]);\n\n  // 监听 sessionType 和 selectedPipelineId 变化，重新加载消息和连接\n  useEffect(() => {\n    if (open) {\n      // 清空当前消息，避免显示旧的消息\n      setMessages([]);\n      loadMessages(selectedPipelineId);\n      initWebSocket(selectedPipelineId);\n    }\n  }, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]);\n\n  // 通知父组件连接状态变化\n  useEffect(() => {\n    onConnectionStatusChange?.(isConnected);\n  }, [isConnected, onConnectionStatusChange]);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (\n        popoverRef.current &&\n        !popoverRef.current.contains(event.target as Node) &&\n        !inputRef.current?.contains(event.target as Node)\n      ) {\n        setShowAtPopover(false);\n      }\n    };\n\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => {\n      document.removeEventListener('mousedown', handleClickOutside);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (showAtPopover) {\n      setIsHovering(true);\n    }\n  }, [showAtPopover]);\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const value = e.target.value;\n    if (sessionType === 'group') {\n      if (value.endsWith('@')) {\n        setShowAtPopover(true);\n      } else if (showAtPopover && (!value.includes('@') || value.length > 1)) {\n        setShowAtPopover(false);\n      }\n    }\n    setInputValue(value);\n  };\n\n  const handleAtSelect = () => {\n    setHasAt(true);\n    setShowAtPopover(false);\n    setInputValue(inputValue.slice(0, -1));\n  };\n\n  const handleAtRemove = () => {\n    setHasAt(false);\n  };\n\n  const handleKeyPress = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault();\n      if (showAtPopover) {\n        handleAtSelect();\n      } else {\n        sendMessage();\n      }\n    } else if (e.key === 'Backspace' && hasAt && inputValue === '') {\n      handleAtRemove();\n    }\n  };\n\n  const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const files = e.target.files;\n    if (!files || files.length === 0) return;\n\n    const newImages: Array<{ file: File; preview: string }> = [];\n\n    for (let i = 0; i < files.length; i++) {\n      const file = files[i];\n      if (file.type.startsWith('image/')) {\n        const preview = URL.createObjectURL(file);\n        newImages.push({ file, preview });\n      }\n    }\n\n    setSelectedImages((prev) => [...prev, ...newImages]);\n  };\n\n  const handleRemoveImage = (index: number) => {\n    setSelectedImages((prev) => {\n      const newImages = [...prev];\n      URL.revokeObjectURL(newImages[index].preview);\n      newImages.splice(index, 1);\n      return newImages;\n    });\n  };\n\n  const sendMessage = async () => {\n    if (\n      !inputValue.trim() &&\n      !hasAt &&\n      selectedImages.length === 0 &&\n      !quotedMessage\n    )\n      return;\n    if (!isConnected || !wsClientRef.current) {\n      toast.error(t('pipelines.debugDialog.notConnected'));\n      return;\n    }\n\n    try {\n      setIsUploading(true);\n\n      const messageChain = [];\n\n      // 添加引用消息(如果有)\n      if (quotedMessage) {\n        // 获取被引用消息的Source组件以获取message_id\n        const sourceComponent = quotedMessage.message_chain.find(\n          (c) => c.type === 'Source',\n        ) as Source | undefined;\n        const messageId = sourceComponent\n          ? sourceComponent.id\n          : quotedMessage.id;\n\n        messageChain.push({\n          type: 'Quote',\n          id: messageId,\n          origin: quotedMessage.message_chain.filter(\n            (c) => c.type !== 'Source',\n          ),\n        });\n      }\n\n      let text_content = inputValue.trim();\n      if (hasAt) {\n        text_content = ' ' + text_content;\n      }\n\n      if (hasAt) {\n        messageChain.push({\n          type: 'At',\n          target: 'websocketbot',\n          display: 'websocketbot',\n        });\n      }\n\n      // 添加文本\n      if (text_content) {\n        messageChain.push({\n          type: 'Plain',\n          text: text_content,\n        });\n      }\n\n      // 上传图片并添加到消息链\n      for (const image of selectedImages) {\n        try {\n          const result = await httpClient.uploadWebSocketImage(\n            selectedPipelineId,\n            image.file,\n          );\n          messageChain.push({\n            type: 'Image',\n            path: result.file_key,\n          });\n        } catch (error) {\n          console.error('图片上传失败:', error);\n          toast.error(t('pipelines.debugDialog.imageUploadFailed'));\n        }\n      }\n\n      // 清空输入框、图片和引用消息\n      setInputValue('');\n      setHasAt(false);\n      setQuotedMessage(null);\n      selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));\n      setSelectedImages([]);\n\n      // 通过WebSocket发送消息\n      // 不在本地添加消息，等待后端广播回来（带有正确的ID）\n      wsClientRef.current.sendMessage(messageChain, streamOutput);\n    } catch (error) {\n      console.error('Failed to send message:', error);\n      toast.error(t('pipelines.debugDialog.sendFailed'));\n    } finally {\n      setIsUploading(false);\n      inputRef.current?.focus();\n    }\n  };\n\n  const renderMessageComponent = (\n    component: MessageChainComponent,\n    index: number,\n  ) => {\n    switch (component.type) {\n      case 'Plain':\n        return <span key={index}>{(component as Plain).text}</span>;\n\n      case 'At': {\n        const atComponent = component as At;\n        // 优先使用 display，如果没有则使用 target\n        const displayName =\n          atComponent.display || atComponent.target?.toString() || '';\n        return (\n          <span key={index} className=\"inline-flex align-middle mx-1\">\n            <AtBadge targetName={displayName} readonly={true} />\n          </span>\n        );\n      }\n\n      case 'AtAll':\n        return (\n          <span key={index} className=\"inline-flex align-middle mx-1\">\n            <AtBadge targetName=\"全体成员\" readonly={true} />\n          </span>\n        );\n\n      case 'Image': {\n        const img = component as Image;\n        const imageUrl = img.url || (img.base64 ? img.base64 : '');\n\n        if (!imageUrl) return null;\n\n        return (\n          <div key={index} className=\"my-2\">\n            <img\n              src={imageUrl}\n              alt=\"Image\"\n              className=\"max-w-full max-h-96 rounded-lg cursor-pointer hover:opacity-90 transition-opacity\"\n              onClick={() => {\n                setPreviewImageUrl(imageUrl);\n                setShowImagePreview(true);\n              }}\n            />\n          </div>\n        );\n      }\n\n      case 'File': {\n        const file = component as MessageChainComponent & { name?: string };\n        return (\n          <div key={index} className=\"my-2 flex items-center gap-2 text-sm\">\n            <svg className=\"w-4 h-4\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n              <path d=\"M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z\" />\n            </svg>\n            <span>[文件] {file.name || 'Unknown'}</span>\n          </div>\n        );\n      }\n\n      case 'Voice': {\n        const voice = component as Voice;\n        const voiceUrl = voice.url || (voice.base64 ? voice.base64 : '');\n\n        if (!voiceUrl) {\n          return <span key={index}>[语音]</span>;\n        }\n\n        return (\n          <div key={index} className=\"my-2 flex items-center gap-2\">\n            <div className=\"flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg\">\n              <svg className=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                <path d=\"M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z\" />\n              </svg>\n              <audio\n                controls\n                src={voiceUrl}\n                className=\"h-8\"\n                style={{ maxWidth: '200px' }}\n              >\n                Your browser does not support the audio element.\n              </audio>\n              {voice.length && voice.length > 0 && (\n                <span className=\"text-xs text-gray-500 dark:text-gray-400\">\n                  {voice.length}s\n                </span>\n              )}\n            </div>\n          </div>\n        );\n      }\n\n      case 'Quote': {\n        const quote = component as Quote;\n        return (\n          <div\n            key={index}\n            className=\"mb-2 pl-3 border-l-2 border-gray-400 dark:border-gray-500\"\n          >\n            <div className=\"text-sm opacity-75\">\n              {quote.origin?.map((comp, idx) =>\n                renderMessageComponent(comp as MessageChainComponent, idx),\n              )}\n            </div>\n          </div>\n        );\n      }\n\n      case 'Source':\n        // Source 不显示\n        return null;\n\n      default:\n        return <span key={index}>[{component.type}]</span>;\n    }\n  };\n\n  const getMessageTimestamp = (message: Message): number => {\n    // 首先尝试从message_chain中的Source组件获取时间戳\n    const sourceComponent = message.message_chain.find(\n      (c) => c.type === 'Source',\n    ) as Source | undefined;\n\n    if (sourceComponent && sourceComponent.timestamp) {\n      return sourceComponent.timestamp;\n    }\n\n    // 如果没有Source组件，使用message.timestamp\n    // 假设timestamp是ISO字符串，转换为Unix时间戳（秒）\n    if (message.timestamp) {\n      return Math.floor(new Date(message.timestamp).getTime() / 1000);\n    }\n\n    return 0;\n  };\n\n  const formatTimestamp = (timestamp: number): string => {\n    if (!timestamp) return '';\n\n    const date = new Date(timestamp * 1000);\n    const now = new Date();\n\n    const hours = date.getHours().toString().padStart(2, '0');\n    const minutes = date.getMinutes().toString().padStart(2, '0');\n\n    // 判断是否是今天\n    const isToday = now.toDateString() === date.toDateString();\n    if (isToday) {\n      return `${hours}:${minutes}`;\n    }\n\n    // 判断是否是昨天\n    const yesterday = new Date(now);\n    yesterday.setDate(yesterday.getDate() - 1);\n    const isYesterday = yesterday.toDateString() === date.toDateString();\n    if (isYesterday) {\n      return `${t('bots.yesterday')} ${hours}:${minutes}`;\n    }\n\n    // 判断是否是今年\n    const isThisYear = now.getFullYear() === date.getFullYear();\n    if (isThisYear) {\n      const month = date.getMonth() + 1;\n      const day = date.getDate();\n      return t('bots.dateFormat', { month, day });\n    }\n\n    // 更早的日期\n    return t('bots.earlier');\n  };\n\n  // Generate a unique key for a message\n  const getMessageKey = (message: Message): string => {\n    return `${message.id}-${message.timestamp}`;\n  };\n\n  // Toggle raw mode for a message (by default, messages are in markdown mode)\n  const toggleRawMode = (message: Message) => {\n    const key = getMessageKey(message);\n    setRawModeMessages((prev) => {\n      const newSet = new Set(prev);\n      if (newSet.has(key)) {\n        newSet.delete(key);\n      } else {\n        newSet.add(key);\n      }\n      return newSet;\n    });\n  };\n\n  // Check if message has any Plain text content\n  const hasPlainText = (message: Message): boolean => {\n    return message.message_chain.some((c) => c.type === 'Plain');\n  };\n\n  // Extract plain text from message chain\n  const getPlainText = (message: Message): string => {\n    return message.message_chain\n      .filter((c) => c.type === 'Plain')\n      .map((c) => (c as Plain).text)\n      .join('');\n  };\n\n  const renderMessageContent = (message: Message) => {\n    const key = getMessageKey(message);\n    const isRawMode = rawModeMessages.has(key);\n\n    // By default, render with markdown if there's plain text (unless raw mode is enabled)\n    if (!isRawMode && hasPlainText(message)) {\n      const plainText = getPlainText(message);\n      const nonPlainComponents = message.message_chain.filter(\n        (c) => c.type !== 'Plain' && c.type !== 'Source',\n      );\n\n      return (\n        <div className=\"text-base leading-relaxed align-middle\">\n          {/* Render non-Plain components first */}\n          {nonPlainComponents.map((component, index) =>\n            renderMessageComponent(component, index),\n          )}\n          {/* Render Plain text as markdown */}\n          <div className=\"markdown-body\">\n            <ReactMarkdown\n              remarkPlugins={[remarkGfm]}\n              rehypePlugins={[\n                rehypeRaw,\n                rehypeSanitize,\n                rehypeHighlight,\n                rehypeSlug,\n                [\n                  rehypeAutolinkHeadings,\n                  {\n                    behavior: 'wrap',\n                    properties: {\n                      className: ['anchor'],\n                    },\n                  },\n                ],\n              ]}\n              components={{\n                ul: ({ children }) => <ul className=\"list-disc\">{children}</ul>,\n                ol: ({ children }) => (\n                  <ol className=\"list-decimal\">{children}</ol>\n                ),\n                li: ({ children }) => <li className=\"ml-4\">{children}</li>,\n                img: ({ src, alt, ...props }) => {\n                  const imageSrc = src || '';\n\n                  if (typeof imageSrc !== 'string') {\n                    return (\n                      <img\n                        src={src}\n                        alt={alt || ''}\n                        className=\"max-w-full h-auto rounded-lg my-4\"\n                        {...props}\n                      />\n                    );\n                  }\n\n                  return (\n                    <img\n                      src={imageSrc}\n                      alt={alt || ''}\n                      className=\"max-w-lg h-auto my-4\"\n                      {...props}\n                    />\n                  );\n                },\n              }}\n            >\n              {plainText}\n            </ReactMarkdown>\n          </div>\n        </div>\n      );\n    }\n\n    return (\n      <div className=\"text-base leading-relaxed align-middle whitespace-pre-wrap\">\n        {message.message_chain.map((component, index) =>\n          renderMessageComponent(component, index),\n        )}\n      </div>\n    );\n  };\n\n  const renderContent = () => (\n    <div className=\"flex flex-1 h-full min-h-0\">\n      <div className=\"w-14 bg-white dark:bg-black p-2 pl-0  flex-shrink-0 flex flex-col justify-start gap-2\">\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className={`w-10 h-10 justify-center rounded-md transition-none ${\n            sessionType === 'person'\n              ? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'\n              : 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'\n          } border-0 shadow-none`}\n          onClick={() => setSessionType('person')}\n        >\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"currentColor\"\n            className=\"w-6 h-6\"\n          >\n            <path d=\"M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z\"></path>\n          </svg>\n        </Button>\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className={`w-10 h-10 justify-center rounded-md transition-none ${\n            sessionType === 'group'\n              ? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'\n              : 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'\n          } border-0 shadow-none`}\n          onClick={() => setSessionType('group')}\n        >\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"currentColor\"\n            className=\"w-6 h-6\"\n          >\n            <path d=\"M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z\"></path>\n          </svg>\n        </Button>\n      </div>\n\n      <div className=\"flex-1 flex flex-col w-[10rem] h-full min-h-0\">\n        <ScrollArea className=\"flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black scroll-area\">\n          <div className=\"space-y-6\">\n            {messages.length === 0 ? (\n              <div className=\"text-center text-muted-foreground py-12 text-lg\">\n                {t('pipelines.debugDialog.noMessages')}\n              </div>\n            ) : (\n              messages.map((message) => (\n                <div\n                  key={message.id + message.timestamp}\n                  className={cn(\n                    'flex',\n                    message.role === 'user' ? 'justify-end' : 'justify-start',\n                  )}\n                >\n                  <div\n                    className={cn(\n                      'max-w-3xl px-5 py-3 rounded-2xl',\n                      message.role === 'user'\n                        ? 'user-message-bubble bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none'\n                        : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',\n                    )}\n                  >\n                    {renderMessageContent(message)}\n                    <div\n                      className={cn(\n                        'text-xs mt-2 flex items-center justify-between gap-2',\n                        message.role === 'user'\n                          ? 'text-gray-600 dark:text-gray-300'\n                          : 'text-gray-500 dark:text-gray-400',\n                      )}\n                    >\n                      <div className=\"flex items-center gap-2\">\n                        <span>\n                          {message.role === 'user'\n                            ? t('pipelines.debugDialog.userMessage')\n                            : t('pipelines.debugDialog.botMessage')}\n                        </span>\n                        {hasPlainText(message) && (\n                          <button\n                            onClick={() => toggleRawMode(message)}\n                            className={cn(\n                              'px-1.5 py-0.5 rounded text-[10px] transition-colors',\n                              message.role === 'user'\n                                ? 'hover:bg-blue-200 dark:hover:bg-blue-800'\n                                : 'hover:bg-gray-200 dark:hover:bg-gray-700',\n                            )}\n                            title={\n                              rawModeMessages.has(getMessageKey(message))\n                                ? t('pipelines.debugDialog.showMarkdown')\n                                : t('pipelines.debugDialog.showRaw')\n                            }\n                          >\n                            {rawModeMessages.has(getMessageKey(message)) ? (\n                              <span className=\"flex items-center gap-0.5\">\n                                <svg\n                                  className=\"w-3 h-3\"\n                                  viewBox=\"0 0 16 16\"\n                                  fill=\"currentColor\"\n                                >\n                                  <path d=\"M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z\" />\n                                </svg>\n                                MD\n                              </span>\n                            ) : (\n                              <span className=\"flex items-center gap-0.5\">\n                                <svg\n                                  className=\"w-3 h-3\"\n                                  fill=\"none\"\n                                  viewBox=\"0 0 24 24\"\n                                  stroke=\"currentColor\"\n                                >\n                                  <path\n                                    strokeLinecap=\"round\"\n                                    strokeLinejoin=\"round\"\n                                    strokeWidth={2}\n                                    d=\"M4 6h16M4 12h16M4 18h7\"\n                                  />\n                                </svg>\n                                {t('pipelines.debugDialog.showRaw')}\n                              </span>\n                            )}\n                          </button>\n                        )}\n                        <button\n                          onClick={() => setQuotedMessage(message)}\n                          className={cn(\n                            'px-1.5 py-0.5 rounded text-[10px] transition-colors flex items-center gap-0.5',\n                            message.role === 'user'\n                              ? 'hover:bg-blue-200 dark:hover:bg-blue-800'\n                              : 'hover:bg-gray-200 dark:hover:bg-gray-700',\n                          )}\n                          title={t('pipelines.debugDialog.reply')}\n                        >\n                          <svg\n                            className=\"w-3 h-3\"\n                            fill=\"none\"\n                            viewBox=\"0 0 24 24\"\n                            stroke=\"currentColor\"\n                          >\n                            <path\n                              strokeLinecap=\"round\"\n                              strokeLinejoin=\"round\"\n                              strokeWidth={2}\n                              d=\"M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6\"\n                            />\n                          </svg>\n                          {t('pipelines.debugDialog.reply')}\n                        </button>\n                      </div>\n                      <span className=\"text-[10px]\">\n                        {formatTimestamp(getMessageTimestamp(message))}\n                      </span>\n                    </div>\n                  </div>\n                </div>\n              ))\n            )}\n            <div ref={messagesEndRef} />\n          </div>\n        </ScrollArea>\n\n        {/* 引用消息预览区域 */}\n        {quotedMessage && (\n          <div className=\"px-4 py-2 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700\">\n            <div className=\"flex items-start gap-2\">\n              <div className=\"flex-1 pl-3 border-l-2 border-[#2288ee]\">\n                <div className=\"text-xs text-gray-500 dark:text-gray-400 mb-1\">\n                  {t('pipelines.debugDialog.replyTo')}{' '}\n                  {quotedMessage.role === 'user'\n                    ? t('pipelines.debugDialog.userMessage')\n                    : t('pipelines.debugDialog.botMessage')}\n                </div>\n                <div className=\"text-sm text-gray-700 dark:text-gray-300 line-clamp-2\">\n                  {quotedMessage.message_chain\n                    .filter((c) => c.type === 'Plain')\n                    .map((c) => (c as Plain).text)\n                    .join('')}\n                </div>\n              </div>\n              <button\n                onClick={() => setQuotedMessage(null)}\n                className=\"w-5 h-5 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300\"\n              >\n                ×\n              </button>\n            </div>\n          </div>\n        )}\n\n        {/* 图片预览区域 */}\n        {selectedImages.length > 0 && (\n          <div className=\"px-4 pb-2 bg-white dark:bg-black\">\n            <div className=\"flex gap-2 flex-wrap\">\n              {selectedImages.map((image, index) => (\n                <div key={index} className=\"relative group\">\n                  <img\n                    src={image.preview}\n                    alt={`preview-${index}`}\n                    className=\"w-20 h-20 object-cover rounded-lg border border-gray-300 dark:border-gray-600\"\n                  />\n                  <button\n                    onClick={() => handleRemoveImage(index)}\n                    className=\"absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity\"\n                  >\n                    ×\n                  </button>\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n\n        <div className=\"p-4 pb-0 bg-white dark:bg-black flex gap-2\">\n          <div className=\"flex gap-2 items-center\">\n            <div className=\"flex items-center gap-1\">\n              <span className=\"text-xs text-gray-500 dark:text-gray-400\">\n                {t('pipelines.debugDialog.streamOutput')}\n              </span>\n              <Switch\n                checked={streamOutput}\n                onCheckedChange={setStreamOutput}\n                disabled={!isConnected}\n                className=\"data-[state=checked]:bg-[#2288ee]\"\n              />\n            </div>\n            <input\n              ref={fileInputRef}\n              type=\"file\"\n              accept=\"image/*\"\n              multiple\n              onChange={handleImageSelect}\n              className=\"hidden\"\n            />\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={() => fileInputRef.current?.click()}\n              disabled={!isConnected || isUploading}\n              className=\"w-10 h-10 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700\"\n              title=\"上传图片\"\n            >\n              <svg\n                className=\"w-5 h-5\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n                stroke=\"currentColor\"\n              >\n                <path\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth={2}\n                  d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"\n                />\n              </svg>\n            </Button>\n          </div>\n          <div className=\"flex-1 flex items-center gap-2\">\n            {hasAt && (\n              <AtBadge targetName=\"websocketbot\" onRemove={handleAtRemove} />\n            )}\n            <div className=\"relative flex-1\">\n              <Input\n                ref={inputRef}\n                value={inputValue}\n                onChange={handleInputChange}\n                onKeyPress={handleKeyPress}\n                placeholder={t('pipelines.debugDialog.inputPlaceholder', {\n                  type:\n                    sessionType === 'person'\n                      ? t('pipelines.debugDialog.privateChat')\n                      : t('pipelines.debugDialog.groupChat'),\n                })}\n                disabled={!isConnected || isUploading}\n                className=\"flex-1 rounded-md px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-[#2288ee] transition-none text-base disabled:opacity-50\"\n              />\n              {showAtPopover && (\n                <div\n                  ref={popoverRef}\n                  className=\"absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-white dark:bg-gray-800 dark:border-gray-600 shadow-lg\"\n                >\n                  <div\n                    className={cn(\n                      'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',\n                      isHovering\n                        ? 'bg-gray-100 dark:bg-gray-700'\n                        : 'bg-white dark:bg-gray-800',\n                    )}\n                    onClick={handleAtSelect}\n                    onMouseEnter={() => setIsHovering(true)}\n                    onMouseLeave={() => setIsHovering(false)}\n                  >\n                    <span className=\"text-gray-800 dark:text-gray-200\">\n                      @websocketbot - {t('pipelines.debugDialog.atTips')}\n                    </span>\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n          <Button\n            onClick={sendMessage}\n            disabled={\n              (!inputValue.trim() &&\n                !hasAt &&\n                selectedImages.length === 0 &&\n                !quotedMessage) ||\n              !isConnected ||\n              isUploading\n            }\n            className=\"rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50\"\n          >\n            {isUploading ? '上传中...' : t('pipelines.debugDialog.send')}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n\n  // 如果是嵌入模式，直接返回内容\n  if (isEmbedded) {\n    return (\n      <>\n        <div className=\"flex flex-col h-full min-h-0\">\n          <div className=\"flex-1 min-h-0 flex flex-col\">{renderContent()}</div>\n        </div>\n        <ImagePreviewDialog\n          open={showImagePreview}\n          imageUrl={previewImageUrl}\n          onClose={() => setShowImagePreview(false)}\n        />\n      </>\n    );\n  }\n\n  // 原有的Dialog包装\n  return (\n    <>\n      <DialogContent className=\"!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white dark:bg-black\">\n        {renderContent()}\n      </DialogContent>\n      <ImagePreviewDialog\n        open={showImagePreview}\n        imageUrl={previewImageUrl}\n        onClose={() => setShowImagePreview(false)}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/components/debug-dialog/ImagePreviewDialog.tsx",
    "content": "import React from 'react';\n\ninterface ImagePreviewDialogProps {\n  open: boolean;\n  imageUrl: string;\n  onClose: () => void;\n}\n\nexport default function ImagePreviewDialog({\n  open,\n  imageUrl,\n  onClose,\n}: ImagePreviewDialogProps) {\n  if (!open) return null;\n\n  return (\n    <div\n      className=\"fixed inset-0 z-[100] flex items-center justify-center p-8 animate-in fade-in duration-200\"\n      onClick={onClose}\n    >\n      {/* 背景遮罩 */}\n      <div className=\"absolute inset-0 bg-black/20 \" />\n\n      {/* 内容区域 */}\n      <div className=\"relative z-10 flex flex-col items-center gap-2\">\n        {/* 关闭按钮 - 在图片上方 */}\n        <button\n          onClick={onClose}\n          className=\"self-end w-9 h-9 rounded-full bg-white hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100 shadow-lg transition-all hover:scale-105 flex items-center justify-center\"\n        >\n          <svg\n            className=\"w-4 h-4\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n          >\n            <path\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth={2}\n              d=\"M6 18L18 6M6 6l12 12\"\n            />\n          </svg>\n        </button>\n\n        {/* 图片 */}\n        <img\n          src={imageUrl}\n          alt=\"Preview\"\n          className=\"max-w-[50vw] max-h-[50vh] object-contain rounded-lg shadow-2xl bg-white\"\n          onClick={(e) => e.stopPropagation()}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx",
    "content": "'use client';\n\nimport React, { useState, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { Button } from '@/components/ui/button';\nimport { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';\nimport { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';\nimport { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer';\nimport { LoadingSpinner } from '@/components/ui/loading-spinner';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { MessageDetails } from '@/app/home/monitoring/types/monitoring';\n\ninterface PipelineMonitoringTabProps {\n  pipelineId: string;\n  onNavigateToMonitoring?: () => void;\n}\n\ninterface RawMessageData {\n  id: string;\n  timestamp: string;\n  bot_id: string;\n  bot_name: string;\n  pipeline_id: string;\n  pipeline_name: string;\n  message_content: string;\n  session_id: string;\n  status: string;\n  level: string;\n  platform: string;\n  user_id: string;\n  runner_name: string;\n  variables: Record<string, unknown>;\n}\n\ninterface RawLLMCallData {\n  id: string;\n  timestamp: string;\n  model_name: string;\n  status: string;\n  duration: number;\n  error_message: string | null;\n  input_tokens: number;\n  output_tokens: number;\n  total_tokens: number;\n}\n\ninterface RawLLMStatsData {\n  total_calls: number;\n  total_input_tokens: number;\n  total_output_tokens: number;\n  total_tokens: number;\n  total_duration_ms: number;\n  average_duration_ms: number;\n}\n\ninterface RawErrorData {\n  id: string;\n  timestamp: string;\n  error_type: string;\n  error_message: string;\n  stack_trace: string | null;\n}\n\nexport default function PipelineMonitoringTab({\n  pipelineId,\n  onNavigateToMonitoring,\n}: PipelineMonitoringTabProps) {\n  const { t } = useTranslation();\n\n  // Filter state - only show data for this pipeline, last 24 hours\n  const filterState = useMemo(\n    () => ({\n      selectedBots: [],\n      selectedPipelines: [pipelineId],\n      timeRange: 'last24Hours' as const,\n      customDateRange: null,\n    }),\n    [pipelineId],\n  );\n\n  const { data, loading, refetch } = useMonitoringData(filterState);\n\n  const [expandedMessageId, setExpandedMessageId] = useState<string | null>(\n    null,\n  );\n  const [messageDetails, setMessageDetails] = useState<\n    Record<string, MessageDetails>\n  >({});\n  const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(\n    {},\n  );\n  const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);\n  const [activeTab, setActiveTab] = useState<string>('messages');\n\n  const toggleMessageExpand = async (messageId: string) => {\n    if (expandedMessageId === messageId) {\n      setExpandedMessageId(null);\n    } else {\n      setExpandedMessageId(messageId);\n\n      if (!messageDetails[messageId]) {\n        setLoadingDetails((prev) => ({ ...prev, [messageId]: true }));\n        try {\n          const result = await httpClient.get<{\n            message_id: string;\n            found: boolean;\n            message: RawMessageData | null;\n            llm_calls: RawLLMCallData[];\n            llm_stats: RawLLMStatsData;\n            errors: RawErrorData[];\n          }>(`/api/v1/monitoring/messages/${messageId}/details`);\n\n          if (result) {\n            setMessageDetails((prev) => ({\n              ...prev,\n              [messageId]: {\n                messageId: result.message_id,\n                found: result.found,\n                message: result.message\n                  ? {\n                      id: result.message.id,\n                      timestamp: new Date(result.message.timestamp),\n                      botId: result.message.bot_id,\n                      botName: result.message.bot_name,\n                      pipelineId: result.message.pipeline_id,\n                      pipelineName: result.message.pipeline_name,\n                      messageContent: result.message.message_content,\n                      sessionId: result.message.session_id,\n                      status: result.message.status,\n                      level: result.message.level,\n                      platform: result.message.platform,\n                      userId: result.message.user_id,\n                      runnerName: result.message.runner_name,\n                      variables: result.message.variables,\n                    }\n                  : undefined,\n                llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({\n                  id: call.id,\n                  timestamp: new Date(call.timestamp),\n                  modelName: call.model_name,\n                  status: call.status,\n                  duration: call.duration,\n                  errorMessage: call.error_message,\n                  tokens: {\n                    input: call.input_tokens || 0,\n                    output: call.output_tokens || 0,\n                    total: call.total_tokens || 0,\n                  },\n                })),\n                errors: result.errors.map((error: RawErrorData) => ({\n                  id: error.id,\n                  timestamp: new Date(error.timestamp),\n                  errorType: error.error_type,\n                  errorMessage: error.error_message,\n                  stackTrace: error.stack_trace,\n                })),\n                llmStats: {\n                  totalCalls: result.llm_stats.total_calls,\n                  totalInputTokens: result.llm_stats.total_input_tokens,\n                  totalOutputTokens: result.llm_stats.total_output_tokens,\n                  totalTokens: result.llm_stats.total_tokens,\n                  totalDurationMs: result.llm_stats.total_duration_ms,\n                  averageDurationMs: result.llm_stats.average_duration_ms,\n                },\n              } as MessageDetails,\n            }));\n          }\n        } catch (error) {\n          console.error('Failed to fetch message details:', error);\n        } finally {\n          setLoadingDetails((prev) => ({ ...prev, [messageId]: false }));\n        }\n      }\n    }\n  };\n\n  const toggleErrorExpand = (errorId: string) => {\n    if (expandedErrorId === errorId) {\n      setExpandedErrorId(null);\n    } else {\n      setExpandedErrorId(errorId);\n    }\n  };\n\n  const jumpToMessage = async (messageId: string) => {\n    setActiveTab('messages');\n    // Small delay to ensure tab transition completes before expanding\n    setTimeout(() => {\n      toggleMessageExpand(messageId);\n    }, 100);\n  };\n\n  return (\n    <div className=\"w-full h-full flex flex-col\">\n      {/* Header with refresh button */}\n      <div className=\"flex items-center justify-between mb-4 pb-4 border-b border-gray-200 dark:border-gray-700\">\n        <p className=\"text-sm text-gray-500 dark:text-gray-400\">\n          {t('pipelines.monitoring.description')}\n        </p>\n        <div className=\"flex items-center gap-2\">\n          {onNavigateToMonitoring && (\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={onNavigateToMonitoring}\n              className=\"bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600\"\n            >\n              <svg\n                className=\"w-4 h-4 mr-2\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                viewBox=\"0 0 24 24\"\n                fill=\"currentColor\"\n              >\n                <path d=\"M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19V6.413L11.2071 14.2071L9.79289 12.7929L17.585 5H13V3H21Z\"></path>\n              </svg>\n              {t('pipelines.monitoring.detailedLogs')}\n            </Button>\n          )}\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={refetch}\n            className=\"bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600\"\n          >\n            <svg\n              className=\"w-4 h-4 mr-2\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z\"></path>\n            </svg>\n            {t('monitoring.refreshData')}\n          </Button>\n        </div>\n      </div>\n\n      {/* Overview Stats */}\n      {data && (\n        <div className=\"grid grid-cols-3 gap-4 mb-6\">\n          <div className=\"bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n            <div className=\"text-sm text-gray-500 dark:text-gray-400\">\n              {t('monitoring.totalMessages')}\n            </div>\n            <div className=\"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1\">\n              {data.overview.totalMessages}\n            </div>\n          </div>\n          <div className=\"bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n            <div className=\"text-sm text-gray-500 dark:text-gray-400\">\n              {t('monitoring.successRate')}\n            </div>\n            <div className=\"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1\">\n              {data.overview.successRate.toFixed(1)}%\n            </div>\n          </div>\n          <div className=\"bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n            <div className=\"text-sm text-gray-500 dark:text-gray-400\">\n              {t('monitoring.tabs.errors')}\n            </div>\n            <div className=\"text-2xl font-bold text-red-600 dark:text-red-400 mt-1\">\n              {data.errors.length}\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Tabs */}\n      <Tabs\n        value={activeTab}\n        onValueChange={setActiveTab}\n        className=\"flex-1 flex flex-col min-h-0\"\n      >\n        <TabsList className=\"bg-gray-100 dark:bg-[#1a1a1e] h-10 p-1 mb-4\">\n          <TabsTrigger\n            value=\"messages\"\n            className=\"px-4 py-1.5 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm\"\n          >\n            {t('monitoring.tabs.messages')}\n          </TabsTrigger>\n          <TabsTrigger\n            value=\"errors\"\n            className=\"px-4 py-1.5 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm\"\n          >\n            {t('monitoring.tabs.errors')}\n          </TabsTrigger>\n          <TabsTrigger\n            value=\"llmCalls\"\n            className=\"px-4 py-1.5 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm\"\n          >\n            {t('monitoring.tabs.modelCalls')}\n          </TabsTrigger>\n        </TabsList>\n\n        <div className=\"flex-1 overflow-y-auto min-h-0\">\n          {/* Messages Tab */}\n          <TabsContent value=\"messages\" className=\"m-0 h-full\">\n            {loading && (\n              <div className=\"py-12 flex justify-center\">\n                <LoadingSpinner text={t('monitoring.messageList.loading')} />\n              </div>\n            )}\n\n            {!loading && data && data.messages && data.messages.length > 0 && (\n              <div className=\"space-y-3\">\n                {data.messages\n                  .filter((msg) => {\n                    const content = msg.messageContent?.trim();\n                    return content && content !== '[]' && content !== '\"\"';\n                  })\n                  .map((msg) => (\n                    <div\n                      key={msg.id}\n                      className=\"border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-all duration-200\"\n                    >\n                      <div\n                        className=\"p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors\"\n                        onClick={() => toggleMessageExpand(msg.id)}\n                      >\n                        <div className=\"flex items-start justify-between\">\n                          <div className=\"flex items-start flex-1\">\n                            <div className=\"mr-2 mt-0.5\">\n                              {expandedMessageId === msg.id ? (\n                                <ChevronDown className=\"w-4 h-4 text-gray-500\" />\n                              ) : (\n                                <ChevronRight className=\"w-4 h-4 text-gray-500\" />\n                              )}\n                            </div>\n                            <div className=\"flex-1\">\n                              <div className=\"flex items-center gap-2 mb-1\">\n                                <span\n                                  className={`text-xs px-2 py-0.5 rounded ${\n                                    msg.status === 'success'\n                                      ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'\n                                      : msg.status === 'error'\n                                        ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'\n                                        : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'\n                                  }`}\n                                >\n                                  {msg.status}\n                                </span>\n                                <span className=\"text-xs text-gray-500 dark:text-gray-400\">\n                                  {msg.botName}\n                                </span>\n                              </div>\n                              <div className=\"text-sm text-gray-700 dark:text-gray-300 line-clamp-2\">\n                                <MessageContentRenderer\n                                  content={msg.messageContent}\n                                />\n                              </div>\n                            </div>\n                          </div>\n                          <span className=\"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4\">\n                            {msg.timestamp.toLocaleString()}\n                          </span>\n                        </div>\n                      </div>\n\n                      {expandedMessageId === msg.id && (\n                        <div className=\"border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900\">\n                          {loadingDetails[msg.id] && (\n                            <div className=\"flex justify-center py-8\">\n                              <LoadingSpinner\n                                text={t('monitoring.messageList.loading')}\n                              />\n                            </div>\n                          )}\n\n                          {!loadingDetails[msg.id] &&\n                            messageDetails[msg.id] && (\n                              <div className=\"space-y-4\">\n                                {messageDetails[msg.id].errors.length > 0 && (\n                                  <div className=\"bg-red-50 dark:bg-red-900/20 rounded-lg p-3\">\n                                    <h4 className=\"text-sm font-semibold text-red-700 dark:text-red-400 mb-2\">\n                                      {t('monitoring.errors.errorMessage')}\n                                    </h4>\n                                    {messageDetails[msg.id].errors.map(\n                                      (error) => (\n                                        <div\n                                          key={error.id}\n                                          className=\"text-sm space-y-2\"\n                                        >\n                                          <div className=\"text-red-600 dark:text-red-400\">\n                                            {error.errorType}:{' '}\n                                            {error.errorMessage}\n                                          </div>\n                                          {error.stackTrace && (\n                                            <pre className=\"text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-40 bg-white dark:bg-gray-900 p-2 rounded whitespace-pre-wrap break-words\">\n                                              {error.stackTrace}\n                                            </pre>\n                                          )}\n                                        </div>\n                                      ),\n                                    )}\n                                  </div>\n                                )}\n\n                                {messageDetails[msg.id].llmCalls.length > 0 && (\n                                  <div className=\"bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3\">\n                                    <h4 className=\"text-sm font-semibold text-blue-700 dark:text-blue-400 mb-2\">\n                                      {t('monitoring.tabs.modelCalls')} (\n                                      {messageDetails[msg.id].llmCalls.length})\n                                    </h4>\n                                    <div className=\"text-xs text-gray-600 dark:text-gray-400 space-y-1\">\n                                      <div>\n                                        {t('monitoring.llmCalls.totalTokens')}:{' '}\n                                        {\n                                          messageDetails[msg.id].llmStats\n                                            .totalTokens\n                                        }\n                                      </div>\n                                      <div>\n                                        {t('monitoring.llmCalls.duration')}:{' '}\n                                        {messageDetails[\n                                          msg.id\n                                        ].llmStats.totalDurationMs.toFixed(0)}\n                                        ms\n                                      </div>\n                                    </div>\n                                  </div>\n                                )}\n                              </div>\n                            )}\n                        </div>\n                      )}\n                    </div>\n                  ))}\n              </div>\n            )}\n\n            {!loading &&\n              (!data || !data.messages || data.messages.length === 0) && (\n                <div className=\"text-center text-gray-500 dark:text-gray-400 py-16\">\n                  <svg\n                    className=\"w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600\"\n                    fill=\"none\"\n                    viewBox=\"0 0 24 24\"\n                    stroke=\"currentColor\"\n                  >\n                    <path\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth={1.5}\n                      d=\"M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z\"\n                    />\n                  </svg>\n                  <p className=\"text-base font-medium\">\n                    {t('monitoring.messageList.noMessages')}\n                  </p>\n                </div>\n              )}\n          </TabsContent>\n\n          {/* Errors Tab */}\n          <TabsContent value=\"errors\" className=\"m-0 h-full\">\n            {loading && (\n              <div className=\"py-12 flex justify-center\">\n                <LoadingSpinner text={t('common.loading')} />\n              </div>\n            )}\n\n            {!loading && data && data.errors && data.errors.length > 0 && (\n              <div className=\"space-y-3\">\n                {data.errors.map((error) => (\n                  <div\n                    key={error.id}\n                    className=\"border border-red-200 dark:border-red-900 rounded-lg overflow-hidden hover:shadow-md transition-all duration-200\"\n                  >\n                    <div\n                      className=\"p-4 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30\"\n                      onClick={() => toggleErrorExpand(error.id)}\n                    >\n                      <div className=\"flex items-start justify-between\">\n                        <div className=\"flex items-start flex-1\">\n                          <div className=\"mr-2 mt-0.5\">\n                            {expandedErrorId === error.id ? (\n                              <ChevronDown className=\"w-4 h-4 text-red-500\" />\n                            ) : (\n                              <ChevronRight className=\"w-4 h-4 text-red-500\" />\n                            )}\n                          </div>\n                          <div className=\"flex-1\">\n                            <div className=\"flex items-center gap-2 mb-1\">\n                              {error.messageId && (\n                                <Button\n                                  variant=\"ghost\"\n                                  size=\"sm\"\n                                  className=\"h-5 px-1.5 text-xs\"\n                                  onClick={(e) => {\n                                    e.stopPropagation();\n                                    jumpToMessage(error.messageId!);\n                                  }}\n                                >\n                                  <ExternalLink className=\"w-3 h-3 mr-1\" />\n                                  {t('monitoring.messageList.viewConversation')}\n                                </Button>\n                              )}\n                            </div>\n                            <div className=\"font-medium text-sm text-red-700 dark:text-red-300 mb-1\">\n                              {error.errorType}\n                            </div>\n                            <p className=\"text-sm text-red-600 dark:text-red-400 line-clamp-2\">\n                              {error.errorMessage}\n                            </p>\n                          </div>\n                        </div>\n                        <span className=\"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4\">\n                          {error.timestamp.toLocaleString()}\n                        </span>\n                      </div>\n                    </div>\n\n                    {expandedErrorId === error.id && (\n                      <div className=\"border-t border-red-200 dark:border-red-900 p-4 bg-white dark:bg-gray-900\">\n                        <div className=\"space-y-3\">\n                          <div className=\"bg-red-50 dark:bg-red-900/20 rounded-lg p-3\">\n                            <h4 className=\"text-sm font-semibold text-red-700 dark:text-red-400 mb-2\">\n                              {t('monitoring.errors.errorMessage')}\n                            </h4>\n                            <div className=\"text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words\">\n                              {error.errorMessage}\n                            </div>\n                          </div>\n\n                          {error.stackTrace && (\n                            <div className=\"bg-gray-50 dark:bg-gray-800 rounded-lg p-3\">\n                              <h4 className=\"text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2\">\n                                {t('monitoring.errors.stackTrace')}\n                              </h4>\n                              <pre className=\"text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-60 bg-white dark:bg-gray-900 p-2 rounded whitespace-pre-wrap break-words\">\n                                {error.stackTrace}\n                              </pre>\n                            </div>\n                          )}\n                        </div>\n                      </div>\n                    )}\n                  </div>\n                ))}\n              </div>\n            )}\n\n            {!loading &&\n              (!data || !data.errors || data.errors.length === 0) && (\n                <div className=\"text-center text-gray-500 dark:text-gray-400 py-16\">\n                  <svg\n                    className=\"w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600\"\n                    fill=\"none\"\n                    viewBox=\"0 0 24 24\"\n                    stroke=\"currentColor\"\n                  >\n                    <path\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth={1.5}\n                      d=\"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z\"\n                    />\n                  </svg>\n                  <p className=\"text-base font-medium text-green-600 dark:text-green-400\">\n                    {t('monitoring.errors.noErrors')}\n                  </p>\n                </div>\n              )}\n          </TabsContent>\n\n          {/* LLM Calls Tab */}\n          <TabsContent value=\"llmCalls\" className=\"m-0 h-full\">\n            {loading && (\n              <div className=\"py-12 flex justify-center\">\n                <LoadingSpinner text={t('common.loading')} />\n              </div>\n            )}\n\n            {!loading && data && data.llmCalls && data.llmCalls.length > 0 && (\n              <div className=\"space-y-3\">\n                {data.llmCalls.map((call) => (\n                  <div\n                    key={call.id}\n                    className=\"border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-all duration-200\"\n                  >\n                    <div className=\"flex items-start justify-between\">\n                      <div className=\"flex-1\">\n                        <div className=\"flex items-center gap-2 mb-2\">\n                          <span\n                            className={`text-xs px-2 py-0.5 rounded ${\n                              call.status === 'success'\n                                ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'\n                                : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'\n                            }`}\n                          >\n                            {call.status}\n                          </span>\n                        </div>\n                        <div className=\"font-medium text-sm text-gray-700 dark:text-gray-300 mb-2\">\n                          {call.modelName}\n                        </div>\n                        <div className=\"text-xs text-gray-600 dark:text-gray-400 space-y-1\">\n                          <div className=\"flex flex-wrap gap-4\">\n                            <span>\n                              {t('monitoring.llmCalls.inputTokens')}:{' '}\n                              {call.tokens.input}\n                            </span>\n                            <span>\n                              {t('monitoring.llmCalls.outputTokens')}:{' '}\n                              {call.tokens.output}\n                            </span>\n                            <span>\n                              {t('monitoring.llmCalls.totalTokens')}:{' '}\n                              {call.tokens.total}\n                            </span>\n                            <span>\n                              {t('monitoring.llmCalls.duration')}:{' '}\n                              {call.duration}ms\n                            </span>\n                            {call.cost && (\n                              <span>\n                                {t('monitoring.llmCalls.cost')}: $\n                                {call.cost.toFixed(4)}\n                              </span>\n                            )}\n                          </div>\n                        </div>\n                        {call.errorMessage && (\n                          <div className=\"mt-2 text-xs text-red-600 dark:text-red-400\">\n                            Error: {call.errorMessage}\n                          </div>\n                        )}\n                      </div>\n                      <span className=\"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4\">\n                        {call.timestamp.toLocaleString()}\n                      </span>\n                    </div>\n                  </div>\n                ))}\n              </div>\n            )}\n\n            {!loading &&\n              (!data || !data.llmCalls || data.llmCalls.length === 0) && (\n                <div className=\"text-center text-gray-500 dark:text-gray-400 py-16\">\n                  <svg\n                    className=\"w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600\"\n                    fill=\"none\"\n                    viewBox=\"0 0 24 24\"\n                    stroke=\"currentColor\"\n                  >\n                    <path\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth={1.5}\n                      d=\"M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\"\n                    />\n                  </svg>\n                  <p className=\"text-base font-medium\">\n                    {t('monitoring.llmCalls.noData')}\n                  </p>\n                </div>\n              )}\n          </TabsContent>\n        </div>\n      </Tabs>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx",
    "content": "import styles from './pipelineCard.module.css';\nimport { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';\nimport { useTranslation } from 'react-i18next';\n\nexport default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {\n  const { t } = useTranslation();\n\n  return (\n    <div className={`${styles.cardContainer}`}>\n      <div className={`${styles.basicInfoContainer}`}>\n        <div className={`${styles.iconBasicInfoContainer}`}>\n          <div className={`${styles.iconEmoji}`}>{cardVO.emoji || '⚙️'}</div>\n          <div className={`${styles.basicInfoNameContainer}`}>\n            <div className={`${styles.basicInfoNameText}  ${styles.bigText}`}>\n              {cardVO.name}\n            </div>\n            <div className={`${styles.basicInfoDescriptionText}`}>\n              {cardVO.description}\n            </div>\n          </div>\n        </div>\n\n        <div className={`${styles.basicInfoLastUpdatedTimeContainer}`}>\n          <svg\n            className={`${styles.basicInfoUpdateTimeIcon}`}\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"currentColor\"\n          >\n            <path d=\"M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z\"></path>\n          </svg>\n          <div className={`${styles.basicInfoUpdateTimeText}`}>\n            {t('pipelines.updateTime')}\n            {cardVO.lastUpdatedTimeAgo}\n          </div>\n        </div>\n      </div>\n\n      {cardVO.isDefault && (\n        <div className={styles.operationContainer}>\n          <div className={styles.operationDefaultBadge}>\n            <svg\n              className={styles.operationDefaultBadgeIcon}\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z\"></path>\n            </svg>\n            <div className={styles.operationDefaultBadgeText}>\n              {t('pipelines.defaultBadge')}\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/components/pipeline-card/PipelineCardVO.ts",
    "content": "export interface IPipelineCardVO {\n  id: string;\n  name: string;\n  description: string;\n  lastUpdatedTimeAgo: string;\n  isDefault: boolean;\n  emoji?: string;\n}\n\nexport class PipelineCardVO implements IPipelineCardVO {\n  id: string;\n  description: string;\n  name: string;\n  lastUpdatedTimeAgo: string;\n  isDefault: boolean;\n  emoji?: string;\n\n  constructor(props: IPipelineCardVO) {\n    this.id = props.id;\n    this.name = props.name;\n    this.description = props.description;\n    this.lastUpdatedTimeAgo = props.lastUpdatedTimeAgo;\n    this.isDefault = props.isDefault;\n    this.emoji = props.emoji;\n  }\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/components/pipeline-card/pipelineCard.module.css",
    "content": ".cardContainer {\n  width: 100%;\n  height: 10rem;\n  background-color: #fff;\n  border-radius: 10px;\n  box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);\n  padding: 1rem;\n  cursor: pointer;\n  display: flex;\n  flex-direction: row;\n  justify-content: space-between;\n  gap: 0.5rem;\n  transition: all 0.2s ease;\n}\n\n:global(.dark) .cardContainer {\n  background-color: #1f1f22;\n  box-shadow: 0;\n}\n\n.cardContainer:hover {\n  box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);\n}\n\n:global(.dark) .cardContainer:hover {\n  box-shadow: 0;\n}\n\n.basicInfoContainer {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  gap: 0.5rem;\n  min-width: 0;\n}\n\n.iconEmoji {\n  width: 3rem;\n  height: 3rem;\n  border-radius: 0.5rem;\n  background-color: #f5f5f5;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 1.75rem;\n  flex-shrink: 0;\n}\n\n:global(.dark) .iconEmoji {\n  background-color: #2a2a2d;\n}\n\n.iconBasicInfoContainer {\n  display: flex;\n  flex-direction: row;\n  gap: 0.75rem;\n  align-items: flex-start;\n  min-width: 0;\n  flex: 1;\n}\n\n.basicInfoNameContainer {\n  display: flex;\n  flex-direction: column;\n  gap: 0.2rem;\n  min-width: 0;\n  flex: 1;\n}\n\n.basicInfoNameText {\n  font-size: 1.4rem;\n  font-weight: 500;\n  color: #1a1a1a;\n}\n\n:global(.dark) .basicInfoNameText {\n  color: #f0f0f0;\n}\n\n.basicInfoDescriptionText {\n  font-size: 0.9rem;\n  font-weight: 400;\n  display: -webkit-box;\n  -webkit-line-clamp: 3;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  color: #b1b1b1;\n}\n\n:global(.dark) .basicInfoDescriptionText {\n  color: #888888;\n}\n\n.basicInfoLastUpdatedTimeContainer {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  gap: 0.5rem;\n}\n\n.basicInfoUpdateTimeIcon {\n  width: 1.2rem;\n  height: 1.2rem;\n  color: #626262;\n}\n\n:global(.dark) .basicInfoUpdateTimeIcon {\n  color: #a0a0a0;\n}\n\n.basicInfoUpdateTimeText {\n  font-size: 1rem;\n  font-weight: 400;\n  color: #626262;\n}\n\n:global(.dark) .basicInfoUpdateTimeText {\n  color: #a0a0a0;\n}\n\n.operationContainer {\n  display: flex;\n  flex-direction: column;\n  align-items: flex-end;\n  justify-content: space-between;\n  gap: 0.5rem;\n  width: 8rem;\n}\n\n.operationDefaultBadge {\n  display: flex;\n  flex-direction: row;\n  gap: 0.5rem;\n}\n\n.operationDefaultBadgeIcon {\n  width: 1.2rem;\n  height: 1.2rem;\n  color: #ffcd27;\n}\n\n:global(.dark) .operationDefaultBadgeIcon {\n  color: #fbbf24;\n}\n\n.operationDefaultBadgeText {\n  font-size: 1rem;\n  font-weight: 400;\n  color: #ffcd27;\n}\n\n:global(.dark) .operationDefaultBadgeText {\n  color: #fbbf24;\n}\n\n.bigText {\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  font-size: 1.4rem;\n  font-weight: bold;\n  max-width: 100%;\n}\n\n.debugButtonIcon {\n  width: 1.2rem;\n  height: 1.2rem;\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx",
    "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { backendClient } from '@/app/infra/http';\nimport { Button } from '@/components/ui/button';\nimport { toast } from 'sonner';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Plus, X, Server, Wrench } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Switch } from '@/components/ui/switch';\nimport { Label } from '@/components/ui/label';\nimport { Plugin } from '@/app/infra/entities/plugin';\nimport { MCPServer } from '@/app/infra/entities/api';\nimport PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';\n\nexport default function PipelineExtension({\n  pipelineId,\n}: {\n  pipelineId: string;\n}) {\n  const { t } = useTranslation();\n  const [loading, setLoading] = useState(true);\n  const [enableAllPlugins, setEnableAllPlugins] = useState(true);\n  const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);\n  const [selectedPlugins, setSelectedPlugins] = useState<Plugin[]>([]);\n  const [allPlugins, setAllPlugins] = useState<Plugin[]>([]);\n  const [selectedMCPServers, setSelectedMCPServers] = useState<MCPServer[]>([]);\n  const [allMCPServers, setAllMCPServers] = useState<MCPServer[]>([]);\n  const [pluginDialogOpen, setPluginDialogOpen] = useState(false);\n  const [mcpDialogOpen, setMcpDialogOpen] = useState(false);\n  const [tempSelectedPluginIds, setTempSelectedPluginIds] = useState<string[]>(\n    [],\n  );\n  const [tempSelectedMCPIds, setTempSelectedMCPIds] = useState<string[]>([]);\n\n  useEffect(() => {\n    loadExtensions();\n  }, [pipelineId]);\n\n  const getPluginId = (plugin: Plugin): string => {\n    const author = plugin.manifest.manifest.metadata.author;\n    const name = plugin.manifest.manifest.metadata.name;\n    return `${author}/${name}`;\n  };\n\n  const loadExtensions = async () => {\n    try {\n      setLoading(true);\n      const data = await backendClient.getPipelineExtensions(pipelineId);\n\n      setEnableAllPlugins(data.enable_all_plugins ?? true);\n      setEnableAllMCPServers(data.enable_all_mcp_servers ?? true);\n\n      const boundPluginIds = new Set(\n        data.bound_plugins.map((p) => `${p.author}/${p.name}`),\n      );\n\n      const selected = data.available_plugins.filter((plugin) =>\n        boundPluginIds.has(getPluginId(plugin)),\n      );\n\n      setSelectedPlugins(selected);\n      setAllPlugins(data.available_plugins);\n\n      // Load MCP servers\n      const boundMCPServerIds = new Set(data.bound_mcp_servers || []);\n      const selectedMCP = data.available_mcp_servers.filter((server) =>\n        boundMCPServerIds.has(server.uuid || ''),\n      );\n\n      setSelectedMCPServers(selectedMCP);\n      setAllMCPServers(data.available_mcp_servers);\n    } catch (error) {\n      console.error('Failed to load extensions:', error);\n      toast.error(t('pipelines.extensions.loadError'));\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const saveToBackend = async (\n    plugins: Plugin[],\n    mcpServers: MCPServer[],\n    newEnableAllPlugins?: boolean,\n    newEnableAllMCPServers?: boolean,\n  ) => {\n    try {\n      const boundPluginsArray = plugins.map((plugin) => {\n        const metadata = plugin.manifest.manifest.metadata;\n        return {\n          author: metadata.author || '',\n          name: metadata.name,\n        };\n      });\n\n      const boundMCPServerIds = mcpServers.map((server) => server.uuid || '');\n\n      await backendClient.updatePipelineExtensions(\n        pipelineId,\n        boundPluginsArray,\n        boundMCPServerIds,\n        newEnableAllPlugins ?? enableAllPlugins,\n        newEnableAllMCPServers ?? enableAllMCPServers,\n      );\n      toast.success(t('pipelines.extensions.saveSuccess'));\n    } catch (error) {\n      console.error('Failed to save extensions:', error);\n      toast.error(t('pipelines.extensions.saveError'));\n      // Reload on error to restore correct state\n      loadExtensions();\n    }\n  };\n\n  const handleRemovePlugin = async (pluginId: string) => {\n    const newPlugins = selectedPlugins.filter(\n      (p) => getPluginId(p) !== pluginId,\n    );\n    setSelectedPlugins(newPlugins);\n    await saveToBackend(newPlugins, selectedMCPServers);\n  };\n\n  const handleRemoveMCPServer = async (serverUuid: string) => {\n    const newServers = selectedMCPServers.filter((s) => s.uuid !== serverUuid);\n    setSelectedMCPServers(newServers);\n    await saveToBackend(selectedPlugins, newServers);\n  };\n\n  const handleOpenPluginDialog = () => {\n    setTempSelectedPluginIds(selectedPlugins.map((p) => getPluginId(p)));\n    setPluginDialogOpen(true);\n  };\n\n  const handleOpenMCPDialog = () => {\n    setTempSelectedMCPIds(selectedMCPServers.map((s) => s.uuid || ''));\n    setMcpDialogOpen(true);\n  };\n\n  const handleTogglePlugin = (pluginId: string) => {\n    setTempSelectedPluginIds((prev) =>\n      prev.includes(pluginId)\n        ? prev.filter((id) => id !== pluginId)\n        : [...prev, pluginId],\n    );\n  };\n\n  const handleToggleMCPServer = (serverUuid: string) => {\n    setTempSelectedMCPIds((prev) =>\n      prev.includes(serverUuid)\n        ? prev.filter((id) => id !== serverUuid)\n        : [...prev, serverUuid],\n    );\n  };\n\n  const handleToggleAllPlugins = () => {\n    if (tempSelectedPluginIds.length === allPlugins.length) {\n      // Deselect all\n      setTempSelectedPluginIds([]);\n    } else {\n      // Select all\n      setTempSelectedPluginIds(allPlugins.map((p) => getPluginId(p)));\n    }\n  };\n\n  const handleToggleAllMCPServers = () => {\n    if (tempSelectedMCPIds.length === allMCPServers.length) {\n      // Deselect all\n      setTempSelectedMCPIds([]);\n    } else {\n      // Select all\n      setTempSelectedMCPIds(allMCPServers.map((s) => s.uuid || ''));\n    }\n  };\n\n  const handleConfirmPluginSelection = async () => {\n    const newSelected = allPlugins.filter((p) =>\n      tempSelectedPluginIds.includes(getPluginId(p)),\n    );\n    setSelectedPlugins(newSelected);\n    setPluginDialogOpen(false);\n    await saveToBackend(newSelected, selectedMCPServers);\n  };\n\n  const handleConfirmMCPSelection = async () => {\n    const newSelected = allMCPServers.filter((s) =>\n      tempSelectedMCPIds.includes(s.uuid || ''),\n    );\n    setSelectedMCPServers(newSelected);\n    setMcpDialogOpen(false);\n    await saveToBackend(selectedPlugins, newSelected);\n  };\n\n  const handleToggleEnableAllPlugins = async (checked: boolean) => {\n    setEnableAllPlugins(checked);\n    await saveToBackend(\n      selectedPlugins,\n      selectedMCPServers,\n      checked,\n      undefined,\n    );\n  };\n\n  const handleToggleEnableAllMCPServers = async (checked: boolean) => {\n    setEnableAllMCPServers(checked);\n    await saveToBackend(\n      selectedPlugins,\n      selectedMCPServers,\n      undefined,\n      checked,\n    );\n  };\n\n  if (loading) {\n    return (\n      <div className=\"space-y-4\">\n        <Skeleton className=\"h-20 w-full\" />\n        <Skeleton className=\"h-20 w-full\" />\n        <Skeleton className=\"h-20 w-full\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-6\">\n      {/* Plugins Section */}\n      <div className=\"space-y-3\">\n        <div className=\"flex items-center justify-between\">\n          <h3 className=\"text-sm font-semibold text-foreground\">\n            {t('pipelines.extensions.pluginsTitle')}\n          </h3>\n          <div className=\"flex items-center gap-2\">\n            <Label\n              htmlFor=\"enable-all-plugins\"\n              className=\"text-sm font-normal cursor-pointer\"\n            >\n              {t('pipelines.extensions.enableAllPlugins')}\n            </Label>\n            <Switch\n              id=\"enable-all-plugins\"\n              checked={enableAllPlugins}\n              onCheckedChange={handleToggleEnableAllPlugins}\n            />\n          </div>\n        </div>\n        <div className=\"space-y-2\">\n          {enableAllPlugins ? (\n            <div className=\"flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30\">\n              <p className=\"text-sm text-muted-foreground\">\n                {t('pipelines.extensions.allPluginsEnabled')}\n              </p>\n            </div>\n          ) : selectedPlugins.length === 0 ? (\n            <div className=\"flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border\">\n              <p className=\"text-sm text-muted-foreground\">\n                {t('pipelines.extensions.noPluginsSelected')}\n              </p>\n            </div>\n          ) : (\n            <div className=\"space-y-2\">\n              {selectedPlugins.map((plugin) => {\n                const pluginId = getPluginId(plugin);\n                const metadata = plugin.manifest.manifest.metadata;\n                return (\n                  <div\n                    key={pluginId}\n                    className=\"flex items-center justify-between rounded-lg border p-3 hover:bg-accent\"\n                  >\n                    <div className=\"flex-1 flex items-center gap-3\">\n                      <img\n                        src={backendClient.getPluginIconURL(\n                          metadata.author || '',\n                          metadata.name,\n                        )}\n                        alt={metadata.name}\n                        className=\"w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0\"\n                      />\n                      <div className=\"flex-1\">\n                        <div className=\"font-medium\">{metadata.name}</div>\n                        <div className=\"text-sm text-muted-foreground\">\n                          {metadata.author} • v{metadata.version}\n                        </div>\n                        <div className=\"flex gap-1 mt-1\">\n                          <PluginComponentList\n                            components={(() => {\n                              const componentKindCount: Record<string, number> =\n                                {};\n                              for (const component of plugin.components) {\n                                const kind = component.manifest.manifest.kind;\n                                if (componentKindCount[kind]) {\n                                  componentKindCount[kind]++;\n                                } else {\n                                  componentKindCount[kind] = 1;\n                                }\n                              }\n                              return componentKindCount;\n                            })()}\n                            showComponentName={true}\n                            showTitle={false}\n                            useBadge={true}\n                            t={t}\n                          />\n                        </div>\n                      </div>\n                      {!plugin.enabled && (\n                        <Badge variant=\"secondary\">\n                          {t('pipelines.extensions.disabled')}\n                        </Badge>\n                      )}\n                    </div>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      onClick={() => handleRemovePlugin(pluginId)}\n                    >\n                      <X className=\"h-4 w-4\" />\n                    </Button>\n                  </div>\n                );\n              })}\n            </div>\n          )}\n        </div>\n\n        <Button\n          onClick={handleOpenPluginDialog}\n          variant=\"outline\"\n          className=\"w-full\"\n          disabled={enableAllPlugins}\n        >\n          <Plus className=\"mr-2 h-4 w-4\" />\n          {t('pipelines.extensions.addPlugin')}\n        </Button>\n      </div>\n\n      {/* MCP Servers Section */}\n      <div className=\"space-y-3\">\n        <div className=\"flex items-center justify-between\">\n          <h3 className=\"text-sm font-semibold text-foreground\">\n            {t('pipelines.extensions.mcpServersTitle')}\n          </h3>\n          <div className=\"flex items-center gap-2\">\n            <Label\n              htmlFor=\"enable-all-mcp-servers\"\n              className=\"text-sm font-normal cursor-pointer\"\n            >\n              {t('pipelines.extensions.enableAllMCPServers')}\n            </Label>\n            <Switch\n              id=\"enable-all-mcp-servers\"\n              checked={enableAllMCPServers}\n              onCheckedChange={handleToggleEnableAllMCPServers}\n            />\n          </div>\n        </div>\n        <div className=\"space-y-2\">\n          {enableAllMCPServers ? (\n            <div className=\"flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30\">\n              <p className=\"text-sm text-muted-foreground\">\n                {t('pipelines.extensions.allMCPServersEnabled')}\n              </p>\n            </div>\n          ) : selectedMCPServers.length === 0 ? (\n            <div className=\"flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border\">\n              <p className=\"text-sm text-muted-foreground\">\n                {t('pipelines.extensions.noMCPServersSelected')}\n              </p>\n            </div>\n          ) : (\n            <div className=\"space-y-2\">\n              {selectedMCPServers.map((server) => (\n                <div\n                  key={server.uuid}\n                  className=\"flex items-center justify-between rounded-lg border p-3 hover:bg-accent\"\n                >\n                  <div className=\"flex-1 flex items-center gap-3\">\n                    <div className=\"w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0\">\n                      <Server className=\"h-5 w-5 text-muted-foreground\" />\n                    </div>\n                    <div className=\"flex-1\">\n                      <div className=\"font-medium\">{server.name}</div>\n                      <div className=\"text-sm text-muted-foreground\">\n                        {server.mode}\n                      </div>\n                      {server.runtime_info &&\n                        server.runtime_info.status === 'connected' && (\n                          <Badge\n                            variant=\"outline\"\n                            className=\"flex items-center gap-1 mt-1\"\n                          >\n                            <Wrench className=\"h-3 w-3 text-black dark:text-white\" />\n                            <span className=\"text-xs text-black dark:text-white\">\n                              {t('pipelines.extensions.toolCount', {\n                                count: server.runtime_info.tool_count || 0,\n                              })}\n                            </span>\n                          </Badge>\n                        )}\n                    </div>\n                    {!server.enable && (\n                      <Badge variant=\"secondary\">\n                        {t('pipelines.extensions.disabled')}\n                      </Badge>\n                    )}\n                  </div>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    onClick={() => handleRemoveMCPServer(server.uuid || '')}\n                  >\n                    <X className=\"h-4 w-4\" />\n                  </Button>\n                </div>\n              ))}\n            </div>\n          )}\n        </div>\n\n        <Button\n          onClick={handleOpenMCPDialog}\n          variant=\"outline\"\n          className=\"w-full\"\n          disabled={enableAllMCPServers}\n        >\n          <Plus className=\"mr-2 h-4 w-4\" />\n          {t('pipelines.extensions.addMCPServer')}\n        </Button>\n      </div>\n\n      {/* Plugin Selection Dialog */}\n      <Dialog open={pluginDialogOpen} onOpenChange={setPluginDialogOpen}>\n        <DialogContent className=\"max-w-2xl max-h-[80vh] overflow-hidden flex flex-col\">\n          <DialogHeader>\n            <DialogTitle>{t('pipelines.extensions.selectPlugins')}</DialogTitle>\n          </DialogHeader>\n          {allPlugins.length > 0 && (\n            <div\n              className=\"flex items-center gap-3 px-1 py-2 border-b cursor-pointer\"\n              onClick={handleToggleAllPlugins}\n            >\n              <Checkbox\n                checked={\n                  tempSelectedPluginIds.length === allPlugins.length &&\n                  allPlugins.length > 0\n                }\n                onCheckedChange={handleToggleAllPlugins}\n              />\n              <span className=\"text-sm font-medium\">\n                {t('pipelines.extensions.selectAll')}\n              </span>\n            </div>\n          )}\n          <div className=\"flex-1 overflow-y-auto space-y-2 pr-2\">\n            {allPlugins.length === 0 ? (\n              <div className=\"flex h-full items-center justify-center\">\n                <p className=\"text-sm text-muted-foreground\">\n                  {t('pipelines.extensions.noPluginsInstalled')}\n                </p>\n              </div>\n            ) : (\n              allPlugins.map((plugin) => {\n                const pluginId = getPluginId(plugin);\n                const metadata = plugin.manifest.manifest.metadata;\n                const isSelected = tempSelectedPluginIds.includes(pluginId);\n                return (\n                  <div\n                    key={pluginId}\n                    className=\"flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer\"\n                    onClick={() => handleTogglePlugin(pluginId)}\n                  >\n                    <Checkbox checked={isSelected} />\n                    <img\n                      src={backendClient.getPluginIconURL(\n                        metadata.author || '',\n                        metadata.name,\n                      )}\n                      alt={metadata.name}\n                      className=\"w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0\"\n                    />\n                    <div className=\"flex-1\">\n                      <div className=\"font-medium\">{metadata.name}</div>\n                      <div className=\"text-sm text-muted-foreground\">\n                        {metadata.author} • v{metadata.version}\n                      </div>\n                      <div className=\"flex gap-1 mt-1\">\n                        <PluginComponentList\n                          components={(() => {\n                            const componentKindCount: Record<string, number> =\n                              {};\n                            for (const component of plugin.components) {\n                              const kind = component.manifest.manifest.kind;\n                              if (componentKindCount[kind]) {\n                                componentKindCount[kind]++;\n                              } else {\n                                componentKindCount[kind] = 1;\n                              }\n                            }\n                            return componentKindCount;\n                          })()}\n                          showComponentName={true}\n                          showTitle={false}\n                          useBadge={true}\n                          t={t}\n                        />\n                      </div>\n                    </div>\n                    {!plugin.enabled && (\n                      <Badge variant=\"secondary\">\n                        {t('pipelines.extensions.disabled')}\n                      </Badge>\n                    )}\n                  </div>\n                );\n              })\n            )}\n          </div>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setPluginDialogOpen(false)}\n            >\n              {t('common.cancel')}\n            </Button>\n            <Button onClick={handleConfirmPluginSelection}>\n              {t('common.confirm')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* MCP Server Selection Dialog */}\n      <Dialog open={mcpDialogOpen} onOpenChange={setMcpDialogOpen}>\n        <DialogContent className=\"max-w-2xl max-h-[80vh] overflow-hidden flex flex-col\">\n          <DialogHeader>\n            <DialogTitle>\n              {t('pipelines.extensions.selectMCPServers')}\n            </DialogTitle>\n          </DialogHeader>\n          {allMCPServers.length > 0 && (\n            <div\n              className=\"flex items-center gap-3 px-1 py-2 border-b cursor-pointer\"\n              onClick={handleToggleAllMCPServers}\n            >\n              <Checkbox\n                checked={\n                  tempSelectedMCPIds.length === allMCPServers.length &&\n                  allMCPServers.length > 0\n                }\n                onCheckedChange={handleToggleAllMCPServers}\n              />\n              <span className=\"text-sm font-medium\">\n                {t('pipelines.extensions.selectAll')}\n              </span>\n            </div>\n          )}\n          <div className=\"flex-1 overflow-y-auto space-y-2 pr-2\">\n            {allMCPServers.length === 0 ? (\n              <div className=\"flex h-full items-center justify-center\">\n                <p className=\"text-sm text-muted-foreground\">\n                  {t('pipelines.extensions.noMCPServersConfigured')}\n                </p>\n              </div>\n            ) : (\n              allMCPServers.map((server) => {\n                const isSelected = tempSelectedMCPIds.includes(\n                  server.uuid || '',\n                );\n                return (\n                  <div\n                    key={server.uuid}\n                    className=\"flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer\"\n                    onClick={() => handleToggleMCPServer(server.uuid || '')}\n                  >\n                    <Checkbox checked={isSelected} />\n                    <div className=\"w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0\">\n                      <Server className=\"h-5 w-5 text-muted-foreground\" />\n                    </div>\n                    <div className=\"flex-1\">\n                      <div className=\"font-medium\">{server.name}</div>\n                      <div className=\"text-sm text-muted-foreground\">\n                        {server.mode}\n                      </div>\n                      {server.runtime_info &&\n                        server.runtime_info.status === 'connected' && (\n                          <Badge\n                            variant=\"outline\"\n                            className=\"flex items-center gap-1 mt-1\"\n                          >\n                            <Wrench className=\"h-3 w-3 text-black dark:text-white\" />\n                            <span className=\"text-xs text-black dark:text-white\">\n                              {t('pipelines.extensions.toolCount', {\n                                count: server.runtime_info.tool_count || 0,\n                              })}\n                            </span>\n                          </Badge>\n                        )}\n                    </div>\n                    {!server.enable && (\n                      <Badge variant=\"secondary\">\n                        {t('pipelines.extensions.disabled')}\n                      </Badge>\n                    )}\n                  </div>\n                );\n              })\n            )}\n          </div>\n          <DialogFooter>\n            <Button variant=\"outline\" onClick={() => setMcpDialogOpen(false)}>\n              {t('common.cancel')}\n            </Button>\n            <Button onClick={handleConfirmMCPSelection}>\n              {t('common.confirm')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx",
    "content": "import { useEffect, useRef, useState, useMemo } from 'react';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';\nimport {\n  PipelineConfigTab,\n  PipelineConfigStage,\n} from '@/app/infra/entities/pipeline';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';\nimport N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';\nimport { Button } from '@/components/ui/button';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { z } from 'zod';\nimport { Input } from '@/components/ui/input';\nimport EmojiPicker from '@/components/ui/emoji-picker';\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from '@/components/ui/form';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\n\nexport default function PipelineFormComponent({\n  onFinish,\n  onNewPipelineCreated,\n  isEditMode,\n  pipelineId,\n  showButtons = true,\n  onDeletePipeline,\n  onCancel,\n}: {\n  pipelineId?: string;\n  isEditMode: boolean;\n  disableForm: boolean;\n  showButtons?: boolean;\n  onFinish: () => void;\n  onNewPipelineCreated: (pipelineId: string) => void;\n  onDeletePipeline: () => void;\n  onCancel: () => void;\n}) {\n  const { t } = useTranslation();\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n  const [showCopyConfirm, setShowCopyConfirm] = useState(false);\n  const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);\n\n  const formSchema = isEditMode\n    ? z.object({\n        basic: z.object({\n          name: z.string().min(1, { message: t('pipelines.nameRequired') }),\n          description: z\n            .string()\n            .min(1, { message: t('pipelines.descriptionRequired') }),\n          emoji: z.string().optional(),\n        }),\n        ai: z.record(z.string(), z.any()),\n        trigger: z.record(z.string(), z.any()),\n        safety: z.record(z.string(), z.any()),\n        output: z.record(z.string(), z.any()),\n      })\n    : z.object({\n        basic: z.object({\n          name: z.string().min(1, { message: t('pipelines.nameRequired') }),\n          description: z\n            .string()\n            .min(1, { message: t('pipelines.descriptionRequired') }),\n          emoji: z.string().optional(),\n        }),\n        ai: z.record(z.string(), z.any()).optional(),\n        trigger: z.record(z.string(), z.any()).optional(),\n        safety: z.record(z.string(), z.any()).optional(),\n        output: z.record(z.string(), z.any()).optional(),\n      });\n\n  type FormValues = z.infer<typeof formSchema>;\n  // 这里不好，可以改成enum等\n  const formLabelList: FormLabel[] = isEditMode\n    ? [\n        { label: t('pipelines.basicInfo'), name: 'basic' },\n        { label: t('pipelines.aiCapabilities'), name: 'ai' },\n        { label: t('pipelines.triggerConditions'), name: 'trigger' },\n        { label: t('pipelines.safetyControls'), name: 'safety' },\n        { label: t('pipelines.outputProcessing'), name: 'output' },\n      ]\n    : [{ label: t('pipelines.basicInfo'), name: 'basic' }];\n\n  const [aiConfigTabSchema, setAIConfigTabSchema] =\n    useState<PipelineConfigTab>();\n  const [triggerConfigTabSchema, setTriggerConfigTabSchema] =\n    useState<PipelineConfigTab>();\n  const [safetyConfigTabSchema, setSafetyConfigTabSchema] =\n    useState<PipelineConfigTab>();\n  const [outputConfigTabSchema, setOutputConfigTabSchema] =\n    useState<PipelineConfigTab>();\n\n  const form = useForm<FormValues>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      basic: {\n        emoji: '⚙️',\n      },\n      ai: {},\n      trigger: {},\n      safety: {},\n      output: {},\n    },\n  });\n\n  // Track unsaved changes by comparing current form values against a saved snapshot\n  const savedSnapshotRef = useRef<string>('');\n  // Track which dynamic form stages have completed their initial mount emission.\n  const initializedStagesRef = useRef<Set<string>>(new Set());\n  const watchedValues = form.watch();\n  const hasUnsavedChanges = useMemo(() => {\n    if (!isEditMode || !savedSnapshotRef.current) return false;\n    return JSON.stringify(watchedValues) !== savedSnapshotRef.current;\n  }, [isEditMode, watchedValues]);\n\n  useEffect(() => {\n    // get config schema from metadata\n    httpClient.getGeneralPipelineMetadata().then((resp) => {\n      for (const config of resp.configs) {\n        if (config.name === 'ai') {\n          setAIConfigTabSchema(config);\n        } else if (config.name === 'trigger') {\n          setTriggerConfigTabSchema(config);\n        } else if (config.name === 'safety') {\n          setSafetyConfigTabSchema(config);\n        } else if (config.name === 'output') {\n          setOutputConfigTabSchema(config);\n        }\n      }\n    });\n\n    if (isEditMode) {\n      httpClient\n        .getPipeline(pipelineId || '')\n        .then((resp: GetPipelineResponseData) => {\n          setIsDefaultPipeline(resp.pipeline.is_default ?? false);\n          const loadedValues = {\n            basic: {\n              name: resp.pipeline.name,\n              description: resp.pipeline.description,\n              emoji: resp.pipeline.emoji || '⚙️',\n            },\n            ai: resp.pipeline.config.ai,\n            trigger: resp.pipeline.config.trigger,\n            safety: resp.pipeline.config.safety,\n            output: resp.pipeline.config.output,\n          };\n          form.reset(loadedValues);\n          savedSnapshotRef.current = JSON.stringify(loadedValues);\n          initializedStagesRef.current.clear();\n        });\n    }\n  }, []);\n\n  useEffect(() => {\n    if (!isEditMode) {\n      form.reset({\n        basic: {\n          name: '',\n          description: '',\n          emoji: '⚙️',\n        },\n      });\n    }\n  }, [form, isEditMode]);\n\n  function handleFormSubmit(values: FormValues) {\n    if (isEditMode) {\n      handleModify(values);\n    } else {\n      handleCreate(values);\n    }\n  }\n\n  function handleCreate(values: FormValues) {\n    const pipeline: Pipeline = {\n      config: {},\n      description: values.basic.description,\n      name: values.basic.name,\n      emoji: values.basic.emoji,\n    };\n    httpClient\n      .createPipeline(pipeline)\n      .then((resp) => {\n        onFinish();\n        onNewPipelineCreated(resp.uuid);\n        toast.success(t('pipelines.createSuccess'));\n      })\n      .catch((err) => {\n        toast.error(t('pipelines.createError') + err.msg);\n      });\n  }\n\n  function handleModify(values: FormValues) {\n    const realConfig = {\n      ai: values.ai,\n      trigger: values.trigger,\n      safety: values.safety,\n      output: values.output,\n    };\n\n    const pipeline: Pipeline = {\n      config: realConfig,\n      // created_at: '',\n      description: values.basic.description,\n      // for_version: '',\n      name: values.basic.name,\n      emoji: values.basic.emoji,\n      // stages: [],\n      // updated_at: '',\n      // uuid: pipelineId || '',\n      // is_default: false,\n    };\n    httpClient\n      .updatePipeline(pipelineId || '', pipeline)\n      .then(() => {\n        savedSnapshotRef.current = JSON.stringify(form.getValues());\n        onFinish();\n        toast.success(t('pipelines.saveSuccess'));\n      })\n      .catch((err) => {\n        toast.error(t('pipelines.saveError') + err.msg);\n      });\n  }\n\n  // Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.\n  // On the first emission for a stage (mount-time default filling), the\n  // snapshot is synchronously re-captured so that hasUnsavedChanges stays false.\n  function handleDynamicFormEmit(\n    formName: keyof FormValues,\n    stageName: string,\n    values: object,\n  ) {\n    const stageKey = `${String(formName)}.${stageName}`;\n    const isFirstEmission = !initializedStagesRef.current.has(stageKey);\n\n    const currentValues =\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      (form.getValues(formName) as Record<string, any>) || {};\n    form.setValue(formName, {\n      ...currentValues,\n      [stageName]: values,\n    });\n\n    if (isFirstEmission) {\n      initializedStagesRef.current.add(stageKey);\n      // Synchronously re-capture snapshot so that the useMemo comparison\n      // in the same render cycle still returns false.\n      savedSnapshotRef.current = JSON.stringify(form.getValues());\n    }\n  }\n\n  function renderDynamicForms(\n    stage: PipelineConfigStage,\n    formName: keyof FormValues,\n  ) {\n    // 如果是 AI 配置，需要特殊处理\n    if (formName === 'ai') {\n      // 获取当前选择的 runner\n      const currentRunner = form.watch('ai.runner.runner');\n\n      // 如果是 runner 配置项，直接渲染\n      if (stage.name === 'runner') {\n        return (\n          <div key={stage.name} className=\"space-y-4 mb-6\">\n            <div className=\"text-lg font-medium\">\n              {extractI18nObject(stage.label)}\n            </div>\n            {stage.description && (\n              <div className=\"text-sm text-gray-500\">\n                {extractI18nObject(stage.description)}\n              </div>\n            )}\n            <DynamicFormComponent\n              itemConfigList={stage.config}\n              initialValues={\n                // eslint-disable-next-line @typescript-eslint/no-explicit-any\n                (form.watch(formName) as Record<string, any>)?.[stage.name] ||\n                {}\n              }\n              onSubmit={(values) => {\n                handleDynamicFormEmit(formName, stage.name, values);\n              }}\n            />\n          </div>\n        );\n      }\n\n      // 如果不是当前选择的 runner 对应的配置项，则不渲染\n      if (stage.name !== currentRunner) {\n        return null;\n      }\n\n      // 对于n8n-service-api配置，使用N8nAuthFormComponent处理表单联动\n      if (stage.name === 'n8n-service-api') {\n        return (\n          <div key={stage.name} className=\"space-y-4 mb-6\">\n            <div className=\"text-lg font-medium\">\n              {extractI18nObject(stage.label)}\n            </div>\n            {stage.description && (\n              <div className=\"text-sm text-gray-500\">\n                {extractI18nObject(stage.description)}\n              </div>\n            )}\n            <N8nAuthFormComponent\n              itemConfigList={stage.config}\n              initialValues={\n                // eslint-disable-next-line @typescript-eslint/no-explicit-any\n                (form.watch(formName) as Record<string, any>)?.[stage.name] ||\n                {}\n              }\n              onSubmit={(values) => {\n                handleDynamicFormEmit(formName, stage.name, values);\n              }}\n            />\n          </div>\n        );\n      }\n    }\n\n    return (\n      <div key={stage.name} className=\"space-y-4 mb-6\">\n        <div className=\"text-lg font-medium\">\n          {extractI18nObject(stage.label)}\n        </div>\n        {stage.description && (\n          <div className=\"text-sm text-gray-500\">\n            {extractI18nObject(stage.description)}\n          </div>\n        )}\n        <DynamicFormComponent\n          itemConfigList={stage.config}\n          initialValues={\n            // eslint-disable-next-line @typescript-eslint/no-explicit-any\n            (form.watch(formName) as Record<string, any>)?.[stage.name] || {}\n          }\n          onSubmit={(values) => {\n            handleDynamicFormEmit(formName, stage.name, values);\n          }}\n        />\n      </div>\n    );\n  }\n\n  const handleDelete = () => {\n    setShowDeleteConfirm(true);\n  };\n\n  const confirmDelete = () => {\n    if (pipelineId) {\n      httpClient\n        .deletePipeline(pipelineId)\n        .then(() => {\n          onDeletePipeline();\n          setShowDeleteConfirm(false);\n          toast.success(t('pipelines.deleteSuccess'));\n        })\n        .catch((err) => {\n          toast.error(t('pipelines.deleteError') + err.msg);\n        });\n    }\n  };\n\n  const handleCopy = () => {\n    setShowCopyConfirm(true);\n  };\n\n  const confirmCopy = () => {\n    if (pipelineId) {\n      httpClient\n        .copyPipeline(pipelineId)\n        .then(() => {\n          onFinish();\n          toast.success(t('common.copySuccess'));\n          setShowCopyConfirm(false);\n          onCancel();\n        })\n        .catch((err) => {\n          toast.error(t('pipelines.createError') + err.msg);\n        });\n    }\n  };\n\n  return (\n    <>\n      <div className=\"!max-w-[70vw] max-w-6xl h-full p-0 flex flex-col bg-white dark:bg-black\">\n        <Form {...form}>\n          <form\n            id=\"pipeline-form\"\n            onSubmit={form.handleSubmit(handleFormSubmit)}\n            className=\"h-full flex flex-col flex-1 min-h-0 mb-2\"\n          >\n            <div className=\"flex-1 flex flex-col min-h-0\">\n              <Tabs\n                defaultValue={formLabelList[0].name}\n                className=\"h-full flex flex-col flex-1 min-h-0\"\n              >\n                <TabsList>\n                  {formLabelList.map((formLabel) => (\n                    <TabsTrigger key={formLabel.name} value={formLabel.name}>\n                      {formLabel.label}\n                    </TabsTrigger>\n                  ))}\n                </TabsList>\n\n                <div\n                  id=\"pipeline-form-content\"\n                  className=\"flex-1 overflow-y-auto min-h-0\"\n                >\n                  {formLabelList.map((formLabel) => (\n                    <TabsContent\n                      key={formLabel.name}\n                      value={formLabel.name}\n                      className=\"overflow-y-auto max-h-full\"\n                    >\n                      {formLabel.name === 'basic' && (\n                        <div className=\"space-y-6\">\n                          {/* Name and Emoji in same row */}\n                          <div className=\"flex gap-4 items-start\">\n                            <FormField\n                              control={form.control}\n                              name=\"basic.name\"\n                              render={({ field }) => (\n                                <FormItem className=\"flex-1\">\n                                  <FormLabel>\n                                    {t('common.name')}\n                                    <span className=\"text-red-500\">*</span>\n                                  </FormLabel>\n                                  <FormControl>\n                                    <Input {...field} />\n                                  </FormControl>\n                                  <FormMessage />\n                                </FormItem>\n                              )}\n                            />\n                            <FormField\n                              control={form.control}\n                              name=\"basic.emoji\"\n                              render={({ field }) => (\n                                <FormItem>\n                                  <FormLabel>{t('common.icon')}</FormLabel>\n                                  <FormControl>\n                                    <EmojiPicker\n                                      value={field.value}\n                                      onChange={field.onChange}\n                                    />\n                                  </FormControl>\n                                  <FormMessage />\n                                </FormItem>\n                              )}\n                            />\n                          </div>\n\n                          <FormField\n                            control={form.control}\n                            name=\"basic.description\"\n                            render={({ field }) => (\n                              <FormItem>\n                                <FormLabel>\n                                  {t('common.description')}\n                                  <span className=\"text-red-500\">*</span>\n                                </FormLabel>\n                                <FormControl>\n                                  <Input {...field} />\n                                </FormControl>\n                                <FormMessage />\n                              </FormItem>\n                            )}\n                          />\n                        </div>\n                      )}\n\n                      {isEditMode && (\n                        <>\n                          {formLabel.name === 'ai' && aiConfigTabSchema && (\n                            <div className=\"space-y-6\">\n                              {aiConfigTabSchema.stages.map((stage) =>\n                                renderDynamicForms(stage, 'ai'),\n                              )}\n                            </div>\n                          )}\n\n                          {formLabel.name === 'trigger' &&\n                            triggerConfigTabSchema && (\n                              <div className=\"space-y-6\">\n                                {triggerConfigTabSchema.stages.map((stage) =>\n                                  renderDynamicForms(stage, 'trigger'),\n                                )}\n                              </div>\n                            )}\n\n                          {formLabel.name === 'safety' &&\n                            safetyConfigTabSchema && (\n                              <div className=\"space-y-6\">\n                                {safetyConfigTabSchema.stages.map((stage) =>\n                                  renderDynamicForms(stage, 'safety'),\n                                )}\n                              </div>\n                            )}\n\n                          {formLabel.name === 'output' &&\n                            outputConfigTabSchema && (\n                              <div className=\"space-y-6\">\n                                {outputConfigTabSchema.stages.map((stage) =>\n                                  renderDynamicForms(stage, 'output'),\n                                )}\n                              </div>\n                            )}\n                        </>\n                      )}\n                    </TabsContent>\n                  ))}\n                </div>\n              </Tabs>\n            </div>\n          </form>\n          {/* 按钮栏移到 Tabs 外部，始终固定底部 */}\n          {showButtons && (\n            <div className=\"flex justify-end items-center gap-2 pt-4 border-t mb-0 bg-white dark:bg-black sticky bottom-0 z-10\">\n              {isEditMode && hasUnsavedChanges && (\n                <div className=\"text-amber-600 dark:text-amber-400 text-sm flex items-center gap-1.5 mr-auto\">\n                  <span className=\"inline-block w-1.5 h-1.5 rounded-full bg-amber-500\" />\n                  {t('pipelines.unsavedChanges')}\n                </div>\n              )}\n\n              {isEditMode && !isDefaultPipeline && (\n                <Button\n                  type=\"button\"\n                  variant=\"destructive\"\n                  onClick={handleDelete}\n                >\n                  {t('common.delete')}\n                </Button>\n              )}\n\n              {isEditMode && isDefaultPipeline && (\n                <div className=\"text-gray-500 text-sm h-full flex items-center mr-2\">\n                  {t('pipelines.defaultPipelineCannotDelete')}\n                </div>\n              )}\n\n              {isEditMode && (\n                <Button\n                  type=\"button\"\n                  variant=\"default\"\n                  onClick={handleCopy}\n                  className=\"bg-green-600 hover:bg-green-700 text-white\"\n                >\n                  {t('common.copy')}\n                </Button>\n              )}\n\n              <Button type=\"submit\" form=\"pipeline-form\">\n                {isEditMode ? t('common.save') : t('common.submit')}\n              </Button>\n              <Button type=\"button\" variant=\"outline\" onClick={onCancel}>\n                {t('common.cancel')}\n              </Button>\n            </div>\n          )}\n        </Form>\n      </div>\n\n      {/* 删除确认对话框 */}\n      <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>{t('common.confirmDelete')}</DialogTitle>\n          </DialogHeader>\n          <div className=\"py-4\">{t('pipelines.deleteConfirmation')}</div>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setShowDeleteConfirm(false)}\n            >\n              {t('common.cancel')}\n            </Button>\n            <Button variant=\"destructive\" onClick={confirmDelete}>\n              {t('common.confirmDelete')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* 复制确认对话框 */}\n      <Dialog open={showCopyConfirm} onOpenChange={setShowCopyConfirm}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>{t('pipelines.copyConfirmTitle')}</DialogTitle>\n          </DialogHeader>\n          <div className=\"py-4\">{t('pipelines.copyConfirmation')}</div>\n          <DialogFooter>\n            <Button variant=\"outline\" onClick={() => setShowCopyConfirm(false)}>\n              {t('common.cancel')}\n            </Button>\n            <Button onClick={confirmCopy}>{t('common.confirm')}</Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\ninterface FormLabel {\n  label: string;\n  name: string;\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/components/pipeline-form/pipelineFormStyle.module.css",
    "content": ".formItemSubtitle {\n  font-size: 18px;\n  font-weight: bold;\n  margin-bottom: 10px;\n}\n\n.changeFormButtonGroupContainer {\n  width: 320px;\n  display: flex;\n  flex-direction: row;\n  justify-content: space-between;\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/page.tsx",
    "content": "'use client';\nimport { useState, useEffect } from 'react';\nimport CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';\nimport PipelineCard from '@/app/home/pipelines/components/pipeline-card/PipelineCard';\nimport styles from './pipelineConfig.module.css';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport PipelineDialog from './PipelineDetailDialog';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { systemInfo } from '@/app/infra/http';\n\nexport default function PluginConfigPage() {\n  const { t } = useTranslation();\n  const [dialogOpen, setDialogOpen] = useState<boolean>(false);\n  const [isEditForm, setIsEditForm] = useState(false);\n  const [pipelineList, setPipelineList] = useState<PipelineCardVO[]>([]);\n  const [selectedPipelineId, setSelectedPipelineId] = useState('');\n  const [sortByValue, setSortByValue] = useState<string>('created_at');\n  const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');\n\n  useEffect(() => {\n    // Load sort preference from localStorage\n    const savedSortBy = localStorage.getItem('pipeline_sort_by');\n    const savedSortOrder = localStorage.getItem('pipeline_sort_order');\n\n    if (savedSortBy && savedSortOrder) {\n      setSortByValue(savedSortBy);\n      setSortOrderValue(savedSortOrder);\n      getPipelines(savedSortBy, savedSortOrder);\n    } else {\n      getPipelines();\n    }\n  }, []);\n\n  function getPipelines(\n    sortBy: string = sortByValue,\n    sortOrder: string = sortOrderValue,\n  ) {\n    httpClient\n      .getPipelines(sortBy, sortOrder)\n      .then((value) => {\n        const currentTime = new Date();\n        const pipelineList = value.pipelines.map((pipeline) => {\n          const lastUpdatedTimeAgo = Math.floor(\n            (currentTime.getTime() -\n              new Date(\n                pipeline.updated_at ?? currentTime.getTime(),\n              ).getTime()) /\n              1000 /\n              60 /\n              60 /\n              24,\n          );\n\n          const lastUpdatedTimeAgoText =\n            lastUpdatedTimeAgo > 0\n              ? ` ${lastUpdatedTimeAgo} ${t('pipelines.daysAgo')}`\n              : t('pipelines.today');\n\n          return new PipelineCardVO({\n            lastUpdatedTimeAgo: lastUpdatedTimeAgoText,\n            description: pipeline.description,\n            id: pipeline.uuid ?? '',\n            name: pipeline.name,\n            emoji: pipeline.emoji,\n            isDefault: pipeline.is_default ?? false,\n          });\n        });\n        setPipelineList(pipelineList);\n      })\n      .catch((error) => {\n        toast.error(t('pipelines.getPipelineListError') + error.message);\n      });\n  }\n\n  const handlePipelineClick = (pipelineId: string) => {\n    setSelectedPipelineId(pipelineId);\n    setIsEditForm(true);\n    setDialogOpen(true);\n  };\n\n  const handleCreateNew = () => {\n    const maxPipelines = systemInfo.limitation?.max_pipelines ?? -1;\n    if (maxPipelines >= 0 && pipelineList.length >= maxPipelines) {\n      toast.error(t('limitation.maxPipelinesReached', { max: maxPipelines }));\n      return;\n    }\n    setIsEditForm(false);\n    setSelectedPipelineId('');\n    setDialogOpen(true);\n  };\n\n  function handleSortChange(value: string) {\n    const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim());\n    setSortByValue(newSortBy);\n    setSortOrderValue(newSortOrder);\n\n    // Save sort preference to localStorage\n    localStorage.setItem('pipeline_sort_by', newSortBy);\n    localStorage.setItem('pipeline_sort_order', newSortOrder);\n\n    getPipelines(newSortBy, newSortOrder);\n  }\n\n  return (\n    <div className={styles.configPageContainer}>\n      <PipelineDialog\n        open={dialogOpen}\n        onOpenChange={setDialogOpen}\n        pipelineId={selectedPipelineId || undefined}\n        isEditMode={isEditForm}\n        onFinish={() => {\n          getPipelines();\n        }}\n        onNewPipelineCreated={(pipelineId) => {\n          getPipelines();\n          setSelectedPipelineId(pipelineId);\n          setIsEditForm(true);\n          setDialogOpen(true);\n        }}\n        onDeletePipeline={() => {\n          getPipelines();\n          setDialogOpen(false);\n        }}\n        onCancel={() => {\n          setDialogOpen(false);\n        }}\n      />\n\n      <div className=\"flex flex-row justify-between items-center mb-4 px-[0.8rem]\">\n        <Select\n          value={`${sortByValue},${sortOrderValue}`}\n          onValueChange={handleSortChange}\n        >\n          <SelectTrigger className=\"w-[180px] cursor-pointer bg-[#ffffff] dark:bg-[#2a2a2e]\">\n            <SelectValue placeholder={t('pipelines.sortBy')} />\n          </SelectTrigger>\n          <SelectContent className=\"bg-[#ffffff] dark:bg-[#2a2a2e]\">\n            <SelectItem\n              value=\"created_at,DESC\"\n              className=\"text-gray-900 dark:text-gray-100\"\n            >\n              {t('pipelines.newestCreated')}\n            </SelectItem>\n            <SelectItem\n              value=\"created_at,ASC\"\n              className=\"text-gray-900 dark:text-gray-100\"\n            >\n              {t('pipelines.earliestCreated')}\n            </SelectItem>\n            <SelectItem\n              value=\"updated_at,DESC\"\n              className=\"text-gray-900 dark:text-gray-100\"\n            >\n              {t('pipelines.recentlyEdited')}\n            </SelectItem>\n            <SelectItem\n              value=\"updated_at,ASC\"\n              className=\"text-gray-900 dark:text-gray-100\"\n            >\n              {t('pipelines.earliestEdited')}\n            </SelectItem>\n          </SelectContent>\n        </Select>\n      </div>\n      <div className={styles.pipelineListContainer}>\n        <CreateCardComponent\n          width={'100%'}\n          height={'10rem'}\n          plusSize={'90px'}\n          onClick={handleCreateNew}\n        />\n\n        {pipelineList.map((pipeline) => {\n          return (\n            <div\n              key={pipeline.id}\n              onClick={() => handlePipelineClick(pipeline.id)}\n            >\n              <PipelineCard cardVO={pipeline} />\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/pipelines/pipelineConfig.module.css",
    "content": ".configPageContainer {\n  width: 100%;\n  height: 100%;\n}\n\n.pipelineListContainer {\n  width: 100%;\n  padding-left: 0.8rem;\n  padding-right: 0.8rem;\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));\n  gap: 2rem;\n  justify-items: stretch;\n  align-items: start;\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-installed/PluginCardVO.ts",
    "content": "import { PluginComponent } from '@/app/infra/entities/plugin';\n\nexport interface IPluginCardVO {\n  author: string;\n  label: string;\n  name: string;\n  description: string;\n  version: string;\n  enabled: boolean;\n  priority: number;\n  install_source: string;\n  install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any\n  status: string;\n  components: PluginComponent[];\n  debug: boolean;\n  hasUpdate?: boolean;\n}\n\nexport class PluginCardVO implements IPluginCardVO {\n  author: string;\n  label: string;\n  name: string;\n  description: string;\n  version: string;\n  enabled: boolean;\n  priority: number;\n  debug: boolean;\n  install_source: string;\n  install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any\n  status: string;\n  components: PluginComponent[];\n  hasUpdate?: boolean;\n\n  constructor(prop: IPluginCardVO) {\n    this.author = prop.author;\n    this.label = prop.label;\n    this.description = prop.description;\n    this.enabled = prop.enabled;\n    this.components = prop.components;\n    this.name = prop.name;\n    this.priority = prop.priority;\n    this.status = prop.status;\n    this.version = prop.version;\n    this.debug = prop.debug;\n    this.install_source = prop.install_source;\n    this.install_info = prop.install_info;\n    this.hasUpdate = prop.hasUpdate;\n  }\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx",
    "content": "import { TFunction } from 'i18next';\nimport { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\n\nexport default function PluginComponentList({\n  components,\n  showComponentName,\n  showTitle,\n  useBadge,\n  t,\n  responsive = false,\n}: {\n  components: Record<string, number>;\n  showComponentName: boolean;\n  showTitle: boolean;\n  useBadge: boolean;\n  t: TFunction;\n  responsive?: boolean;\n}) {\n  const kindIconMap: Record<string, React.ReactNode> = {\n    Tool: <Wrench className=\"w-5 h-5\" />,\n    EventListener: <AudioWaveform className=\"w-5 h-5\" />,\n    Command: <Hash className=\"w-5 h-5\" />,\n    KnowledgeEngine: <Book className=\"w-5 h-5\" />,\n    Parser: <FileText className=\"w-5 h-5\" />,\n  };\n\n  const componentKindList = Object.keys(components || {});\n\n  return (\n    <>\n      {showTitle && <div>{t('plugins.componentsList')}</div>}\n      {componentKindList.length > 0 && (\n        <>\n          {componentKindList.map((kind) => {\n            return useBadge ? (\n              <Badge\n                key={kind}\n                variant=\"outline\"\n                className=\"flex items-center gap-1\"\n              >\n                {kindIconMap[kind]}\n                {/* 响应式显示组件名称：在中等屏幕以上显示 */}\n                {responsive ? (\n                  <span className=\"hidden md:inline\">\n                    {t('plugins.componentName.' + kind)}\n                  </span>\n                ) : (\n                  showComponentName && t('plugins.componentName.' + kind)\n                )}\n                <span className=\"ml-1\">{components[kind]}</span>\n              </Badge>\n            ) : (\n              <div\n                key={kind}\n                className=\"flex flex-row items-center justify-start gap-[0.2rem]\"\n              >\n                {kindIconMap[kind]}\n                {/* 响应式显示组件名称：在中等屏幕以上显示 */}\n                {responsive ? (\n                  <span className=\"hidden md:inline\">\n                    {t('plugins.componentName.' + kind)}\n                  </span>\n                ) : (\n                  showComponentName && t('plugins.componentName.' + kind)\n                )}\n                <span className=\"ml-1\">{components[kind]}</span>\n              </div>\n            );\n          })}\n        </>\n      )}\n\n      {componentKindList.length === 0 && <div>{t('plugins.noComponents')}</div>}\n    </>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx",
    "content": "'use client';\n\nimport { useState, useEffect, forwardRef, useImperativeHandle } from 'react';\nimport { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';\nimport PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';\nimport PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';\nimport PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';\nimport styles from '@/app/home/plugins/plugins.module.css';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { getCloudServiceClientSync } from '@/app/infra/http';\nimport { isNewerVersion } from '@/app/utils/versionCompare';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { useTranslation } from 'react-i18next';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\nimport { toast } from 'sonner';\nimport { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';\n\nexport interface PluginInstalledComponentRef {\n  refreshPluginList: () => void;\n}\n\nenum PluginOperationType {\n  DELETE = 'DELETE',\n  UPDATE = 'UPDATE',\n}\n\n// eslint-disable-next-line react/display-name\nconst PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(\n  (props, ref) => {\n    const { t } = useTranslation();\n    const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);\n    const [modalOpen, setModalOpen] = useState<boolean>(false);\n    const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(\n      null,\n    );\n    const [readmeModalOpen, setReadmeModalOpen] = useState<boolean>(false);\n    const [readmePlugin, setReadmePlugin] = useState<PluginCardVO | null>(null);\n    const [showOperationModal, setShowOperationModal] = useState(false);\n    const [operationType, setOperationType] = useState<PluginOperationType>(\n      PluginOperationType.DELETE,\n    );\n    const [targetPlugin, setTargetPlugin] = useState<PluginCardVO | null>(null);\n    const [deleteData, setDeleteData] = useState<boolean>(false);\n\n    const asyncTask = useAsyncTask({\n      onSuccess: () => {\n        const successMessage =\n          operationType === PluginOperationType.DELETE\n            ? t('plugins.deleteSuccess')\n            : t('plugins.updateSuccess');\n        toast.success(successMessage);\n        setShowOperationModal(false);\n        getPluginList();\n      },\n      onError: () => {\n        // Error is already handled in the hook state\n      },\n    });\n\n    useEffect(() => {\n      initData();\n    }, []);\n\n    function initData() {\n      getPluginList();\n    }\n\n    async function getPluginList() {\n      try {\n        // 获取已安装插件列表\n        const installedPluginsResp = await httpClient.getPlugins();\n        const installedPlugins = installedPluginsResp.plugins;\n\n        // 获取市场插件列表\n        const client = getCloudServiceClientSync();\n        const marketplaceResp = await client.getMarketplacePlugins(1, 100);\n        const marketplacePlugins = marketplaceResp.plugins;\n\n        // 创建市场插件映射，便于快速查找\n        const marketplacePluginMap = new Map();\n        marketplacePlugins.forEach((plugin) => {\n          const key = `${plugin.author}/${plugin.name}`;\n          marketplacePluginMap.set(key, plugin);\n        });\n\n        // 转换并比较版本号\n        const pluginCards = installedPlugins.map((plugin) => {\n          const cardVO = new PluginCardVO({\n            author: plugin.manifest.manifest.metadata.author ?? '',\n            label: extractI18nObject(plugin.manifest.manifest.metadata.label),\n            description: extractI18nObject(\n              plugin.manifest.manifest.metadata.description ?? {\n                en_US: '',\n                zh_Hans: '',\n              },\n            ),\n            debug: plugin.debug,\n            enabled: plugin.enabled,\n            name: plugin.manifest.manifest.metadata.name,\n            version: plugin.manifest.manifest.metadata.version ?? '',\n            status: plugin.status,\n            components: plugin.components,\n            priority: plugin.priority,\n            install_source: plugin.install_source,\n            install_info: plugin.install_info,\n          });\n\n          // 检查是否来自市场且有更新\n          if (cardVO.install_source === 'marketplace') {\n            const marketplaceKey = `${cardVO.author}/${cardVO.name}`;\n            const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);\n            if (marketplacePlugin && marketplacePlugin.latest_version) {\n              cardVO.hasUpdate = isNewerVersion(\n                marketplacePlugin.latest_version,\n                cardVO.version,\n              );\n            }\n          }\n\n          return cardVO;\n        });\n\n        setPluginList(pluginCards);\n      } catch (error) {\n        console.error('获取插件列表失败:', error);\n        // 失败时仍显示已安装插件，不影响用户体验\n        const installedPluginsResp = await httpClient.getPlugins();\n        setPluginList(\n          installedPluginsResp.plugins.map((plugin) => {\n            return new PluginCardVO({\n              author: plugin.manifest.manifest.metadata.author ?? '',\n              label: extractI18nObject(plugin.manifest.manifest.metadata.label),\n              description: extractI18nObject(\n                plugin.manifest.manifest.metadata.description ?? {\n                  en_US: '',\n                  zh_Hans: '',\n                },\n              ),\n              debug: plugin.debug,\n              enabled: plugin.enabled,\n              name: plugin.manifest.manifest.metadata.name,\n              version: plugin.manifest.manifest.metadata.version ?? '',\n              status: plugin.status,\n              components: plugin.components,\n              priority: plugin.priority,\n              install_source: plugin.install_source,\n              install_info: plugin.install_info,\n            });\n          }),\n        );\n      }\n    }\n\n    useImperativeHandle(ref, () => ({\n      refreshPluginList: getPluginList,\n    }));\n\n    function handlePluginClick(plugin: PluginCardVO) {\n      setSelectedPlugin(plugin);\n      setModalOpen(true);\n    }\n\n    function handleViewReadme(plugin: PluginCardVO) {\n      setReadmePlugin(plugin);\n      setReadmeModalOpen(true);\n    }\n\n    function handlePluginDelete(plugin: PluginCardVO) {\n      setTargetPlugin(plugin);\n      setOperationType(PluginOperationType.DELETE);\n      setShowOperationModal(true);\n      setDeleteData(false);\n      asyncTask.reset();\n    }\n\n    function handlePluginUpdate(plugin: PluginCardVO) {\n      setTargetPlugin(plugin);\n      setOperationType(PluginOperationType.UPDATE);\n      setShowOperationModal(true);\n      asyncTask.reset();\n    }\n\n    function executeOperation() {\n      if (!targetPlugin) return;\n\n      const apiCall =\n        operationType === PluginOperationType.DELETE\n          ? httpClient.removePlugin(\n              targetPlugin.author,\n              targetPlugin.name,\n              deleteData,\n            )\n          : httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);\n\n      apiCall\n        .then((res) => {\n          asyncTask.startTask(res.task_id);\n        })\n        .catch((error) => {\n          const errorMessage =\n            operationType === PluginOperationType.DELETE\n              ? t('plugins.deleteError') + error.message\n              : t('plugins.updateError') + error.message;\n          toast.error(errorMessage);\n        });\n    }\n\n    return (\n      <>\n        <Dialog\n          open={showOperationModal}\n          onOpenChange={(open) => {\n            if (!open) {\n              setShowOperationModal(false);\n              setTargetPlugin(null);\n              asyncTask.reset();\n            }\n          }}\n        >\n          <DialogContent>\n            <DialogHeader>\n              <DialogTitle>\n                {operationType === PluginOperationType.DELETE\n                  ? t('plugins.deleteConfirm')\n                  : t('plugins.updateConfirm')}\n              </DialogTitle>\n            </DialogHeader>\n            <DialogDescription>\n              {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (\n                <div className=\"flex flex-col gap-4\">\n                  <div>\n                    {operationType === PluginOperationType.DELETE\n                      ? t('plugins.confirmDeletePlugin', {\n                          author: targetPlugin?.author ?? '',\n                          name: targetPlugin?.name ?? '',\n                        })\n                      : t('plugins.confirmUpdatePlugin', {\n                          author: targetPlugin?.author ?? '',\n                          name: targetPlugin?.name ?? '',\n                        })}\n                  </div>\n                  {operationType === PluginOperationType.DELETE && (\n                    <div className=\"flex items-center space-x-2\">\n                      <Checkbox\n                        id=\"delete-data\"\n                        checked={deleteData}\n                        onCheckedChange={(checked) =>\n                          setDeleteData(checked === true)\n                        }\n                      />\n                      <label\n                        htmlFor=\"delete-data\"\n                        className=\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer\"\n                      >\n                        {t('plugins.deleteDataCheckbox')}\n                      </label>\n                    </div>\n                  )}\n                </div>\n              )}\n              {asyncTask.status === AsyncTaskStatus.RUNNING && (\n                <div>\n                  {operationType === PluginOperationType.DELETE\n                    ? t('plugins.deleting')\n                    : t('plugins.updating')}\n                </div>\n              )}\n              {asyncTask.status === AsyncTaskStatus.ERROR && (\n                <div>\n                  {operationType === PluginOperationType.DELETE\n                    ? t('plugins.deleteError')\n                    : t('plugins.updateError')}\n                  <div className=\"text-red-500\">{asyncTask.error}</div>\n                </div>\n              )}\n            </DialogDescription>\n            <DialogFooter>\n              {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (\n                <Button\n                  variant=\"outline\"\n                  onClick={() => {\n                    setShowOperationModal(false);\n                    setTargetPlugin(null);\n                    asyncTask.reset();\n                  }}\n                >\n                  {t('plugins.cancel')}\n                </Button>\n              )}\n              {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (\n                <Button\n                  variant={\n                    operationType === PluginOperationType.DELETE\n                      ? 'destructive'\n                      : 'default'\n                  }\n                  onClick={() => {\n                    executeOperation();\n                  }}\n                >\n                  {operationType === PluginOperationType.DELETE\n                    ? t('plugins.confirmDelete')\n                    : t('plugins.confirmUpdate')}\n                </Button>\n              )}\n              {asyncTask.status === AsyncTaskStatus.RUNNING && (\n                <Button\n                  variant={\n                    operationType === PluginOperationType.DELETE\n                      ? 'destructive'\n                      : 'default'\n                  }\n                  disabled\n                >\n                  {operationType === PluginOperationType.DELETE\n                    ? t('plugins.deleting')\n                    : t('plugins.updating')}\n                </Button>\n              )}\n              {asyncTask.status === AsyncTaskStatus.ERROR && (\n                <Button\n                  variant=\"default\"\n                  onClick={() => {\n                    setShowOperationModal(false);\n                    asyncTask.reset();\n                  }}\n                >\n                  {t('plugins.close')}\n                </Button>\n              )}\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n\n        {pluginList.length === 0 ? (\n          <div className=\"flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2\">\n            <svg\n              className=\"h-[3rem] w-[3rem]\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H20C20.5523 5 21 5.44772 21 6V10.1707C21 10.4953 20.8424 10.7997 20.5774 10.9872C20.3123 11.1746 19.9728 11.2217 19.6668 11.1135C19.4595 11.0403 19.2355 11 19 11C17.8954 11 17 11.8954 17 13C17 14.1046 17.8954 15 19 15C19.2355 15 19.4595 14.9597 19.6668 14.8865C19.9728 14.7783 20.3123 14.8254 20.5774 15.0128C20.8424 15.2003 21 15.5047 21 15.8293V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H19V17C16.7909 17 15 15.2091 15 13C15 10.7909 16.7909 9 19 9V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z\"></path>\n            </svg>\n            <div className=\"text-lg mb-2\">{t('plugins.noPluginInstalled')}</div>\n          </div>\n        ) : (\n          <div className={`${styles.pluginListContainer}`}>\n            <Dialog open={modalOpen} onOpenChange={setModalOpen}>\n              <DialogContent className=\"w-[700px] max-h-[80vh] p-0 flex flex-col\">\n                <DialogHeader className=\"px-6 pt-6 pb-2\">\n                  <DialogTitle>{t('plugins.pluginConfig')}</DialogTitle>\n                </DialogHeader>\n                <div className=\"flex-1 overflow-y-auto px-6\">\n                  {selectedPlugin && (\n                    <PluginForm\n                      pluginAuthor={selectedPlugin.author}\n                      pluginName={selectedPlugin.name}\n                      onFormSubmit={(timeout?: number) => {\n                        setModalOpen(false);\n                        if (timeout) {\n                          setTimeout(() => {\n                            getPluginList();\n                          }, timeout);\n                        } else {\n                          getPluginList();\n                        }\n                      }}\n                      onFormCancel={() => {\n                        setModalOpen(false);\n                      }}\n                    />\n                  )}\n                </div>\n              </DialogContent>\n            </Dialog>\n\n            <Dialog open={readmeModalOpen} onOpenChange={setReadmeModalOpen}>\n              <DialogContent className=\"sm:max-w-[900px] max-w-[90vw] max-h-[85vh] p-0 flex flex-col\">\n                <DialogHeader className=\"px-6 pt-6 pb-2 border-b\">\n                  <DialogTitle>\n                    {readmePlugin &&\n                      `${readmePlugin.author}/${readmePlugin.name} - ${t(\n                        'plugins.readme',\n                      )}`}\n                  </DialogTitle>\n                </DialogHeader>\n                <div className=\"flex-1 overflow-y-auto\">\n                  {readmePlugin && (\n                    <PluginReadme\n                      pluginAuthor={readmePlugin.author}\n                      pluginName={readmePlugin.name}\n                    />\n                  )}\n                </div>\n              </DialogContent>\n            </Dialog>\n\n            {pluginList.map((vo, index) => {\n              return (\n                <div key={index}>\n                  <PluginCardComponent\n                    cardVO={vo}\n                    onCardClick={() => handlePluginClick(vo)}\n                    onDeleteClick={() => handlePluginDelete(vo)}\n                    onUpgradeClick={() => handlePluginUpdate(vo)}\n                    onViewReadme={() => handleViewReadme(vo)}\n                  />\n                </div>\n              );\n            })}\n          </div>\n        )}\n      </>\n    );\n  },\n);\n\nexport default PluginInstalledComponent;\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx",
    "content": "import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';\nimport { useState } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { useTranslation } from 'react-i18next';\nimport {\n  BugIcon,\n  ExternalLink,\n  Ellipsis,\n  Trash,\n  ArrowUp,\n  Settings,\n  FileText,\n} from 'lucide-react';\nimport { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { Button } from '@/components/ui/button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';\n\nexport default function PluginCardComponent({\n  cardVO,\n  onCardClick,\n  onDeleteClick,\n  onUpgradeClick,\n  onViewReadme,\n}: {\n  cardVO: PluginCardVO;\n  onCardClick: () => void;\n  onDeleteClick: (cardVO: PluginCardVO) => void;\n  onUpgradeClick: (cardVO: PluginCardVO) => void;\n  onViewReadme: (cardVO: PluginCardVO) => void;\n}) {\n  const { t } = useTranslation();\n  const [dropdownOpen, setDropdownOpen] = useState(false);\n  const [isHovered, setIsHovered] = useState(false);\n\n  return (\n    <>\n      <div\n        className=\"w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]\"\n        onMouseEnter={() => setIsHovered(true)}\n        onMouseLeave={() => {\n          if (!dropdownOpen) {\n            setIsHovered(false);\n          }\n        }}\n      >\n        <div className=\"w-full h-full flex flex-row items-start justify-start gap-[1.2rem]\">\n          {/* Icon - fixed width */}\n          <img\n            src={httpClient.getPluginIconURL(cardVO.author, cardVO.name)}\n            alt=\"plugin icon\"\n            className=\"w-16 h-16 rounded-[8%] flex-shrink-0\"\n          />\n\n          {/* Content area - flexible width with min-width to prevent overflow */}\n          <div className=\"flex-1 min-w-0 h-full flex flex-col items-start justify-between gap-[0.6rem]\">\n            {/* Top content area - allows overflow with max height */}\n            <div className=\"flex flex-col items-start justify-start w-full min-w-0 flex-1 overflow-hidden\">\n              <div className=\"flex flex-col items-start justify-start w-full min-w-0\">\n                <div className=\"text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full\">\n                  {cardVO.author} / {cardVO.name}\n                </div>\n                <div className=\"flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full\">\n                  <div className=\"text-[1.2rem] text-black dark:text-[#f0f0f0] truncate max-w-[10rem]\">\n                    {cardVO.label}\n                  </div>\n                  <Badge\n                    variant=\"outline\"\n                    className=\"text-[0.7rem] flex-shrink-0\"\n                  >\n                    v{cardVO.version}\n                  </Badge>\n                  {cardVO.debug && (\n                    <Badge\n                      variant=\"outline\"\n                      className=\"text-[0.7rem] border-orange-400 text-orange-400 flex-shrink-0\"\n                    >\n                      <BugIcon className=\"w-4 h-4\" />\n                      {t('plugins.debugging')}\n                    </Badge>\n                  )}\n                  {!cardVO.debug && (\n                    <>\n                      {cardVO.install_source === 'github' && (\n                        <Badge\n                          variant=\"outline\"\n                          className=\"text-[0.7rem] border-blue-400 text-blue-400 flex-shrink-0\"\n                        >\n                          {t('plugins.fromGithub')}\n                        </Badge>\n                      )}\n                      {cardVO.install_source === 'local' && (\n                        <Badge\n                          variant=\"outline\"\n                          className=\"text-[0.7rem] border-green-400 text-green-400 flex-shrink-0\"\n                        >\n                          {t('plugins.fromLocal')}\n                        </Badge>\n                      )}\n                      {cardVO.install_source === 'marketplace' && (\n                        <Badge\n                          variant=\"outline\"\n                          className=\"text-[0.7rem] border-purple-400 text-purple-400 flex-shrink-0\"\n                        >\n                          {t('plugins.fromMarketplace')}\n                        </Badge>\n                      )}\n                    </>\n                  )}\n                </div>\n              </div>\n\n              <div className=\"text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999] w-full\">\n                {cardVO.description}\n              </div>\n            </div>\n\n            {/* Components list - fixed at bottom */}\n            <div className=\"w-full flex flex-row items-start justify-start gap-[0.6rem] flex-shrink-0 min-h-[1.5rem]\">\n              <PluginComponentList\n                components={(() => {\n                  const componentKindCount: Record<string, number> = {};\n                  for (const component of cardVO.components) {\n                    const kind = component.manifest.manifest.kind;\n                    if (componentKindCount[kind]) {\n                      componentKindCount[kind]++;\n                    } else {\n                      componentKindCount[kind] = 1;\n                    }\n                  }\n                  return componentKindCount;\n                })()}\n                showComponentName={false}\n                showTitle={true}\n                useBadge={false}\n                t={t}\n              />\n            </div>\n          </div>\n\n          {/* Menu button - fixed width and position */}\n          <div className=\"flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0\">\n            <div className=\"flex items-center justify-center\"></div>\n\n            <div className=\"flex items-center justify-center\">\n              <DropdownMenu\n                open={dropdownOpen}\n                onOpenChange={(open) => {\n                  setDropdownOpen(open);\n                  if (!open) {\n                    setIsHovered(false);\n                  }\n                }}\n              >\n                <DropdownMenuTrigger asChild>\n                  <div className=\"relative\">\n                    <Button\n                      variant=\"ghost\"\n                      className=\"bg-white dark:bg-[#1f1f22] hover:bg-gray-100 dark:hover:bg-[#2a2a2d]\"\n                    >\n                      <Ellipsis className=\"w-4 h-4\" />\n                    </Button>\n                    {cardVO.hasUpdate && (\n                      <div className=\"absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-white dark:border-[#1f1f22]\"></div>\n                    )}\n                  </div>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent>\n                  {/**upgrade */}\n                  {cardVO.install_source === 'marketplace' && (\n                    <DropdownMenuItem\n                      className=\"flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer\"\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        onUpgradeClick(cardVO);\n                        setDropdownOpen(false);\n                      }}\n                    >\n                      <ArrowUp className=\"w-4 h-4\" />\n                      <span>{t('plugins.update')}</span>\n                      {cardVO.hasUpdate && (\n                        <Badge className=\"ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4\">\n                          {t('plugins.new')}\n                        </Badge>\n                      )}\n                    </DropdownMenuItem>\n                  )}\n                  {/**view source */}\n                  {(cardVO.install_source === 'github' ||\n                    cardVO.install_source === 'marketplace') && (\n                    <DropdownMenuItem\n                      className=\"flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer\"\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        if (cardVO.install_source === 'github') {\n                          window.open(cardVO.install_info.github_url, '_blank');\n                        } else if (cardVO.install_source === 'marketplace') {\n                          window.open(\n                            getCloudServiceClientSync().getPluginMarketplaceURL(\n                              systemInfo.cloud_service_url,\n                              cardVO.author,\n                              cardVO.name,\n                            ),\n                            '_blank',\n                          );\n                        }\n                        setDropdownOpen(false);\n                      }}\n                    >\n                      <ExternalLink className=\"w-4 h-4\" />\n                      <span>{t('plugins.viewSource')}</span>\n                    </DropdownMenuItem>\n                  )}\n                  <DropdownMenuItem\n                    className=\"flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer text-red-600 focus:text-red-600\"\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      onDeleteClick(cardVO);\n                      setDropdownOpen(false);\n                    }}\n                  >\n                    <Trash className=\"w-4 h-4\" />\n                    <span>{t('plugins.delete')}</span>\n                  </DropdownMenuItem>\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </div>\n          </div>\n        </div>\n\n        {/* Hover overlay with action buttons */}\n        <div\n          className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 z-10 ${\n            isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'\n          }`}\n        >\n          <Button\n            onClick={(e) => {\n              e.stopPropagation();\n              onViewReadme(cardVO);\n            }}\n            className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${\n              isHovered\n                ? 'translate-y-0 opacity-100'\n                : 'translate-y-1 opacity-0'\n            }`}\n            style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}\n          >\n            <FileText className=\"w-4 h-4\" />\n            {t('plugins.readme')}\n          </Button>\n          <Button\n            onClick={(e) => {\n              e.stopPropagation();\n              onCardClick();\n            }}\n            variant=\"outline\"\n            className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${\n              isHovered\n                ? 'translate-y-0 opacity-100'\n                : 'translate-y-1 opacity-0'\n            }`}\n            style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}\n          >\n            <Settings className=\"w-4 h-4\" />\n            {t('plugins.config')}\n          </Button>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx",
    "content": "import { useState, useEffect, useRef } from 'react';\nimport { ApiRespPluginConfig } from '@/app/infra/entities/api';\nimport { Plugin } from '@/app/infra/entities/plugin';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';\nimport { Button } from '@/components/ui/button';\nimport { toast } from 'sonner';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\nimport { useTranslation } from 'react-i18next';\nimport PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';\n\nexport default function PluginForm({\n  pluginAuthor,\n  pluginName,\n  onFormSubmit,\n  onFormCancel,\n}: {\n  pluginAuthor: string;\n  pluginName: string;\n  onFormSubmit: (timeout?: number) => void;\n  onFormCancel: () => void;\n}) {\n  const { t } = useTranslation();\n  const [pluginInfo, setPluginInfo] = useState<Plugin>();\n  const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();\n  const [isSaving, setIsLoading] = useState(false);\n  const currentFormValues = useRef<object>({});\n  const uploadedFileKeys = useRef<Set<string>>(new Set());\n  const initialFileKeys = useRef<Set<string>>(new Set());\n\n  useEffect(() => {\n    // 获取插件信息\n    httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {\n      setPluginInfo(res.plugin);\n    });\n    // 获取插件配置\n    httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {\n      setPluginConfig(res);\n\n      // 提取初始配置中的所有文件 key\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const extractFileKeys = (obj: any): string[] => {\n        const keys: string[] = [];\n        if (obj && typeof obj === 'object') {\n          if ('file_key' in obj && typeof obj.file_key === 'string') {\n            keys.push(obj.file_key);\n          }\n          for (const value of Object.values(obj)) {\n            if (Array.isArray(value)) {\n              value.forEach((item) => keys.push(...extractFileKeys(item)));\n            } else if (typeof value === 'object' && value !== null) {\n              keys.push(...extractFileKeys(value));\n            }\n          }\n        }\n        return keys;\n      };\n\n      const fileKeys = extractFileKeys(res.config);\n      initialFileKeys.current = new Set(fileKeys);\n    });\n  }, [pluginAuthor, pluginName]);\n\n  const handleSubmit = async () => {\n    setIsLoading(true);\n\n    try {\n      // 保存配置\n      await httpClient.updatePluginConfig(\n        pluginAuthor,\n        pluginName,\n        currentFormValues.current,\n      );\n\n      // 提取最终保存的配置中的所有文件 key\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const extractFileKeys = (obj: any): string[] => {\n        const keys: string[] = [];\n        if (obj && typeof obj === 'object') {\n          if ('file_key' in obj && typeof obj.file_key === 'string') {\n            keys.push(obj.file_key);\n          }\n          for (const value of Object.values(obj)) {\n            if (Array.isArray(value)) {\n              value.forEach((item) => keys.push(...extractFileKeys(item)));\n            } else if (typeof value === 'object' && value !== null) {\n              keys.push(...extractFileKeys(value));\n            }\n          }\n        }\n        return keys;\n      };\n\n      const finalFileKeys = new Set(extractFileKeys(currentFormValues.current));\n\n      // 计算需要删除的文件：\n      // 1. 在编辑期间上传的，但最终未保存的文件\n      // 2. 初始配置中有的，但最终配置中没有的文件（被删除的文件）\n      const filesToDelete: string[] = [];\n\n      // 上传了但未使用的文件\n      uploadedFileKeys.current.forEach((key) => {\n        if (!finalFileKeys.has(key)) {\n          filesToDelete.push(key);\n        }\n      });\n\n      // 初始有但最终没有的文件（被删除的）\n      initialFileKeys.current.forEach((key) => {\n        if (!finalFileKeys.has(key)) {\n          filesToDelete.push(key);\n        }\n      });\n\n      // 删除不需要的文件\n      const deletePromises = filesToDelete.map((fileKey) =>\n        httpClient.deletePluginConfigFile(fileKey).catch((err) => {\n          console.warn(`Failed to delete file ${fileKey}:`, err);\n        }),\n      );\n\n      await Promise.all(deletePromises);\n\n      toast.success(t('plugins.saveConfigSuccessNormal'));\n      onFormSubmit(1000);\n    } catch (error) {\n      toast.error(t('plugins.saveConfigError') + (error as Error).message);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  if (!pluginInfo || !pluginConfig) {\n    return (\n      <div className=\"flex items-center justify-center h-full mb-[2rem]\">\n        {t('plugins.loading')}\n      </div>\n    );\n  }\n\n  return (\n    <div>\n      <div className=\"space-y-2\">\n        <div className=\"text-lg font-medium\">\n          {extractI18nObject(pluginInfo.manifest.manifest.metadata.label)}\n        </div>\n        <div className=\"text-sm text-gray-500 pb-2\">\n          {extractI18nObject(\n            pluginInfo.manifest.manifest.metadata.description ?? {\n              en_US: '',\n              zh_Hans: '',\n            },\n          )}\n        </div>\n\n        <div className=\"mb-4 flex flex-row items-center justify-start gap-[0.4rem]\">\n          <PluginComponentList\n            components={(() => {\n              const componentKindCount: Record<string, number> = {};\n              for (const component of pluginInfo.components) {\n                const kind = component.manifest.manifest.kind;\n                if (componentKindCount[kind]) {\n                  componentKindCount[kind]++;\n                } else {\n                  componentKindCount[kind] = 1;\n                }\n              }\n              return componentKindCount;\n            })()}\n            showComponentName={true}\n            showTitle={false}\n            useBadge={true}\n            t={t}\n          />\n        </div>\n\n        {pluginInfo.manifest.manifest.spec.config.length > 0 && (\n          <DynamicFormComponent\n            itemConfigList={pluginInfo.manifest.manifest.spec.config}\n            initialValues={pluginConfig.config as Record<string, object>}\n            onSubmit={(values) => {\n              // 只保存表单值的引用,不触发状态更新\n              currentFormValues.current = values;\n            }}\n            onFileUploaded={(fileKey) => {\n              // 追踪上传的文件\n              uploadedFileKeys.current.add(fileKey);\n            }}\n          />\n        )}\n        {pluginInfo.manifest.manifest.spec.config.length === 0 && (\n          <div className=\"text-sm text-gray-500\">\n            {t('plugins.pluginNoConfig')}\n          </div>\n        )}\n      </div>\n\n      <div className=\"sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4\">\n        <div className=\"flex justify-end gap-2\">\n          <Button\n            type=\"submit\"\n            onClick={() => handleSubmit()}\n            disabled={isSaving}\n          >\n            {isSaving ? t('plugins.saving') : t('plugins.saveConfig')}\n          </Button>\n          <Button type=\"button\" variant=\"outline\" onClick={onFormCancel}>\n            {t('plugins.cancel')}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { useTranslation } from 'react-i18next';\nimport ReactMarkdown from 'react-markdown';\nimport remarkGfm from 'remark-gfm';\nimport rehypeRaw from 'rehype-raw';\nimport rehypeSanitize from 'rehype-sanitize';\nimport rehypeHighlight from 'rehype-highlight';\nimport rehypeSlug from 'rehype-slug';\nimport rehypeAutolinkHeadings from 'rehype-autolink-headings';\nimport { getAPILanguageCode } from '@/i18n/I18nProvider';\nimport '@/styles/github-markdown.css';\n\nexport default function PluginReadme({\n  pluginAuthor,\n  pluginName,\n}: {\n  pluginAuthor: string;\n  pluginName: string;\n}) {\n  const { t } = useTranslation();\n  const [readme, setReadme] = useState<string>('');\n  const [isLoadingReadme, setIsLoadingReadme] = useState(false);\n\n  const language = getAPILanguageCode();\n\n  useEffect(() => {\n    // Fetch plugin README\n    setIsLoadingReadme(true);\n    httpClient\n      .getPluginReadme(pluginAuthor, pluginName, language)\n      .then((res) => {\n        setReadme(res.readme);\n      })\n      .catch(() => {\n        setReadme('');\n      })\n      .finally(() => {\n        setIsLoadingReadme(false);\n      });\n  }, [pluginAuthor, pluginName]);\n\n  return (\n    <div className=\"w-full h-full overflow-auto\">\n      {isLoadingReadme ? (\n        <div className=\"p-6 text-sm text-gray-500 dark:text-gray-400\">\n          {t('plugins.loadingReadme')}\n        </div>\n      ) : readme ? (\n        <div className=\"markdown-body p-6 max-w-none pt-0\">\n          <ReactMarkdown\n            remarkPlugins={[remarkGfm]}\n            rehypePlugins={[\n              rehypeRaw,\n              rehypeSanitize,\n              rehypeHighlight,\n              rehypeSlug,\n              [\n                rehypeAutolinkHeadings,\n                {\n                  behavior: 'wrap',\n                  properties: {\n                    className: ['anchor'],\n                  },\n                },\n              ],\n            ]}\n            components={{\n              ul: ({ children }) => <ul className=\"list-disc\">{children}</ul>,\n              ol: ({ children }) => (\n                <ol className=\"list-decimal\">{children}</ol>\n              ),\n              li: ({ children }) => <li className=\"ml-4\">{children}</li>,\n              img: ({ src, alt, ...props }) => {\n                let imageSrc = src || '';\n\n                if (typeof imageSrc !== 'string') {\n                  return (\n                    <img\n                      src={src}\n                      alt={alt || ''}\n                      className=\"max-w-full h-auto rounded-lg my-4\"\n                      {...props}\n                    />\n                  );\n                }\n\n                if (\n                  imageSrc &&\n                  !imageSrc.startsWith('http://') &&\n                  !imageSrc.startsWith('https://') &&\n                  !imageSrc.startsWith('data:')\n                ) {\n                  imageSrc = imageSrc.replace(/^(\\.\\/|\\/)+/, '');\n\n                  if (!imageSrc.startsWith('assets/')) {\n                    imageSrc = `assets/${imageSrc}`;\n                  }\n\n                  const assetPath = imageSrc.replace(/^assets\\//, '');\n                  imageSrc = httpClient.getPluginAssetURL(\n                    pluginAuthor,\n                    pluginName,\n                    assetPath,\n                  );\n                }\n\n                return (\n                  <img\n                    src={imageSrc}\n                    alt={alt || ''}\n                    className=\"max-w-lg h-auto my-4\"\n                    {...props}\n                  />\n                );\n              },\n            }}\n          >\n            {readme}\n          </ReactMarkdown>\n        </div>\n      ) : (\n        <div className=\"p-6 text-sm text-gray-500 dark:text-gray-400\">\n          {t('plugins.noReadme')}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx",
    "content": "'use client';\n\nimport { useState, useEffect, useCallback, useRef, Suspense } from 'react';\nimport { Input } from '@/components/ui/input';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport {\n  Search,\n  Wrench,\n  AudioWaveform,\n  Hash,\n  Book,\n  FileText,\n} from 'lucide-react';\nimport PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';\nimport { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';\nimport { getCloudServiceClientSync } from '@/app/infra/http';\nimport { useTranslation } from 'react-i18next';\nimport { PluginV4 } from '@/app/infra/entities/plugin';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\nimport { toast } from 'sonner';\nimport { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';\nimport { LoadingSpinner } from '@/components/ui/loading-spinner';\nimport { TagsFilter } from './TagsFilter';\nimport { PluginTag } from '@/app/infra/http/CloudServiceClient';\n\nimport { RecommendationLists, RecommendationList } from './RecommendationLists';\n\ninterface SortOption {\n  value: string;\n  label: string;\n  sortBy: string;\n  sortOrder: string;\n}\n\n// 内部组件，用于处理搜索参数\nfunction MarketPageContent({\n  installPlugin,\n}: {\n  installPlugin: (plugin: PluginV4) => void;\n}) {\n  const { t } = useTranslation();\n\n  const [searchQuery, setSearchQuery] = useState('');\n  const [componentFilter, setComponentFilter] = useState<string>('all');\n  const [selectedTags, setSelectedTags] = useState<string[]>([]);\n  const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);\n  const [tagNames, setTagNames] = useState<Record<string, string>>({});\n  const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);\n  const [isLoading, setIsLoading] = useState(false);\n  const [isLoadingMore, setIsLoadingMore] = useState(false);\n  const [hasMore, setHasMore] = useState(true);\n  const [currentPage, setCurrentPage] = useState(1);\n  const [total, setTotal] = useState(0);\n  const [sortOption, setSortOption] = useState('install_count_desc');\n  const [recommendationLists, setRecommendationLists] = useState<\n    RecommendationList[]\n  >([]);\n\n  const pageSize = 12; // 每页12个\n  const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n  const scrollContainerRef = useRef<HTMLDivElement | null>(null);\n\n  // 排序选项\n  const sortOptions: SortOption[] = [\n    {\n      value: 'created_at_desc',\n      label: t('market.sort.recentlyAdded'),\n      sortBy: 'created_at',\n      sortOrder: 'DESC',\n    },\n    {\n      value: 'updated_at_desc',\n      label: t('market.sort.recentlyUpdated'),\n      sortBy: 'updated_at',\n      sortOrder: 'DESC',\n    },\n    {\n      value: 'install_count_desc',\n      label: t('market.sort.mostDownloads'),\n      sortBy: 'install_count',\n      sortOrder: 'DESC',\n    },\n    {\n      value: 'install_count_asc',\n      label: t('market.sort.leastDownloads'),\n      sortBy: 'install_count',\n      sortOrder: 'ASC',\n    },\n  ];\n\n  // 获取当前排序参数\n  const getCurrentSort = useCallback(() => {\n    const option = sortOptions.find((opt) => opt.value === sortOption);\n    return option\n      ? { sortBy: option.sortBy, sortOrder: option.sortOrder }\n      : { sortBy: 'install_count', sortOrder: 'DESC' };\n  }, [sortOption]);\n\n  // 将API响应转换为VO对象\n  const transformToVO = useCallback((plugin: PluginV4): PluginMarketCardVO => {\n    return new PluginMarketCardVO({\n      pluginId: plugin.author + ' / ' + plugin.name,\n      author: plugin.author,\n      pluginName: plugin.name,\n      label: extractI18nObject(plugin.label),\n      description:\n        extractI18nObject(plugin.description) || t('market.noDescription'),\n      installCount: plugin.install_count,\n      iconURL: getCloudServiceClientSync().getPluginIconURL(\n        plugin.author,\n        plugin.name,\n      ),\n      githubURL: plugin.repository,\n      version: plugin.latest_version,\n      components: plugin.components,\n      tags: plugin.tags || [],\n    });\n  }, []);\n\n  // 获取插件列表\n  const fetchPlugins = useCallback(\n    async (page: number, isSearch: boolean = false, reset: boolean = false) => {\n      if (page === 1) {\n        setIsLoading(true);\n      } else {\n        setIsLoadingMore(true);\n      }\n\n      try {\n        const { sortBy, sortOrder } = getCurrentSort();\n        const filterValue =\n          componentFilter === 'all' ? undefined : componentFilter;\n\n        // Always use searchMarketplacePlugins to support component filtering and tags filtering\n        const response =\n          await getCloudServiceClientSync().searchMarketplacePlugins(\n            isSearch && searchQuery.trim() ? searchQuery.trim() : '',\n            page,\n            pageSize,\n            sortBy,\n            sortOrder,\n            filterValue,\n            selectedTags.length > 0 ? selectedTags : undefined,\n          );\n\n        const data: ApiRespMarketplacePlugins = response;\n        const newPlugins = data.plugins.map(transformToVO);\n        const total = data.total;\n\n        if (reset || page === 1) {\n          setPlugins(newPlugins);\n        } else {\n          setPlugins((prev) => [...prev, ...newPlugins]);\n        }\n\n        setTotal(total);\n        setHasMore(\n          data.plugins.length === pageSize &&\n            plugins.length + newPlugins.length < total,\n        );\n      } catch (error) {\n        console.error('Failed to fetch plugins:', error);\n        toast.error(t('market.loadFailed'));\n      } finally {\n        setIsLoading(false);\n        setIsLoadingMore(false);\n      }\n    },\n    [\n      searchQuery,\n      componentFilter,\n      selectedTags,\n      pageSize,\n      transformToVO,\n      plugins.length,\n      getCurrentSort,\n    ],\n  );\n\n  // 初始加载\n  useEffect(() => {\n    fetchPlugins(1, false, true);\n    fetchAvailableTags();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // 获取可用标签\n  const fetchAvailableTags = async () => {\n    try {\n      const response = await getCloudServiceClientSync().getAllTags();\n      const tags = response.tags || [];\n      setAvailableTags(tags);\n\n      // Build tag names map for all components to use\n      const nameMap: Record<string, string> = {};\n      tags.forEach((tag: PluginTag) => {\n        const displayName = {\n          en_US: tag.display_name.en_US || tag.tag,\n          zh_Hans: tag.display_name.zh_Hans || tag.tag,\n          zh_Hant: tag.display_name.zh_Hant,\n          ja_JP: tag.display_name.ja_JP,\n        };\n        nameMap[tag.tag] = extractI18nObject(displayName);\n      });\n      setTagNames(nameMap);\n    } catch (error) {\n      console.error('Failed to fetch tags:', error);\n    }\n  };\n\n  // Fetch recommendation lists\n  useEffect(() => {\n    async function fetchRecommendationLists() {\n      try {\n        const response =\n          await getCloudServiceClientSync().getRecommendationLists();\n        setRecommendationLists(response.lists || []);\n      } catch (error) {\n        console.error('Failed to fetch recommendation lists:', error);\n      }\n    }\n    fetchRecommendationLists();\n  }, []);\n\n  // 搜索功能\n  const handleSearch = useCallback(\n    (query: string) => {\n      setSearchQuery(query);\n      setCurrentPage(1);\n      setPlugins([]);\n      fetchPlugins(1, !!query.trim(), true);\n    },\n    [fetchPlugins],\n  );\n\n  // 防抖搜索\n  const handleSearchInputChange = useCallback(\n    (value: string) => {\n      setSearchQuery(value);\n\n      // 清除之前的定时器\n      if (searchTimeoutRef.current) {\n        clearTimeout(searchTimeoutRef.current);\n      }\n\n      // 设置新的定时器\n      searchTimeoutRef.current = setTimeout(() => {\n        handleSearch(value);\n      }, 300);\n    },\n    [handleSearch],\n  );\n\n  // 排序选项变化处理\n  const handleSortChange = useCallback((value: string) => {\n    setSortOption(value);\n    setCurrentPage(1);\n    setPlugins([]);\n    // fetchPlugins will be called by useEffect when sortOption changes\n  }, []);\n\n  // 组件筛选变化处理\n  const handleComponentFilterChange = useCallback((value: string) => {\n    setComponentFilter(value);\n    setCurrentPage(1);\n    setPlugins([]);\n    // fetchPlugins will be called by useEffect when componentFilter changes\n  }, []);\n\n  // 当排序选项或组件筛选变化时重新加载数据\n  useEffect(() => {\n    fetchPlugins(1, !!searchQuery.trim(), true);\n  }, [sortOption, componentFilter]);\n\n  // Tags 筛选变化时重新搜索\n  useEffect(() => {\n    if (!isLoading) {\n      setCurrentPage(1);\n      fetchPlugins(1, searchQuery.trim() !== '', true);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [selectedTags]);\n\n  // 处理 tags 变化\n  const handleTagsChange = useCallback((tags: string[]) => {\n    setSelectedTags(tags);\n  }, []);\n\n  // 处理安装插件\n  const handleInstallPlugin = useCallback(\n    async (author: string, pluginName: string) => {\n      try {\n        // Fetch full plugin details to get PluginV4 object\n        const response = await getCloudServiceClientSync().getPluginDetail(\n          author,\n          pluginName,\n        );\n        const pluginV4: PluginV4 = response.plugin;\n\n        // Call the install function passed from parent\n        installPlugin(pluginV4);\n      } catch (error) {\n        console.error('Failed to install plugin:', error);\n        toast.error(t('market.installFailed'));\n      }\n    },\n    [plugins, installPlugin, t],\n  );\n\n  // 清理定时器\n  useEffect(() => {\n    return () => {\n      if (searchTimeoutRef.current) {\n        clearTimeout(searchTimeoutRef.current);\n      }\n    };\n  }, []);\n\n  const visiblePlugins = plugins;\n\n  // 加载更多\n  const loadMore = useCallback(() => {\n    if (!isLoadingMore && hasMore) {\n      const nextPage = currentPage + 1;\n      setCurrentPage(nextPage);\n      fetchPlugins(nextPage, !!searchQuery.trim());\n    }\n  }, [currentPage, isLoadingMore, hasMore, fetchPlugins, searchQuery]);\n\n  // Check if content fills the viewport and load more if needed\n  const checkAndLoadMore = useCallback(() => {\n    const scrollContainer = scrollContainerRef.current;\n    if (!scrollContainer || isLoading || isLoadingMore || !hasMore) return;\n\n    const { scrollHeight, clientHeight } = scrollContainer;\n    // If content doesn't fill the viewport (no scrollbar), load more\n    if (scrollHeight <= clientHeight) {\n      loadMore();\n    }\n  }, [loadMore, isLoading, isLoadingMore, hasMore]);\n\n  // Listen to scroll events on the scroll container\n  useEffect(() => {\n    const scrollContainer = scrollContainerRef.current;\n    if (!scrollContainer) return;\n\n    const handleScroll = () => {\n      const { scrollTop, scrollHeight, clientHeight } = scrollContainer;\n      // Load more when scrolled to within 100px of the bottom\n      if (scrollTop + clientHeight >= scrollHeight - 100) {\n        loadMore();\n      }\n    };\n\n    scrollContainer.addEventListener('scroll', handleScroll);\n    return () => scrollContainer.removeEventListener('scroll', handleScroll);\n  }, [loadMore]);\n\n  // Check if we need to load more after content changes or initial load\n  useEffect(() => {\n    // Small delay to ensure DOM has updated\n    const timer = setTimeout(() => {\n      checkAndLoadMore();\n    }, 100);\n    return () => clearTimeout(timer);\n  }, [plugins, checkAndLoadMore]);\n\n  // Also check on window resize\n  useEffect(() => {\n    const handleResize = () => {\n      checkAndLoadMore();\n    };\n\n    window.addEventListener('resize', handleResize);\n    return () => window.removeEventListener('resize', handleResize);\n  }, [checkAndLoadMore]);\n\n  // 安装插件\n  // const handleInstallPlugin = (plugin: PluginV4) => {\n  // };\n\n  return (\n    <div className=\"h-full flex flex-col\">\n      {/* Fixed header with search and sort controls */}\n      <div className=\"flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6\">\n        {/* Search box and Tags filter */}\n        <div className=\"flex flex-col sm:flex-row items-center justify-center gap-3\">\n          <div className=\"relative w-full max-w-2xl\">\n            <Search className=\"absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4\" />\n            <Input\n              placeholder={t('market.searchPlaceholder')}\n              value={searchQuery}\n              onChange={(e) => handleSearchInputChange(e.target.value)}\n              onKeyPress={(e) => {\n                if (e.key === 'Enter') {\n                  // Immediately search, clear debounce timer\n                  if (searchTimeoutRef.current) {\n                    clearTimeout(searchTimeoutRef.current);\n                  }\n                  handleSearch(searchQuery);\n                }\n              }}\n              className=\"pl-10 pr-4 text-sm sm:text-base\"\n            />\n          </div>\n\n          {/* Tags filter */}\n          <TagsFilter\n            availableTags={availableTags}\n            selectedTags={selectedTags}\n            onTagsChange={handleTagsChange}\n          />\n        </div>\n\n        {/* Component filter and sort */}\n        <div className=\"flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4\">\n          {/* Component filter */}\n          <div className=\"flex flex-col sm:flex-row items-center gap-2\">\n            <span className=\"text-xs sm:text-sm text-muted-foreground whitespace-nowrap\">\n              {t('market.filterByComponent')}:\n            </span>\n            <ToggleGroup\n              type=\"single\"\n              spacing={2}\n              size=\"sm\"\n              value={componentFilter}\n              onValueChange={(value) => {\n                if (value) handleComponentFilterChange(value);\n              }}\n              className=\"justify-start\"\n            >\n              <ToggleGroupItem\n                value=\"all\"\n                aria-label=\"All components\"\n                className=\"text-xs sm:text-sm cursor-pointer\"\n              >\n                {t('market.allComponents')}\n              </ToggleGroupItem>\n              <ToggleGroupItem\n                value=\"Tool\"\n                aria-label=\"Tool\"\n                className=\"text-xs sm:text-sm cursor-pointer\"\n              >\n                <Wrench className=\"h-4 w-4 mr-1\" />\n                {t('plugins.componentName.Tool')}\n              </ToggleGroupItem>\n              <ToggleGroupItem\n                value=\"Command\"\n                aria-label=\"Command\"\n                className=\"text-xs sm:text-sm cursor-pointer\"\n              >\n                <Hash className=\"h-4 w-4 mr-1\" />\n                {t('plugins.componentName.Command')}\n              </ToggleGroupItem>\n              <ToggleGroupItem\n                value=\"EventListener\"\n                aria-label=\"EventListener\"\n                className=\"text-xs sm:text-sm cursor-pointer\"\n              >\n                <AudioWaveform className=\"h-4 w-4 mr-1\" />\n                {t('plugins.componentName.EventListener')}\n              </ToggleGroupItem>\n              <ToggleGroupItem\n                value=\"KnowledgeEngine\"\n                aria-label=\"KnowledgeEngine\"\n                className=\"text-xs sm:text-sm cursor-pointer\"\n              >\n                <Book className=\"h-4 w-4 mr-1\" />\n                {t('plugins.componentName.KnowledgeEngine')}\n              </ToggleGroupItem>\n              <ToggleGroupItem\n                value=\"Parser\"\n                aria-label=\"Parser\"\n                className=\"text-xs sm:text-sm cursor-pointer\"\n              >\n                <FileText className=\"h-4 w-4 mr-1\" />\n                {t('plugins.componentName.Parser')}\n              </ToggleGroupItem>\n            </ToggleGroup>\n          </div>\n\n          {/* Sort dropdown */}\n          <div className=\"flex items-center gap-2 sm:gap-3\">\n            <span className=\"text-xs sm:text-sm text-muted-foreground whitespace-nowrap\">\n              {t('market.sortBy')}:\n            </span>\n            <Select value={sortOption} onValueChange={handleSortChange}>\n              <SelectTrigger className=\"w-40 sm:w-48 text-xs sm:text-sm\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {sortOptions.map((option) => (\n                  <SelectItem key={option.value} value={option.value}>\n                    {option.label}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          </div>\n        </div>\n\n        {/* Search results stats */}\n        {total > 0 && (\n          <div className=\"text-center text-muted-foreground text-sm\">\n            {searchQuery\n              ? t('market.searchResults', { count: total })\n              : t('market.totalPlugins', { count: total })}\n          </div>\n        )}\n      </div>\n\n      {/* Scrollable content area */}\n      <div\n        ref={scrollContainerRef}\n        className=\"flex-1 overflow-y-auto px-3 sm:px-4\"\n      >\n        {/* Recommendation Lists */}\n        {!searchQuery &&\n          componentFilter === 'all' &&\n          selectedTags.length === 0 && (\n            <div className=\"pt-4\">\n              <RecommendationLists\n                lists={recommendationLists}\n                tagNames={tagNames}\n                onInstall={handleInstallPlugin}\n              />\n            </div>\n          )}\n\n        {isLoading ? (\n          <div className=\"flex items-center justify-center py-12\">\n            <LoadingSpinner text={t('market.loading')} />\n          </div>\n        ) : plugins.length === 0 ? (\n          <div className=\"flex items-center justify-center py-12\">\n            <div className=\"text-muted-foreground\">\n              {searchQuery ? t('market.noResults') : t('market.noPlugins')}\n            </div>\n          </div>\n        ) : (\n          <>\n            <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6 pb-6 pt-4\">\n              {visiblePlugins.map((plugin) => (\n                <PluginMarketCardComponent\n                  key={plugin.pluginId}\n                  cardVO={plugin}\n                  onInstall={handleInstallPlugin}\n                  tagNames={tagNames}\n                />\n              ))}\n            </div>\n\n            {/* Loading more indicator */}\n            {isLoadingMore && (\n              <div className=\"flex items-center justify-center py-6\">\n                <LoadingSpinner size=\"sm\" text={t('market.loadingMore')} />\n              </div>\n            )}\n\n            {/* No more data hint */}\n            {!hasMore && plugins.length > 0 && (\n              <div className=\"text-center text-muted-foreground py-6\">\n                {t('market.allLoaded')}\n                {' · '}\n                <a\n                  href=\"https://github.com/langbot-app/langbot-plugin-demo/issues/new?template=plugin-request.yml\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"text-primary hover:underline\"\n                >\n                  {t('market.requestPlugin')}\n                </a>\n              </div>\n            )}\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n\n// 主组件，包装在 Suspense 中\nexport default function MarketPage({\n  installPlugin,\n}: {\n  installPlugin: (plugin: PluginV4) => void;\n}) {\n  return (\n    <Suspense\n      fallback={\n        <div className=\"container mx-auto px-4 py-6\">\n          <div className=\"flex items-center justify-center py-12\">\n            <LoadingSpinner text=\"加载中...\" />\n          </div>\n        </div>\n      }\n    >\n      <MarketPageContent installPlugin={installPlugin} />\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx",
    "content": "'use client';\n\nimport { useState, useRef, useEffect, useCallback } from 'react';\nimport { ChevronLeft, ChevronRight, Star } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';\nimport { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';\nimport { PluginV4 } from '@/app/infra/entities/plugin';\nimport { I18nObject } from '@/app/infra/entities/common';\nimport { extractI18nObject } from '@/i18n/I18nProvider';\nimport { getCloudServiceClientSync } from '@/app/infra/http';\nimport { useTranslation } from 'react-i18next';\n\nexport interface RecommendationList {\n  uuid: string;\n  label: I18nObject;\n  sort_order: number;\n  plugins: PluginV4[];\n}\n\n// Match the main plugin grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4\n\nfunction pluginToVO(\n  plugin: PluginV4,\n  t: (key: string) => string,\n): PluginMarketCardVO {\n  return new PluginMarketCardVO({\n    pluginId: plugin.author + ' / ' + plugin.name,\n    author: plugin.author,\n    pluginName: plugin.name,\n    label: extractI18nObject(plugin.label),\n    description:\n      extractI18nObject(plugin.description) || t('market.noDescription'),\n    installCount: plugin.install_count,\n    iconURL: getCloudServiceClientSync().getPluginIconURL(\n      plugin.author,\n      plugin.name,\n    ),\n    githubURL: plugin.repository,\n    version: plugin.latest_version,\n    components: plugin.components,\n    tags: plugin.tags || [],\n  });\n}\n\nfunction RecommendationListRow({\n  list,\n  tagNames,\n  onInstall,\n  isLast,\n}: {\n  list: RecommendationList;\n  tagNames: Record<string, string>;\n  onInstall: (author: string, pluginName: string) => void;\n  isLast: boolean;\n}) {\n  const { t } = useTranslation();\n  const [page, setPage] = useState(0);\n  const [perPage, setPerPage] = useState(4);\n  const gridRef = useRef<HTMLDivElement>(null);\n\n  const plugins = list.plugins || [];\n\n  // Measure how many columns the CSS grid actually renders\n  const measureCols = useCallback(() => {\n    if (!gridRef.current) return;\n    const style = window.getComputedStyle(gridRef.current);\n    const cols = style.gridTemplateColumns.split(' ').length;\n    setPerPage(cols);\n  }, []);\n\n  useEffect(() => {\n    measureCols();\n    const observer = new ResizeObserver(measureCols);\n    if (gridRef.current) observer.observe(gridRef.current);\n    return () => observer.disconnect();\n  }, [measureCols]);\n\n  // Auto-advance every 5 seconds\n  useEffect(() => {\n    if (plugins.length <= perPage) return;\n    const timer = setInterval(() => {\n      setPage((p) => {\n        const tp = Math.max(1, Math.ceil(plugins.length / perPage));\n        return p >= tp - 1 ? 0 : p + 1;\n      });\n    }, 5000);\n    return () => clearInterval(timer);\n  }, [plugins.length, perPage]);\n\n  const totalPages = Math.max(1, Math.ceil(plugins.length / perPage));\n  const safePage = Math.min(page, totalPages - 1);\n  if (safePage !== page) setPage(safePage);\n\n  const start = safePage * perPage;\n  const visiblePlugins = plugins.slice(start, start + perPage);\n\n  if (plugins.length === 0) return null;\n\n  return (\n    <div className=\"mb-6\">\n      <div className=\"flex items-center justify-between mb-3\">\n        <div className=\"flex items-center gap-2\">\n          <Star className=\"w-4 h-4 text-yellow-500\" />\n          <h3 className=\"font-semibold text-base\">\n            {extractI18nObject(list.label)}\n          </h3>\n        </div>\n        {totalPages > 1 && (\n          <div className=\"flex items-center gap-1\">\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={() => setPage((p) => Math.max(0, p - 1))}\n              disabled={safePage === 0}\n              className=\"h-7 w-7 p-0\"\n            >\n              <ChevronLeft className=\"w-4 h-4\" />\n            </Button>\n            <span className=\"text-xs text-muted-foreground px-1\">\n              {safePage + 1} / {totalPages}\n            </span>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}\n              disabled={safePage >= totalPages - 1}\n              className=\"h-7 w-7 p-0\"\n            >\n              <ChevronRight className=\"w-4 h-4\" />\n            </Button>\n          </div>\n        )}\n      </div>\n      <div\n        ref={gridRef}\n        className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6\"\n      >\n        {visiblePlugins.map((plugin) => (\n          <PluginMarketCardComponent\n            key={plugin.author + ' / ' + plugin.name}\n            cardVO={pluginToVO(plugin, t)}\n            tagNames={tagNames}\n            onInstall={onInstall}\n          />\n        ))}\n      </div>\n      {totalPages > 1 && !isLast && (\n        <div className=\"border-b border-border mt-6\" />\n      )}\n    </div>\n  );\n}\n\nexport function RecommendationLists({\n  lists,\n  tagNames,\n  onInstall,\n}: {\n  lists: RecommendationList[];\n  tagNames: Record<string, string>;\n  onInstall: (author: string, pluginName: string) => void;\n}) {\n  if (!lists || lists.length === 0) return null;\n\n  return (\n    <div className=\"mt-6\">\n      {lists.map((list, index) => (\n        <RecommendationListRow\n          key={list.uuid}\n          list={list}\n          tagNames={tagNames}\n          onInstall={onInstall}\n          isLast={index === lists.length - 1}\n        />\n      ))}\n      <div className=\"border-b border-border mb-6\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-market/TagsFilter.tsx",
    "content": "'use client';\n\nimport { useTranslation } from 'react-i18next';\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectTrigger,\n} from '@/components/ui/select';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Label } from '@/components/ui/label';\nimport { Tag as TagIcon } from 'lucide-react';\nimport { useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { PluginTag } from '@/app/infra/http/CloudServiceClient';\n\ninterface TagsFilterProps {\n  availableTags: PluginTag[];\n  selectedTags: string[];\n  onTagsChange: (tags: string[]) => void;\n}\n\nexport function TagsFilter({\n  availableTags,\n  selectedTags,\n  onTagsChange,\n}: TagsFilterProps) {\n  const { t, i18n } = useTranslation();\n  const [open, setOpen] = useState(false);\n\n  const handleTagToggle = (tag: string) => {\n    const newTags = selectedTags.includes(tag)\n      ? selectedTags.filter((t) => t !== tag)\n      : [...selectedTags, tag];\n    onTagsChange(newTags);\n  };\n\n  const handleClearAll = () => {\n    onTagsChange([]);\n  };\n\n  const extractI18nObject = (obj: { zh_Hans?: string; en_US?: string }) => {\n    const lang = i18n.language || 'en_US';\n    return obj[lang as keyof typeof obj] || obj.zh_Hans || obj.en_US || '';\n  };\n\n  return (\n    <Select open={open} onOpenChange={setOpen}>\n      <SelectTrigger className=\"w-[140px]\">\n        <div className=\"flex items-center gap-2 w-full\">\n          <TagIcon className=\"h-4 w-4 flex-shrink-0\" />\n          {selectedTags.length === 0 ? (\n            <span className=\"text-muted-foreground truncate text-sm\">\n              {t('market.tags.filterByTags')}\n            </span>\n          ) : (\n            <span className=\"text-sm truncate\">\n              {selectedTags.length} {t('market.tags.selected')}\n            </span>\n          )}\n        </div>\n      </SelectTrigger>\n      <SelectContent className=\"w-[240px]\">\n        <SelectGroup>\n          <div className=\"px-2 py-1.5 flex items-center justify-between border-b\">\n            <span className=\"text-sm font-medium\">\n              {t('market.tags.selectTags')}\n            </span>\n            {selectedTags.length > 0 && (\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={handleClearAll}\n                className=\"h-auto p-0 text-xs hover:bg-transparent hover:text-destructive\"\n              >\n                {t('market.tags.clearAll')}\n              </Button>\n            )}\n          </div>\n\n          {availableTags.length === 0 ? (\n            <div className=\"px-2 py-6 text-center text-sm text-muted-foreground\">\n              {t('market.tags.noTags')}\n            </div>\n          ) : (\n            <div className=\"max-h-[300px] overflow-y-auto\">\n              {availableTags.map((tag) => (\n                <div\n                  key={tag.tag}\n                  className=\"flex items-center space-x-2 px-2 py-2 hover:bg-accent cursor-pointer\"\n                  onClick={(e) => {\n                    e.preventDefault();\n                    handleTagToggle(tag.tag);\n                  }}\n                >\n                  <Checkbox\n                    id={`tag-${tag.tag}`}\n                    checked={selectedTags.includes(tag.tag)}\n                    onClick={(e) => e.stopPropagation()}\n                    onCheckedChange={() => handleTagToggle(tag.tag)}\n                  />\n                  <Label\n                    htmlFor={`tag-${tag.tag}`}\n                    className=\"text-sm font-normal cursor-pointer flex-1\"\n                    onClick={(e) => e.preventDefault()}\n                  >\n                    {extractI18nObject(tag.display_name)}\n                  </Label>\n                </div>\n              ))}\n            </div>\n          )}\n        </SelectGroup>\n      </SelectContent>\n    </Select>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx",
    "content": "import { PluginMarketCardVO } from './PluginMarketCardVO';\nimport { useTranslation } from 'react-i18next';\nimport { Badge } from '@/components/ui/badge';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@/components/ui/tooltip';\nimport {\n  Wrench,\n  AudioWaveform,\n  Hash,\n  Download,\n  ExternalLink,\n  Book,\n  FileText,\n  Info,\n} from 'lucide-react';\nimport { useState, useRef, useEffect } from 'react';\nimport { Button } from '@/components/ui/button';\n\nexport default function PluginMarketCardComponent({\n  cardVO,\n  onInstall,\n  tagNames = {},\n}: {\n  cardVO: PluginMarketCardVO;\n  onInstall?: (author: string, pluginName: string) => void;\n  tagNames?: Record<string, string>;\n}) {\n  const { t } = useTranslation();\n  const [isHovered, setIsHovered] = useState(false);\n  const bottomRef = useRef<HTMLDivElement>(null);\n  const [visibleTags, setVisibleTags] = useState(2);\n\n  // Measure how many tags fit in the bottom row\n  useEffect(() => {\n    const tags = cardVO.tags;\n    if (!bottomRef.current || !tags || tags.length === 0) return;\n\n    const measure = () => {\n      const container = bottomRef.current;\n      if (!container) return;\n      const width = container.offsetWidth;\n      const availableForTags = width - 140 - 80;\n      if (availableForTags <= 0) {\n        setVisibleTags(0);\n        return;\n      }\n      const tagWidth = 80;\n      const plusBadgeWidth = 40;\n      const maxTags = Math.max(\n        0,\n        Math.floor((availableForTags - plusBadgeWidth) / tagWidth),\n      );\n      if (maxTags >= tags.length) {\n        setVisibleTags(tags.length);\n      } else {\n        setVisibleTags(Math.max(1, maxTags));\n      }\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(bottomRef.current);\n    return () => observer.disconnect();\n  }, [cardVO.tags]);\n\n  const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;\n\n  function handleInstallClick(e: React.MouseEvent) {\n    e.stopPropagation();\n    if (onInstall) {\n      onInstall(cardVO.author, cardVO.pluginName);\n    }\n  }\n\n  function handleViewDetailsClick(e: React.MouseEvent) {\n    e.stopPropagation();\n    const detailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;\n    window.open(detailUrl, '_blank');\n  }\n\n  const kindIconMap: Record<string, React.ReactNode> = {\n    Tool: <Wrench className=\"w-4 h-4\" />,\n    EventListener: <AudioWaveform className=\"w-4 h-4\" />,\n    Command: <Hash className=\"w-4 h-4\" />,\n    KnowledgeEngine: <Book className=\"w-4 h-4\" />,\n    Parser: <FileText className=\"w-4 h-4\" />,\n  };\n\n  // Plugins that only contain KnowledgeRetriever components are deprecated\n  const isDeprecated = (() => {\n    if (!cardVO.components) return false;\n    const keys = Object.keys(cardVO.components);\n    return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');\n  })();\n\n  return (\n    <div\n      className=\"w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative\"\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n    >\n      <div className=\"w-full h-full flex flex-col justify-between gap-3\">\n        {/* 上部分：插件信息 */}\n        <div className=\"flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0\">\n          <img\n            src={cardVO.iconURL}\n            alt=\"plugin icon\"\n            className=\"w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%]\"\n          />\n\n          <div className=\"flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 overflow-hidden\">\n            <div className=\"flex flex-col items-start justify-start w-full min-w-0\">\n              <div className=\"text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full\">\n                {cardVO.pluginId}\n              </div>\n              <div className=\"flex items-center gap-1.5 w-full min-w-0\">\n                <div className=\"text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate\">\n                  {cardVO.label}\n                </div>\n                {isDeprecated && (\n                  <TooltipProvider delayDuration={200}>\n                    <Tooltip>\n                      <TooltipTrigger\n                        asChild\n                        onClick={(e) => e.preventDefault()}\n                      >\n                        <Badge\n                          variant=\"outline\"\n                          className=\"text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help\"\n                        >\n                          {t('market.deprecated')}\n                          <Info className=\"w-2.5 h-2.5\" />\n                        </Badge>\n                      </TooltipTrigger>\n                      <TooltipContent\n                        side=\"top\"\n                        className=\"max-w-[240px] text-xs\"\n                      >\n                        {t('market.deprecatedTooltip')}\n                      </TooltipContent>\n                    </Tooltip>\n                  </TooltipProvider>\n                )}\n              </div>\n            </div>\n\n            <div className=\"text-[0.7rem] sm:text-[0.8rem] text-[#666] dark:text-[#999] line-clamp-2 overflow-hidden\">\n              {cardVO.description}\n            </div>\n          </div>\n\n          <div className=\"flex flex-row items-start justify-center gap-[0.4rem] flex-shrink-0\">\n            {cardVO.githubURL && (\n              <svg\n                className=\"w-5 h-5 sm:w-[1.4rem] sm:h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0] flex-shrink-0\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                viewBox=\"0 0 24 24\"\n                fill=\"currentColor\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  window.open(cardVO.githubURL, '_blank');\n                }}\n              >\n                <path d=\"M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z\"></path>\n              </svg>\n            )}\n          </div>\n        </div>\n\n        {/* 下部分：下载量、标签和组件列表 */}\n        <div\n          ref={bottomRef}\n          className=\"w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden\"\n        >\n          <div className=\"flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden\">\n            {/* 下载数量 */}\n            <div className=\"flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0\">\n              <svg\n                className=\"w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2\"\n              >\n                <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n                <polyline points=\"7,10 12,15 17,10\" />\n                <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\" />\n              </svg>\n              <div className=\"text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap\">\n                {cardVO.installCount?.toLocaleString() ?? '0'}\n              </div>\n            </div>\n\n            {/* Tags - adaptive */}\n            {cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (\n              <div className=\"flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0\">\n                {cardVO.tags.slice(0, visibleTags).map((tag) => (\n                  <Badge\n                    key={tag}\n                    variant=\"secondary\"\n                    className=\"text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0 whitespace-nowrap\"\n                  >\n                    <svg\n                      className=\"w-2.5 h-2.5 flex-shrink-0\"\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    >\n                      <path d=\"M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z\" />\n                      <line x1=\"7\" y1=\"7\" x2=\"7.01\" y2=\"7\" />\n                    </svg>\n                    <span className=\"truncate max-w-[5rem]\">\n                      {tagNames[tag] || tag}\n                    </span>\n                  </Badge>\n                ))}\n                {remainingTags > 0 && (\n                  <Badge\n                    variant=\"outline\"\n                    className=\"text-[0.65rem] sm:text-[0.7rem] px-1.5 py-0.5 h-5 flex items-center flex-shrink-0 whitespace-nowrap\"\n                  >\n                    +{remainingTags}\n                  </Badge>\n                )}\n              </div>\n            )}\n          </div>\n\n          {/* 组件列表 */}\n          {cardVO.components && Object.keys(cardVO.components).length > 0 && (\n            <div className=\"flex flex-row items-center gap-1\">\n              {Object.entries(cardVO.components).map(([kind, count]) => (\n                <Badge\n                  key={kind}\n                  variant=\"outline\"\n                  className=\"flex items-center gap-1\"\n                >\n                  {kindIconMap[kind]}\n                  <span className=\"ml-1\">{count}</span>\n                </Badge>\n              ))}\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Hover overlay with action buttons */}\n      <div\n        className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 ${\n          isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'\n        }`}\n      >\n        <Button\n          onClick={handleInstallClick}\n          className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${\n            isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'\n          }`}\n          style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}\n        >\n          <Download className=\"w-4 h-4\" />\n          {t('market.install')}\n        </Button>\n        <Button\n          onClick={handleViewDetailsClick}\n          variant=\"outline\"\n          className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${\n            isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'\n          }`}\n          style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}\n        >\n          <ExternalLink className=\"w-4 h-4\" />\n          {t('market.viewDetails')}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts",
    "content": "export interface IPluginMarketCardVO {\n  pluginId: string;\n  author: string;\n  pluginName: string;\n  label: string;\n  description: string;\n  installCount: number;\n  iconURL: string;\n  githubURL: string;\n  version: string;\n  components?: Record<string, number>;\n  tags?: string[];\n}\n\nexport class PluginMarketCardVO implements IPluginMarketCardVO {\n  pluginId: string;\n  description: string;\n  label: string;\n  author: string;\n  pluginName: string;\n  iconURL: string;\n  githubURL: string;\n  installCount: number;\n  version: string;\n  components?: Record<string, number>;\n  tags?: string[];\n\n  constructor(prop: IPluginMarketCardVO) {\n    this.description = prop.description;\n    this.label = prop.label;\n    this.author = prop.author;\n    this.pluginName = prop.pluginName;\n    this.iconURL = prop.iconURL;\n    this.githubURL = prop.githubURL;\n    this.installCount = prop.installCount;\n    this.pluginId = prop.pluginId;\n    this.version = prop.version;\n    this.components = prop.components;\n    this.tags = prop.tags;\n  }\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/mcp-server/MCPCardVO.ts",
    "content": "import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';\n\nexport class MCPCardVO {\n  name: string;\n  mode: 'stdio' | 'sse' | 'http';\n  enable: boolean;\n  status: MCPSessionStatus;\n  tools: number;\n  error?: string;\n\n  constructor(data: MCPServer) {\n    this.name = data.name;\n    this.mode = data.mode;\n    this.enable = data.enable;\n\n    // Determine status from runtime_info\n    if (!data.runtime_info) {\n      this.status = MCPSessionStatus.ERROR;\n      this.tools = 0;\n    } else if (data.runtime_info.status === MCPSessionStatus.CONNECTED) {\n      this.status = data.runtime_info.status;\n      this.tools = data.runtime_info.tool_count || 0;\n    } else {\n      this.status = data.runtime_info.status;\n      this.tools = 0;\n      this.error = data.runtime_info.error_message;\n    }\n  }\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx",
    "content": "'use client';\n\nimport { useEffect, useState, useRef } from 'react';\nimport MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent';\nimport { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';\nimport { useTranslation } from 'react-i18next';\nimport { MCPSessionStatus } from '@/app/infra/entities/api';\n\nimport { httpClient } from '@/app/infra/http/HttpClient';\n\nexport default function MCPComponent({\n  onEditServer,\n}: {\n  askInstallServer?: (githubURL: string) => void;\n  onEditServer?: (serverName: string) => void;\n}) {\n  const { t } = useTranslation();\n  const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]);\n  const [loading, setLoading] = useState(false);\n  const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n  useEffect(() => {\n    fetchInstalledServers();\n\n    return () => {\n      // Cleanup: clear polling interval when component unmounts\n      if (pollingIntervalRef.current) {\n        clearInterval(pollingIntervalRef.current);\n      }\n    };\n  }, []);\n\n  // Check if any enabled server is connecting and start/stop polling accordingly\n  useEffect(() => {\n    const hasConnecting = installedServers.some(\n      (server) =>\n        server.enable && server.status === MCPSessionStatus.CONNECTING,\n    );\n\n    if (hasConnecting && !pollingIntervalRef.current) {\n      // Start polling every 3 seconds\n      pollingIntervalRef.current = setInterval(() => {\n        fetchInstalledServers();\n      }, 3000);\n    } else if (!hasConnecting && pollingIntervalRef.current) {\n      // Stop polling when no enabled server is connecting\n      clearInterval(pollingIntervalRef.current);\n      pollingIntervalRef.current = null;\n    }\n\n    return () => {\n      if (pollingIntervalRef.current) {\n        clearInterval(pollingIntervalRef.current);\n        pollingIntervalRef.current = null;\n      }\n    };\n  }, [installedServers]);\n\n  function fetchInstalledServers() {\n    setLoading(true);\n    httpClient\n      .getMCPServers()\n      .then((resp) => {\n        const servers = resp.servers.map((server) => new MCPCardVO(server));\n        setInstalledServers(servers);\n        setLoading(false);\n      })\n      .catch((error) => {\n        console.error('Failed to fetch MCP servers:', error);\n        setLoading(false);\n      });\n  }\n\n  return (\n    <div className=\"w-full h-full\">\n      {/* Server list */}\n      <div className=\"w-full h-full px-[0.8rem] pt-[0rem]\">\n        {loading ? (\n          <div className=\"flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2\">\n            {t('mcp.loading')}\n          </div>\n        ) : installedServers.length === 0 ? (\n          <div className=\"flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2\">\n            <svg\n              className=\"h-[3rem] w-[3rem]\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 24 24\"\n              fill=\"currentColor\"\n            >\n              <path d=\"M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z\"></path>\n            </svg>\n            <div className=\"text-lg mb-2\">{t('mcp.noServerInstalled')}</div>\n          </div>\n        ) : (\n          <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem] pb-6\">\n            {installedServers.map((server, index) => (\n              <div key={`${server.name}-${index}`}>\n                <MCPCardComponent\n                  cardVO={server}\n                  onCardClick={() => {\n                    if (onEditServer) {\n                      onEditServer(server.name);\n                    }\n                  }}\n                  onRefresh={fetchInstalledServers}\n                />\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx",
    "content": "import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';\nimport { useState, useEffect } from 'react';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { Switch } from '@/components/ui/switch';\nimport { Button } from '@/components/ui/button';\nimport { Badge } from '@/components/ui/badge';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react';\nimport { MCPSessionStatus } from '@/app/infra/entities/api';\n\nexport default function MCPCardComponent({\n  cardVO,\n  onCardClick,\n  onRefresh,\n}: {\n  cardVO: MCPCardVO;\n  onCardClick: () => void;\n  onRefresh: () => void;\n}) {\n  const { t } = useTranslation();\n  const [enabled, setEnabled] = useState(cardVO.enable);\n  const [switchEnable, setSwitchEnable] = useState(true);\n  const [testing, setTesting] = useState(false);\n  const [toolsCount, setToolsCount] = useState(cardVO.tools);\n  const [status, setStatus] = useState(cardVO.status);\n\n  useEffect(() => {\n    setStatus(cardVO.status);\n    setToolsCount(cardVO.tools);\n    setEnabled(cardVO.enable);\n  }, [cardVO.status, cardVO.tools, cardVO.enable]);\n\n  function handleEnable(checked: boolean) {\n    setSwitchEnable(false);\n    httpClient\n      .toggleMCPServer(cardVO.name, checked)\n      .then(() => {\n        setEnabled(checked);\n        toast.success(t('mcp.saveSuccess'));\n        onRefresh();\n        setSwitchEnable(true);\n      })\n      .catch((err) => {\n        toast.error(t('mcp.modifyFailed') + err.msg);\n        setSwitchEnable(true);\n      });\n  }\n\n  function handleTest(e: React.MouseEvent) {\n    e.stopPropagation();\n    setTesting(true);\n\n    httpClient\n      .testMCPServer(cardVO.name, {})\n      .then((resp) => {\n        const taskId = resp.task_id;\n\n        const interval = setInterval(() => {\n          httpClient.getAsyncTask(taskId).then((taskResp) => {\n            if (taskResp.runtime.done) {\n              clearInterval(interval);\n              setTesting(false);\n\n              if (taskResp.runtime.exception) {\n                toast.error(\n                  t('mcp.refreshFailed') + taskResp.runtime.exception,\n                );\n              } else {\n                toast.success(t('mcp.refreshSuccess'));\n              }\n\n              // Refresh to get updated runtime_info\n              onRefresh();\n            }\n          });\n        }, 1000);\n      })\n      .catch((err) => {\n        toast.error(t('mcp.refreshFailed') + err.msg);\n        setTesting(false);\n      });\n  }\n\n  return (\n    <div\n      className=\"w-[100%] h-[10rem] bg-white dark:bg-[#1f1f22] rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] dark:shadow-none p-[1.2rem] cursor-pointer transition-all duration-200 hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.1)] dark:hover:shadow-none\"\n      onClick={onCardClick}\n    >\n      <div className=\"w-full h-full flex flex-row items-start justify-start gap-[1.2rem]\">\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          width=\"64\"\n          height=\"64\"\n          fill=\"rgba(70,146,221,1)\"\n        >\n          <path d=\"M17.6567 14.8284L16.2425 13.4142L17.6567 12C19.2188 10.4379 19.2188 7.90524 17.6567 6.34314C16.0946 4.78105 13.5619 4.78105 11.9998 6.34314L10.5856 7.75736L9.17139 6.34314L10.5856 4.92893C12.9287 2.58578 16.7277 2.58578 19.0709 4.92893C21.414 7.27208 21.414 11.0711 19.0709 13.4142L17.6567 14.8284ZM14.8282 17.6569L13.414 19.0711C11.0709 21.4142 7.27189 21.4142 4.92875 19.0711C2.5856 16.7279 2.5856 12.9289 4.92875 10.5858L6.34296 9.17157L7.75717 10.5858L6.34296 12C4.78086 13.5621 4.78086 16.0948 6.34296 17.6569C7.90506 19.2189 10.4377 19.2189 11.9998 17.6569L13.414 16.2426L14.8282 17.6569ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z\"></path>\n        </svg>\n\n        <div className=\"w-full h-full flex flex-col items-start justify-between gap-[0.6rem]\">\n          <div className=\"flex flex-col items-start justify-start gap-[0.3rem]\">\n            <div className=\"flex flex-row items-center gap-[0.5rem]\">\n              <div className=\"text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium\">\n                {cardVO.name}\n              </div>\n              <Badge variant=\"secondary\" className=\"text-[0.65rem] px-1.5 py-0\">\n                {cardVO.mode.toUpperCase()}\n              </Badge>\n            </div>\n          </div>\n\n          <div className=\"w-full flex flex-row items-start justify-start gap-[0.6rem]\">\n            {!enabled ? (\n              // 未启用 - 橙色\n              <div className=\"flex flex-row items-center gap-[0.4rem]\">\n                <Ban className=\"w-4 h-4 text-orange-500 dark:text-orange-400\" />\n                <div className=\"text-sm text-orange-500 dark:text-orange-400 font-medium\">\n                  {t('mcp.statusDisabled')}\n                </div>\n              </div>\n            ) : status === MCPSessionStatus.CONNECTED ? (\n              // 连接成功 - 显示工具数量\n              <div className=\"flex h-full flex-row items-center justify-center gap-[0.4rem]\">\n                <Wrench className=\"w-5 h-5\" />\n                <div className=\"text-base text-black dark:text-[#f0f0f0] font-medium\">\n                  {t('mcp.toolCount', { count: toolsCount })}\n                </div>\n              </div>\n            ) : status === MCPSessionStatus.CONNECTING ? (\n              // 连接中 - 蓝色加载\n              <div className=\"flex flex-row items-center gap-[0.4rem]\">\n                <Loader2 className=\"w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin\" />\n                <div className=\"text-sm text-blue-500 dark:text-blue-400 font-medium\">\n                  {t('mcp.connecting')}\n                </div>\n              </div>\n            ) : (\n              // 连接失败 - 红色\n              <div className=\"flex flex-row items-center gap-[0.4rem]\">\n                <AlertCircle className=\"w-4 h-4 text-red-500 dark:text-red-400\" />\n                <div className=\"text-sm text-red-500 dark:text-red-400 font-medium\">\n                  {t('mcp.connectionFailedStatus')}\n                </div>\n              </div>\n            )}\n          </div>\n        </div>\n\n        <div className=\"flex flex-col items-center justify-between h-full\">\n          <div\n            className=\"flex items-center justify-center\"\n            onClick={(e) => e.stopPropagation()}\n          >\n            <Switch\n              className=\"cursor-pointer\"\n              checked={enabled}\n              onCheckedChange={handleEnable}\n              disabled={!switchEnable}\n            />\n          </div>\n\n          <div className=\"flex items-center justify-center gap-[0.4rem]\">\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"p-1 h-8 w-8\"\n              onClick={(e) => handleTest(e)}\n              disabled={testing}\n            >\n              <RefreshCcw className=\"w-4 h-4\" />\n            </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx",
    "content": "'use client';\n\nimport React from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { toast } from 'sonner';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n  DialogDescription,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport { httpClient } from '@/app/infra/http/HttpClient';\n\ninterface MCPDeleteConfirmDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  serverName: string | null;\n  onSuccess?: () => void;\n}\n\nexport default function MCPDeleteConfirmDialog({\n  open,\n  onOpenChange,\n  serverName,\n  onSuccess,\n}: MCPDeleteConfirmDialogProps) {\n  const { t } = useTranslation();\n\n  async function handleDelete() {\n    if (!serverName) return;\n\n    try {\n      await httpClient.deleteMCPServer(serverName);\n      toast.success(t('mcp.deleteSuccess'));\n\n      onOpenChange(false);\n\n      if (onSuccess) {\n        onSuccess();\n      }\n    } catch (error) {\n      console.error('Failed to delete server:', error);\n      toast.error(t('mcp.deleteFailed'));\n    }\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>{t('mcp.confirmDeleteTitle')}</DialogTitle>\n        </DialogHeader>\n        <DialogDescription>{t('mcp.confirmDeleteServer')}</DialogDescription>\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n            {t('common.cancel')}\n          </Button>\n          <Button variant=\"destructive\" onClick={handleDelete}>\n            {t('common.confirm')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx",
    "content": "'use client';\n\nimport React, { useState, useEffect, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Resolver, useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { z } from 'zod';\nimport { toast } from 'sonner';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport {\n  Card,\n  CardHeader,\n  CardTitle,\n  CardDescription,\n} from '@/components/ui/card';\nimport {\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from '@/components/ui/form';\nimport {\n  Select,\n  SelectTrigger,\n  SelectValue,\n  SelectContent,\n  SelectItem,\n} from '@/components/ui/select';\nimport { Input } from '@/components/ui/input';\nimport { Button } from '@/components/ui/button';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport {\n  MCPServerRuntimeInfo,\n  MCPTool,\n  MCPServer,\n  MCPSessionStatus,\n  MCPServerExtraArgsSSE,\n  MCPServerExtraArgsHttp,\n  MCPServerExtraArgsStdio,\n} from '@/app/infra/entities/api';\nimport { CustomApiError } from '@/app/infra/entities/common';\n\n// Status Display Component - 在测试中、连接中或连接失败时使用\nfunction StatusDisplay({\n  testing,\n  runtimeInfo,\n  t,\n}: {\n  testing: boolean;\n  runtimeInfo: MCPServerRuntimeInfo;\n  t: (key: string) => string;\n}) {\n  if (testing) {\n    return (\n      <div className=\"flex items-center gap-2 text-blue-600\">\n        <svg\n          className=\"w-5 h-5 animate-spin\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n        >\n          <circle\n            className=\"opacity-25\"\n            cx=\"12\"\n            cy=\"12\"\n            r=\"10\"\n            stroke=\"currentColor\"\n            strokeWidth=\"4\"\n          />\n          <path\n            className=\"opacity-75\"\n            fill=\"currentColor\"\n            d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n          />\n        </svg>\n        <span className=\"font-medium\">{t('mcp.testing')}</span>\n      </div>\n    );\n  }\n\n  // 连接中\n  if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {\n    return (\n      <div className=\"flex items-center gap-2 text-blue-600\">\n        <svg\n          className=\"w-5 h-5 animate-spin\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n        >\n          <circle\n            className=\"opacity-25\"\n            cx=\"12\"\n            cy=\"12\"\n            r=\"10\"\n            stroke=\"currentColor\"\n            strokeWidth=\"4\"\n          />\n          <path\n            className=\"opacity-75\"\n            fill=\"currentColor\"\n            d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n          />\n        </svg>\n        <span className=\"font-medium\">{t('mcp.connecting')}</span>\n      </div>\n    );\n  }\n\n  // 连接失败\n  return (\n    <div className=\"space-y-1\">\n      <div className=\"flex items-center gap-2 text-red-600\">\n        <svg\n          className=\"w-5 h-5\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n          stroke=\"currentColor\"\n        >\n          <path\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth={2}\n            d=\"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z\"\n          />\n        </svg>\n        <span className=\"font-medium\">{t('mcp.connectionFailed')}</span>\n      </div>\n      {runtimeInfo.error_message && (\n        <div className=\"text-sm text-red-500 pl-7\">\n          {runtimeInfo.error_message}\n        </div>\n      )}\n    </div>\n  );\n}\n\n// Tools List Component\nfunction ToolsList({ tools }: { tools: MCPTool[] }) {\n  return (\n    <div className=\"space-y-2 max-h-[300px] overflow-y-auto\">\n      {tools.map((tool, index) => (\n        <Card key={index} className=\"py-3 shadow-none\">\n          <CardHeader>\n            <CardTitle className=\"text-sm\">{tool.name}</CardTitle>\n            {tool.description && (\n              <CardDescription className=\"text-xs\">\n                {tool.description}\n              </CardDescription>\n            )}\n          </CardHeader>\n        </Card>\n      ))}\n    </div>\n  );\n}\n\nconst getFormSchema = (t: (key: string) => string) =>\n  z\n    .object({\n      name: z\n        .string({ required_error: t('mcp.nameRequired') })\n        .min(1, { message: t('mcp.nameRequired') }),\n      mode: z.enum(['sse', 'stdio', 'http']),\n      timeout: z\n        .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })\n        .positive({ message: t('mcp.timeoutMustBePositive') })\n        .default(30),\n      ssereadtimeout: z\n        .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })\n        .positive({ message: t('mcp.timeoutMustBePositive') })\n        .default(300),\n      url: z.string().optional(),\n      command: z.string().optional(),\n      args: z.array(z.object({ value: z.string() })).optional(),\n      extra_args: z\n        .array(\n          z.object({\n            key: z.string(),\n            type: z.enum(['string', 'number', 'boolean']),\n            value: z.string(),\n          }),\n        )\n        .optional(),\n    })\n    .superRefine((data, ctx) => {\n      if (data.mode === 'sse' || data.mode === 'http') {\n        if (!data.url || data.url.length === 0) {\n          ctx.addIssue({\n            code: z.ZodIssueCode.custom,\n            message: t('mcp.urlRequired'),\n            path: ['url'],\n          });\n        }\n      } else if (data.mode === 'stdio') {\n        if (!data.command || data.command.length === 0) {\n          ctx.addIssue({\n            code: z.ZodIssueCode.custom,\n            message: t('mcp.commandRequired'),\n            path: ['command'],\n          });\n        }\n      }\n    });\n\ntype FormValues = z.infer<ReturnType<typeof getFormSchema>> & {\n  timeout: number;\n  ssereadtimeout: number;\n};\n\ninterface MCPFormDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  serverName?: string | null;\n  isEditMode?: boolean;\n  onSuccess?: () => void;\n  onDelete?: () => void;\n}\n\nexport default function MCPFormDialog({\n  open,\n  onOpenChange,\n  serverName,\n  isEditMode = false,\n  onSuccess,\n  onDelete,\n}: MCPFormDialogProps) {\n  const { t } = useTranslation();\n  const formSchema = getFormSchema(t);\n\n  const form = useForm<FormValues>({\n    resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,\n    defaultValues: {\n      name: '',\n      mode: 'sse',\n      url: '',\n      command: '',\n      args: [],\n      timeout: 30,\n      ssereadtimeout: 300,\n      extra_args: [],\n    },\n  });\n\n  const [extraArgs, setExtraArgs] = useState<\n    { key: string; type: 'string' | 'number' | 'boolean'; value: string }[]\n  >([]);\n  const [stdioArgs, setStdioArgs] = useState<{ value: string }[]>([]);\n  const [mcpTesting, setMcpTesting] = useState(false);\n  const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(\n    null,\n  );\n  const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n  const watchMode = form.watch('mode');\n\n  // Load server data when editing\n  useEffect(() => {\n    if (open && isEditMode && serverName) {\n      loadServerForEdit(serverName);\n    } else if (open && !isEditMode) {\n      // Reset form when creating new server\n      form.reset({\n        name: '',\n        mode: 'sse',\n        url: '',\n        command: '',\n        args: [],\n        timeout: 30,\n        ssereadtimeout: 300,\n        extra_args: [],\n      });\n      setExtraArgs([]);\n      setStdioArgs([]);\n      setRuntimeInfo(null);\n    }\n\n    // Cleanup polling interval when dialog closes\n    return () => {\n      if (pollingIntervalRef.current) {\n        clearInterval(pollingIntervalRef.current);\n        pollingIntervalRef.current = null;\n      }\n    };\n  }, [open, isEditMode, serverName]);\n\n  // Poll for updates when runtime_info status is CONNECTING\n  useEffect(() => {\n    if (\n      !open ||\n      !isEditMode ||\n      !serverName ||\n      !runtimeInfo ||\n      runtimeInfo.status !== MCPSessionStatus.CONNECTING\n    ) {\n      // Stop polling if conditions are not met\n      if (pollingIntervalRef.current) {\n        clearInterval(pollingIntervalRef.current);\n        pollingIntervalRef.current = null;\n      }\n      return;\n    }\n\n    // Start polling if not already running\n    if (!pollingIntervalRef.current) {\n      pollingIntervalRef.current = setInterval(() => {\n        loadServerForEdit(serverName);\n      }, 3000);\n    }\n\n    return () => {\n      if (pollingIntervalRef.current) {\n        clearInterval(pollingIntervalRef.current);\n        pollingIntervalRef.current = null;\n      }\n    };\n  }, [open, isEditMode, serverName, runtimeInfo?.status]);\n\n  async function loadServerForEdit(serverName: string) {\n    try {\n      const resp = await httpClient.getMCPServer(serverName);\n      const server = resp.server ?? resp;\n\n      form.setValue('name', server.name);\n      form.setValue('mode', server.mode);\n\n      if (server.mode === 'sse' || server.mode === 'http') {\n        form.setValue('url', server.extra_args.url);\n        form.setValue('timeout', server.extra_args.timeout);\n\n        if (server.mode === 'sse') {\n          form.setValue('ssereadtimeout', server.extra_args.ssereadtimeout);\n        }\n\n        if (server.extra_args.headers) {\n          const headers = Object.entries(server.extra_args.headers).map(\n            ([key, value]) => ({\n              key,\n              type: 'string' as const,\n              value: String(value),\n            }),\n          );\n          setExtraArgs(headers);\n          form.setValue('extra_args', headers);\n        }\n      } else if (server.mode === 'stdio') {\n        form.setValue('command', server.extra_args.command);\n        const args = (server.extra_args.args || []).map((arg: string) => ({\n          value: arg,\n        }));\n        setStdioArgs(args);\n        form.setValue('args', args);\n\n        if (server.extra_args.env) {\n          const envs = Object.entries(server.extra_args.env).map(\n            ([key, value]) => ({\n              key,\n              type: 'string' as const,\n              value: String(value),\n            }),\n          );\n          setExtraArgs(envs);\n          form.setValue('extra_args', envs);\n        }\n      }\n\n      if (server.runtime_info) {\n        setRuntimeInfo(server.runtime_info);\n      } else {\n        setRuntimeInfo(null);\n      }\n    } catch (error) {\n      console.error('Failed to load server:', error);\n      toast.error(t('mcp.loadFailed'));\n    }\n  }\n\n  async function handleFormSubmit(value: z.infer<typeof formSchema>) {\n    try {\n      let serverConfig: MCPServer;\n\n      if (value.mode === 'sse' || value.mode === 'http') {\n        const headers: Record<string, string> = {};\n        value.extra_args?.forEach((arg) => {\n          headers[arg.key] = String(arg.value);\n        });\n\n        if (value.mode === 'sse') {\n          serverConfig = {\n            name: value.name,\n            mode: 'sse',\n            enable: true,\n            extra_args: {\n              url: value.url!,\n              headers: headers,\n              timeout: value.timeout,\n              ssereadtimeout: value.ssereadtimeout,\n            },\n          };\n        } else {\n          serverConfig = {\n            name: value.name,\n            mode: 'http',\n            enable: true,\n            extra_args: {\n              url: value.url!,\n              headers: headers,\n              timeout: value.timeout,\n            },\n          };\n        }\n      } else {\n        // Convert extra_args to env\n        const env: Record<string, string> = {};\n        value.extra_args?.forEach((arg) => {\n          env[arg.key] = String(arg.value);\n        });\n\n        // Convert args object array to string array\n        const args = value.args?.map((arg) => arg.value) || [];\n\n        serverConfig = {\n          name: value.name,\n          mode: 'stdio',\n          enable: true,\n          extra_args: {\n            command: value.command!,\n            args: args,\n            env: env,\n          },\n        };\n      }\n\n      if (isEditMode && serverName) {\n        await httpClient.updateMCPServer(serverName, serverConfig);\n        toast.success(t('mcp.updateSuccess'));\n      } else {\n        await httpClient.createMCPServer(serverConfig);\n        toast.success(t('mcp.createSuccess'));\n      }\n\n      handleDialogClose(false);\n      onSuccess?.();\n    } catch (error) {\n      console.error('Failed to save MCP server:', error);\n      const errMsg = (error as CustomApiError).msg || '';\n      toast.error(\n        (isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')) + errMsg,\n      );\n    }\n  }\n\n  async function testMcp() {\n    setMcpTesting(true);\n\n    try {\n      const mode = form.getValues('mode');\n      let extraArgsData:\n        | MCPServerExtraArgsSSE\n        | MCPServerExtraArgsHttp\n        | MCPServerExtraArgsStdio;\n\n      if (mode === 'sse') {\n        extraArgsData = {\n          url: form.getValues('url')!,\n          timeout: form.getValues('timeout'),\n          headers: Object.fromEntries(\n            extraArgs.map((arg) => [arg.key, arg.value]),\n          ),\n          ssereadtimeout: form.getValues('ssereadtimeout'),\n        };\n      } else if (mode === 'http') {\n        extraArgsData = {\n          url: form.getValues('url')!,\n          timeout: form.getValues('timeout'),\n          headers: Object.fromEntries(\n            extraArgs.map((arg) => [arg.key, arg.value]),\n          ),\n        };\n      } else {\n        extraArgsData = {\n          command: form.getValues('command')!,\n          args: stdioArgs.map((arg) => arg.value),\n          env: Object.fromEntries(extraArgs.map((arg) => [arg.key, arg.value])),\n        };\n      }\n\n      const { task_id } = await httpClient.testMCPServer('_', {\n        name: form.getValues('name'),\n        mode: mode,\n        enable: true,\n        extra_args: extraArgsData,\n      } as MCPServer);\n\n      if (!task_id) {\n        throw new Error(t('mcp.noTaskId'));\n      }\n\n      const interval = setInterval(async () => {\n        try {\n          const taskResp = await httpClient.getAsyncTask(task_id);\n\n          if (taskResp.runtime?.done) {\n            clearInterval(interval);\n            setMcpTesting(false);\n\n            if (taskResp.runtime.exception) {\n              const errorMsg =\n                taskResp.runtime.exception || t('mcp.unknownError');\n              toast.error(`${t('mcp.testError')}: ${errorMsg}`);\n              setRuntimeInfo({\n                status: MCPSessionStatus.ERROR,\n                error_message: errorMsg,\n                tool_count: 0,\n                tools: [],\n              });\n            } else {\n              if (isEditMode) {\n                await loadServerForEdit(form.getValues('name'));\n              }\n              toast.success(t('mcp.testSuccess'));\n            }\n          }\n        } catch (err) {\n          clearInterval(interval);\n          setMcpTesting(false);\n          const errorMsg =\n            (err as CustomApiError).msg || t('mcp.getTaskFailed');\n          toast.error(`${t('mcp.testError')}: ${errorMsg}`);\n        }\n      }, 1000);\n    } catch (err) {\n      setMcpTesting(false);\n      const errorMsg = (err as Error).message || t('mcp.unknownError');\n      toast.error(`${t('mcp.testError')}: ${errorMsg}`);\n    }\n  }\n\n  const addExtraArg = () => {\n    const newArgs = [\n      ...extraArgs,\n      { key: '', type: 'string' as const, value: '' },\n    ];\n    setExtraArgs(newArgs);\n    form.setValue('extra_args', newArgs);\n  };\n\n  const removeExtraArg = (index: number) => {\n    const newArgs = extraArgs.filter((_, i) => i !== index);\n    setExtraArgs(newArgs);\n    form.setValue('extra_args', newArgs);\n  };\n\n  const updateExtraArg = (\n    index: number,\n    field: 'key' | 'type' | 'value',\n    value: string,\n  ) => {\n    const newArgs = [...extraArgs];\n    newArgs[index] = { ...newArgs[index], [field]: value };\n    setExtraArgs(newArgs);\n    form.setValue('extra_args', newArgs);\n  };\n\n  const addStdioArg = () => {\n    const newArgs = [...stdioArgs, { value: '' }];\n    setStdioArgs(newArgs);\n    form.setValue('args', newArgs);\n  };\n\n  const removeStdioArg = (index: number) => {\n    const newArgs = stdioArgs.filter((_, i) => i !== index);\n    setStdioArgs(newArgs);\n    form.setValue('args', newArgs);\n  };\n\n  const updateStdioArg = (index: number, value: string) => {\n    const newArgs = [...stdioArgs];\n    newArgs[index] = { value };\n    setStdioArgs(newArgs);\n    form.setValue('args', newArgs);\n  };\n\n  const handleDialogClose = (open: boolean) => {\n    onOpenChange(open);\n    if (!open) {\n      form.reset();\n      setExtraArgs([]);\n      setStdioArgs([]);\n      setRuntimeInfo(null);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={handleDialogClose}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>\n            {isEditMode ? t('mcp.editServer') : t('mcp.createServer')}\n          </DialogTitle>\n        </DialogHeader>\n\n        {isEditMode && runtimeInfo && (\n          <div className=\"mb-0 space-y-3\">\n            {/* 测试中或连接失败时显示状态 */}\n            {(mcpTesting ||\n              runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (\n              <div className=\"p-3 rounded-lg border\">\n                <StatusDisplay\n                  testing={mcpTesting}\n                  runtimeInfo={runtimeInfo}\n                  t={t}\n                />\n              </div>\n            )}\n\n            {/* 连接成功时只显示工具列表 */}\n            {!mcpTesting &&\n              runtimeInfo.status === MCPSessionStatus.CONNECTED &&\n              runtimeInfo.tools?.length > 0 && (\n                <>\n                  <div className=\"text-sm font-medium\">\n                    {t('mcp.toolCount', {\n                      count: runtimeInfo.tools?.length || 0,\n                    })}\n                  </div>\n                  <ToolsList tools={runtimeInfo.tools} />\n                </>\n              )}\n          </div>\n        )}\n\n        <Form {...form}>\n          <form\n            onSubmit={form.handleSubmit(handleFormSubmit)}\n            className=\"space-y-4\"\n          >\n            <div className=\"space-y-4\">\n              <FormField\n                control={form.control}\n                name=\"name\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t('mcp.name')}</FormLabel>\n                    <FormControl>\n                      <Input {...field} />\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n\n              <FormField\n                control={form.control}\n                name=\"mode\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t('mcp.serverMode')}</FormLabel>\n                    <Select\n                      onValueChange={field.onChange}\n                      defaultValue={field.value}\n                      value={field.value}\n                    >\n                      <FormControl>\n                        <SelectTrigger>\n                          <SelectValue placeholder={t('mcp.selectMode')} />\n                        </SelectTrigger>\n                      </FormControl>\n                      <SelectContent>\n                        <SelectItem value=\"http\">{t('mcp.http')}</SelectItem>\n                        <SelectItem value=\"stdio\">{t('mcp.stdio')}</SelectItem>\n                        <SelectItem value=\"sse\">{t('mcp.sse')}</SelectItem>\n                      </SelectContent>\n                    </Select>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n\n              {(watchMode === 'sse' || watchMode === 'http') && (\n                <>\n                  <FormField\n                    control={form.control}\n                    name=\"url\"\n                    render={({ field }) => (\n                      <FormItem>\n                        <FormLabel>{t('mcp.url')}</FormLabel>\n                        <FormControl>\n                          <Input {...field} />\n                        </FormControl>\n                        <FormMessage />\n                      </FormItem>\n                    )}\n                  />\n\n                  <FormField\n                    control={form.control}\n                    name=\"timeout\"\n                    render={({ field }) => (\n                      <FormItem>\n                        <FormLabel>{t('mcp.timeout')}</FormLabel>\n                        <FormControl>\n                          <Input\n                            type=\"number\"\n                            placeholder={t('mcp.timeout')}\n                            {...field}\n                            onChange={(e) =>\n                              field.onChange(Number(e.target.value))\n                            }\n                          />\n                        </FormControl>\n                        <FormMessage />\n                      </FormItem>\n                    )}\n                  />\n\n                  {watchMode === 'sse' && (\n                    <FormField\n                      control={form.control}\n                      name=\"ssereadtimeout\"\n                      render={({ field }) => (\n                        <FormItem>\n                          <FormLabel>{t('mcp.sseTimeout')}</FormLabel>\n                          <FormControl>\n                            <Input\n                              type=\"number\"\n                              placeholder={t('mcp.sseTimeoutDescription')}\n                              {...field}\n                              onChange={(e) =>\n                                field.onChange(Number(e.target.value))\n                              }\n                            />\n                          </FormControl>\n                          <FormMessage />\n                        </FormItem>\n                      )}\n                    />\n                  )}\n                </>\n              )}\n\n              {watchMode === 'stdio' && (\n                <>\n                  <FormField\n                    control={form.control}\n                    name=\"command\"\n                    render={({ field }) => (\n                      <FormItem>\n                        <FormLabel>{t('mcp.command')}</FormLabel>\n                        <FormControl>\n                          <Input {...field} />\n                        </FormControl>\n                        <FormMessage />\n                      </FormItem>\n                    )}\n                  />\n\n                  <FormItem>\n                    <FormLabel>{t('mcp.args')}</FormLabel>\n                    <div className=\"space-y-2\">\n                      {stdioArgs.map((arg, index) => (\n                        <div key={index} className=\"flex gap-2\">\n                          <Input\n                            placeholder={t('mcp.args')}\n                            value={arg.value}\n                            onChange={(e) =>\n                              updateStdioArg(index, e.target.value)\n                            }\n                          />\n                          <button\n                            type=\"button\"\n                            className=\"p-2 hover:bg-gray-100 rounded\"\n                            onClick={() => removeStdioArg(index)}\n                          >\n                            <svg\n                              xmlns=\"http://www.w3.org/2000/svg\"\n                              viewBox=\"0 0 24 24\"\n                              fill=\"currentColor\"\n                              className=\"w-5 h-5 text-red-500\"\n                            >\n                              <path d=\"M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z\"></path>\n                            </svg>\n                          </button>\n                        </div>\n                      ))}\n                      <Button\n                        type=\"button\"\n                        variant=\"outline\"\n                        onClick={addStdioArg}\n                      >\n                        {t('mcp.addArgument')}\n                      </Button>\n                    </div>\n                  </FormItem>\n                </>\n              )}\n\n              <FormItem>\n                <FormLabel>\n                  {watchMode === 'sse' || watchMode === 'http'\n                    ? t('mcp.headers')\n                    : t('mcp.env')}\n                </FormLabel>\n                <div className=\"space-y-2\">\n                  {extraArgs.map((arg, index) => (\n                    <div key={index} className=\"flex gap-2\">\n                      <Input\n                        placeholder={t('models.keyName')}\n                        value={arg.key}\n                        onChange={(e) =>\n                          updateExtraArg(index, 'key', e.target.value)\n                        }\n                      />\n                      {/* Only show type select for SSE headers if needed, but usually headers are strings. Env vars are definitely strings.\n                          The original code had type selector. Let's keep it for compatibility or remove if not needed.\n                          Headers are strings. Env vars are strings.\n                          Let's hide the type selector as it was confusing anyway, or force it to string.\n                       */}\n                      {/* <Select\n                        value={arg.type}\n                        onValueChange={(value) =>\n                          updateExtraArg(index, 'type', value)\n                        }\n                      >\n                        <SelectTrigger className=\"w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]\">\n                          <SelectValue placeholder={t('models.type')} />\n                        </SelectTrigger>\n                        <SelectContent className=\"bg-[#ffffff] dark:bg-[#2a2a2e]\">\n                          <SelectItem value=\"string\">\n                            {t('models.string')}\n                          </SelectItem>\n                        </SelectContent>\n                      </Select> */}\n                      <Input\n                        placeholder={t('models.value')}\n                        value={arg.value}\n                        onChange={(e) =>\n                          updateExtraArg(index, 'value', e.target.value)\n                        }\n                      />\n                      <button\n                        type=\"button\"\n                        className=\"p-2 hover:bg-gray-100 rounded\"\n                        onClick={() => removeExtraArg(index)}\n                      >\n                        <svg\n                          xmlns=\"http://www.w3.org/2000/svg\"\n                          viewBox=\"0 0 24 24\"\n                          fill=\"currentColor\"\n                          className=\"w-5 h-5 text-red-500\"\n                        >\n                          <path d=\"M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z\"></path>\n                        </svg>\n                      </button>\n                    </div>\n                  ))}\n                  <Button type=\"button\" variant=\"outline\" onClick={addExtraArg}>\n                    {watchMode === 'sse' || watchMode === 'http'\n                      ? t('mcp.addHeader')\n                      : t('mcp.addEnvVar')}\n                  </Button>\n                </div>\n                <FormDescription>\n                  {t('mcp.extraParametersDescription')}\n                </FormDescription>\n                <FormMessage />\n              </FormItem>\n\n              <DialogFooter>\n                {isEditMode && onDelete && (\n                  <Button\n                    type=\"button\"\n                    variant=\"destructive\"\n                    onClick={onDelete}\n                  >\n                    {t('common.delete')}\n                  </Button>\n                )}\n\n                <Button type=\"submit\">\n                  {isEditMode ? t('common.save') : t('common.submit')}\n                </Button>\n\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  onClick={() => testMcp()}\n                  disabled={mcpTesting}\n                >\n                  {t('common.test')}\n                </Button>\n\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  onClick={() => handleDialogClose(false)}\n                >\n                  {t('common.cancel')}\n                </Button>\n              </DialogFooter>\n            </div>\n          </form>\n        </Form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/page.tsx",
    "content": "'use client';\nimport PluginInstalledComponent, {\n  PluginInstalledComponentRef,\n} from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent';\nimport MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';\nimport MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent';\nimport MCPFormDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPFormDialog';\nimport MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog';\nimport styles from './plugins.module.css';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { Button } from '@/components/ui/button';\nimport {\n  Card,\n  CardHeader,\n  CardTitle,\n  CardDescription,\n} from '@/components/ui/card';\nimport {\n  PlusIcon,\n  ChevronDownIcon,\n  UploadIcon,\n  StoreIcon,\n  Download,\n  Power,\n  Github,\n  ChevronLeft,\n  Code,\n  Copy,\n  Check,\n  Bug,\n} from 'lucide-react';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { Input } from '@/components/ui/input';\nimport React, { useState, useRef, useCallback, useEffect } from 'react';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport { PluginV4 } from '@/app/infra/entities/plugin';\nimport { systemInfo } from '@/app/infra/http/HttpClient';\nimport { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';\n\nenum PluginInstallStatus {\n  WAIT_INPUT = 'wait_input',\n  SELECT_RELEASE = 'select_release',\n  SELECT_ASSET = 'select_asset',\n  ASK_CONFIRM = 'ask_confirm',\n  INSTALLING = 'installing',\n  ERROR = 'error',\n}\n\ninterface GithubRelease {\n  id: number;\n  tag_name: string;\n  name: string;\n  published_at: string;\n  prerelease: boolean;\n  draft: boolean;\n}\n\ninterface GithubAsset {\n  id: number;\n  name: string;\n  size: number;\n  download_url: string;\n  content_type: string;\n}\n\nexport default function PluginConfigPage() {\n  const { t } = useTranslation();\n  const [activeTab, setActiveTab] = useState('installed');\n  const [modalOpen, setModalOpen] = useState(false);\n  const [installSource, setInstallSource] = useState<string>('local');\n  const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any\n  const [mcpSSEModalOpen, setMcpSSEModalOpen] = useState(false);\n  const [pluginInstallStatus, setPluginInstallStatus] =\n    useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);\n  const [installError, setInstallError] = useState<string | null>(null);\n  const [githubURL, setGithubURL] = useState('');\n  const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]);\n  const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(\n    null,\n  );\n  const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]);\n  const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(null);\n  const [githubOwner, setGithubOwner] = useState('');\n  const [githubRepo, setGithubRepo] = useState('');\n  const [fetchingReleases, setFetchingReleases] = useState(false);\n  const [fetchingAssets, setFetchingAssets] = useState(false);\n  const [isDragOver, setIsDragOver] = useState(false);\n  const [pluginSystemStatus, setPluginSystemStatus] =\n    useState<ApiRespPluginSystemStatus | null>(null);\n  const [statusLoading, setStatusLoading] = useState(true);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);\n  const [editingServerName, setEditingServerName] = useState<string | null>(\n    null,\n  );\n  const [isEditMode, setIsEditMode] = useState(false);\n  const [refreshKey, setRefreshKey] = useState(0);\n  const [debugInfo, setDebugInfo] = useState<{\n    debug_url: string;\n    plugin_debug_key: string;\n  } | null>(null);\n  const [debugPopoverOpen, setDebugPopoverOpen] = useState(false);\n  const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);\n  const [copiedDebugKey, setCopiedDebugKey] = useState(false);\n\n  useEffect(() => {\n    const fetchPluginSystemStatus = async () => {\n      try {\n        setStatusLoading(true);\n        const status = await httpClient.getPluginSystemStatus();\n        setPluginSystemStatus(status);\n      } catch (error) {\n        console.error('Failed to fetch plugin system status:', error);\n        toast.error(t('plugins.failedToGetStatus'));\n      } finally {\n        setStatusLoading(false);\n      }\n    };\n\n    fetchPluginSystemStatus();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  function formatFileSize(bytes: number): string {\n    if (bytes === 0) return '0 Bytes';\n    const k = 1024;\n    const sizes = ['Bytes', 'KB', 'MB', 'GB'];\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n    return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];\n  }\n\n  function watchTask(taskId: number) {\n    let alreadySuccess = false;\n\n    const interval = setInterval(() => {\n      httpClient.getAsyncTask(taskId).then((resp) => {\n        if (resp.runtime.done) {\n          clearInterval(interval);\n          if (resp.runtime.exception) {\n            setInstallError(resp.runtime.exception);\n            setPluginInstallStatus(PluginInstallStatus.ERROR);\n          } else {\n            if (!alreadySuccess) {\n              toast.success(t('plugins.installSuccess'));\n              alreadySuccess = true;\n            }\n            resetGithubState();\n            setModalOpen(false);\n            pluginInstalledRef.current?.refreshPluginList();\n          }\n        }\n      });\n    }, 1000);\n  }\n\n  const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);\n\n  function resetGithubState() {\n    setGithubURL('');\n    setGithubReleases([]);\n    setSelectedRelease(null);\n    setGithubAssets([]);\n    setSelectedAsset(null);\n    setGithubOwner('');\n    setGithubRepo('');\n    setFetchingReleases(false);\n    setFetchingAssets(false);\n  }\n\n  async function checkExtensionsLimit(): Promise<boolean> {\n    const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;\n    if (maxExtensions < 0) return true;\n    try {\n      const [pluginsResp, mcpResp] = await Promise.all([\n        httpClient.getPlugins(),\n        httpClient.getMCPServers(),\n      ]);\n      const total =\n        (pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);\n      if (total >= maxExtensions) {\n        toast.error(\n          t('limitation.maxExtensionsReached', { max: maxExtensions }),\n        );\n        return false;\n      }\n    } catch {\n      // If we can't check, let backend handle it\n    }\n    return true;\n  }\n\n  async function fetchGithubReleases() {\n    if (!githubURL.trim()) {\n      toast.error(t('plugins.enterRepoUrl'));\n      return;\n    }\n\n    setFetchingReleases(true);\n    setInstallError(null);\n\n    try {\n      const result = await httpClient.getGithubReleases(githubURL);\n      setGithubReleases(result.releases);\n      setGithubOwner(result.owner);\n      setGithubRepo(result.repo);\n\n      if (result.releases.length === 0) {\n        toast.warning(t('plugins.noReleasesFound'));\n      } else {\n        setPluginInstallStatus(PluginInstallStatus.SELECT_RELEASE);\n      }\n    } catch (error: unknown) {\n      console.error('Failed to fetch GitHub releases:', error);\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      setInstallError(errorMessage || t('plugins.fetchReleasesError'));\n      setPluginInstallStatus(PluginInstallStatus.ERROR);\n    } finally {\n      setFetchingReleases(false);\n    }\n  }\n\n  async function handleReleaseSelect(release: GithubRelease) {\n    setSelectedRelease(release);\n    setFetchingAssets(true);\n    setInstallError(null);\n\n    try {\n      const result = await httpClient.getGithubReleaseAssets(\n        githubOwner,\n        githubRepo,\n        release.id,\n      );\n      setGithubAssets(result.assets);\n\n      if (result.assets.length === 0) {\n        toast.warning(t('plugins.noAssetsFound'));\n      } else {\n        setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);\n      }\n    } catch (error: unknown) {\n      console.error('Failed to fetch GitHub release assets:', error);\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      setInstallError(errorMessage || t('plugins.fetchAssetsError'));\n      setPluginInstallStatus(PluginInstallStatus.ERROR);\n    } finally {\n      setFetchingAssets(false);\n    }\n  }\n\n  function handleAssetSelect(asset: GithubAsset) {\n    setSelectedAsset(asset);\n    setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);\n  }\n\n  function handleModalConfirm() {\n    if (installSource === 'github' && selectedAsset && selectedRelease) {\n      installPlugin('github', {\n        asset_url: selectedAsset.download_url,\n        owner: githubOwner,\n        repo: githubRepo,\n        release_tag: selectedRelease.tag_name,\n      });\n    } else {\n      installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any\n    }\n  }\n\n  function installPlugin(\n    installSource: string,\n    installInfo: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any\n  ) {\n    setPluginInstallStatus(PluginInstallStatus.INSTALLING);\n    if (installSource === 'github') {\n      httpClient\n        .installPluginFromGithub(\n          installInfo.asset_url,\n          installInfo.owner,\n          installInfo.repo,\n          installInfo.release_tag,\n        )\n        .then((resp) => {\n          const taskId = resp.task_id;\n          watchTask(taskId);\n        })\n        .catch((err) => {\n          setInstallError(err.msg);\n          setPluginInstallStatus(PluginInstallStatus.ERROR);\n        });\n    } else if (installSource === 'local') {\n      httpClient\n        .installPluginFromLocal(installInfo.file)\n        .then((resp) => {\n          const taskId = resp.task_id;\n          watchTask(taskId);\n        })\n        .catch((err) => {\n          setInstallError(err.msg);\n          setPluginInstallStatus(PluginInstallStatus.ERROR);\n        });\n    } else if (installSource === 'marketplace') {\n      httpClient\n        .installPluginFromMarketplace(\n          installInfo.plugin_author,\n          installInfo.plugin_name,\n          installInfo.plugin_version,\n        )\n        .then((resp) => {\n          const taskId = resp.task_id;\n          watchTask(taskId);\n        });\n    }\n  }\n\n  const validateFileType = (file: File): boolean => {\n    const allowedExtensions = ['.lbpkg', '.zip'];\n    const fileName = file.name.toLowerCase();\n    return allowedExtensions.some((ext) => fileName.endsWith(ext));\n  };\n\n  const uploadPluginFile = useCallback(\n    async (file: File) => {\n      if (!pluginSystemStatus?.is_enable || !pluginSystemStatus?.is_connected) {\n        toast.error(t('plugins.pluginSystemNotReady'));\n        return;\n      }\n\n      if (!validateFileType(file)) {\n        toast.error(t('plugins.unsupportedFileType'));\n        return;\n      }\n\n      if (!(await checkExtensionsLimit())) return;\n\n      setModalOpen(true);\n      setPluginInstallStatus(PluginInstallStatus.INSTALLING);\n      setInstallError(null);\n      installPlugin('local', { file });\n    },\n    [t, pluginSystemStatus, installPlugin],\n  );\n\n  const handleFileSelect = useCallback(async () => {\n    if (!(await checkExtensionsLimit())) return;\n    if (fileInputRef.current) {\n      fileInputRef.current.click();\n    }\n  }, []);\n\n  const handleFileChange = useCallback(\n    (event: React.ChangeEvent<HTMLInputElement>) => {\n      const file = event.target.files?.[0];\n      if (file) {\n        uploadPluginFile(file);\n      }\n\n      event.target.value = '';\n    },\n    [uploadPluginFile],\n  );\n\n  const isPluginSystemReady =\n    pluginSystemStatus?.is_enable && pluginSystemStatus?.is_connected;\n\n  const handleDragOver = useCallback(\n    (event: React.DragEvent) => {\n      event.preventDefault();\n      if (isPluginSystemReady) {\n        setIsDragOver(true);\n      }\n    },\n    [isPluginSystemReady],\n  );\n\n  const handleDragLeave = useCallback((event: React.DragEvent) => {\n    event.preventDefault();\n    setIsDragOver(false);\n  }, []);\n\n  const handleDrop = useCallback(\n    (event: React.DragEvent) => {\n      event.preventDefault();\n      setIsDragOver(false);\n\n      if (!isPluginSystemReady) {\n        toast.error(t('plugins.pluginSystemNotReady'));\n        return;\n      }\n\n      const files = Array.from(event.dataTransfer.files);\n      if (files.length > 0) {\n        uploadPluginFile(files[0]);\n      }\n    },\n    [uploadPluginFile, isPluginSystemReady, t],\n  );\n\n  const handleShowDebugInfo = async () => {\n    try {\n      const info = await httpClient.getPluginDebugInfo();\n      setDebugInfo(info);\n      setDebugPopoverOpen(true);\n    } catch (error) {\n      console.error('Failed to fetch debug info:', error);\n      toast.error(t('plugins.failedToGetDebugInfo'));\n    }\n  };\n\n  const handleCopyDebugInfo = (text: string, type: 'url' | 'key') => {\n    try {\n      navigator.clipboard.writeText(text);\n      if (type === 'url') {\n        setCopiedDebugUrl(true);\n        setTimeout(() => setCopiedDebugUrl(false), 2000);\n      } else {\n        setCopiedDebugKey(true);\n        setTimeout(() => setCopiedDebugKey(false), 2000);\n      }\n    } catch {\n      const textArea = document.createElement('textarea');\n      textArea.value = text;\n      textArea.style.position = 'fixed';\n      textArea.style.left = '-999999px';\n      textArea.style.top = '-999999px';\n      document.body.appendChild(textArea);\n      textArea.select();\n      textArea.setSelectionRange(0, 99999);\n      const success = document.execCommand('copy');\n      document.body.removeChild(textArea);\n      if (success) {\n        setCopiedDebugUrl(true);\n        setTimeout(() => setCopiedDebugUrl(false), 2000);\n      } else {\n        setCopiedDebugKey(true);\n        setTimeout(() => setCopiedDebugKey(false), 2000);\n      }\n    }\n  };\n\n  const renderPluginDisabledState = () => (\n    <div className=\"flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]\">\n      <Power className=\"w-16 h-16 text-gray-400 mb-4\" />\n      <h2 className=\"text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-2\">\n        {t('plugins.systemDisabled')}\n      </h2>\n      <p className=\"text-gray-500 dark:text-gray-400 max-w-md\">\n        {t('plugins.systemDisabledDesc')}\n      </p>\n    </div>\n  );\n\n  const renderPluginConnectionErrorState = () => (\n    <div className=\"flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]\">\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 24 24\"\n        width=\"72\"\n        height=\"72\"\n        fill=\"#BDBDBD\"\n      >\n        <path d=\"M17.657 14.8284L16.2428 13.4142L17.657 12C19.2191 10.4379 19.2191 7.90526 17.657 6.34316C16.0949 4.78106 13.5622 4.78106 12.0001 6.34316L10.5859 7.75737L9.17171 6.34316L10.5859 4.92895C12.9291 2.5858 16.7281 2.5858 19.0712 4.92895C21.4143 7.27209 21.4143 11.0711 19.0712 13.4142L17.657 14.8284ZM14.8286 17.6569L13.4143 19.0711C11.0712 21.4142 7.27221 21.4142 4.92907 19.0711C2.58592 16.7279 2.58592 12.9289 4.92907 10.5858L6.34328 9.17159L7.75749 10.5858L6.34328 12C4.78118 13.5621 4.78118 16.0948 6.34328 17.6569C7.90538 19.219 10.438 19.219 12.0001 17.6569L13.4143 16.2427L14.8286 17.6569ZM14.8286 7.75737L16.2428 9.17159L9.17171 16.2427L7.75749 14.8284L14.8286 7.75737ZM5.77539 2.29291L7.70724 1.77527L8.74252 5.63897L6.81067 6.15661L5.77539 2.29291ZM15.2578 18.3611L17.1896 17.8434L18.2249 21.7071L16.293 22.2248L15.2578 18.3611ZM2.29303 5.77527L6.15673 6.81054L5.63909 8.7424L1.77539 7.70712L2.29303 5.77527ZM18.3612 15.2576L22.2249 16.2929L21.7072 18.2248L17.8435 17.1895L18.3612 15.2576Z\"></path>\n      </svg>\n\n      <h2 className=\"text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-2\">\n        {t('plugins.connectionError')}\n      </h2>\n      <p className=\"text-gray-500 dark:text-gray-400 max-w-md mb-4\">\n        {t('plugins.connectionErrorDesc')}\n      </p>\n    </div>\n  );\n\n  const renderLoadingState = () => (\n    <div className=\"flex flex-col items-center justify-center h-[60vh] pt-[10vh]\">\n      <p className=\"text-gray-500 dark:text-gray-400\">\n        {t('plugins.loadingStatus')}\n      </p>\n    </div>\n  );\n\n  if (statusLoading) {\n    return renderLoadingState();\n  }\n\n  if (!pluginSystemStatus?.is_enable) {\n    return renderPluginDisabledState();\n  }\n\n  if (!pluginSystemStatus?.is_connected) {\n    return renderPluginConnectionErrorState();\n  }\n\n  return (\n    <div\n      className={`${styles.pageContainer} h-full flex flex-col ${\n        isDragOver ? 'bg-blue-50' : ''\n      }`}\n      onDragOver={handleDragOver}\n      onDragLeave={handleDragLeave}\n      onDrop={handleDrop}\n    >\n      <input\n        ref={fileInputRef}\n        type=\"file\"\n        accept=\".lbpkg,.zip\"\n        onChange={handleFileChange}\n        style={{ display: 'none' }}\n      />\n      <Tabs\n        value={activeTab}\n        onValueChange={setActiveTab}\n        className=\"w-full h-full flex flex-col\"\n      >\n        <div className=\"flex flex-row justify-between items-center px-[0.8rem] flex-shrink-0\">\n          <TabsList className=\"shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]\">\n            <TabsTrigger value=\"installed\" className=\"px-6 py-4 cursor-pointer\">\n              {t('plugins.installed')}\n            </TabsTrigger>\n            {systemInfo.enable_marketplace && (\n              <TabsTrigger value=\"market\" className=\"px-6 py-4 cursor-pointer\">\n                {t('plugins.marketplace')}\n              </TabsTrigger>\n            )}\n            <TabsTrigger\n              value=\"mcp-servers\"\n              className=\"px-6 py-4 cursor-pointer\"\n            >\n              {t('mcp.title')}\n            </TabsTrigger>\n          </TabsList>\n\n          <div className=\"flex flex-row justify-end items-center gap-2\">\n            {activeTab === 'installed' && (\n              <Popover\n                open={debugPopoverOpen}\n                onOpenChange={setDebugPopoverOpen}\n              >\n                <PopoverTrigger asChild>\n                  <Button\n                    variant=\"outline\"\n                    className=\"px-4 py-5 cursor-pointer\"\n                    onClick={handleShowDebugInfo}\n                  >\n                    <Code className=\"w-4 h-4 mr-2\" />\n                    {t('plugins.debugInfo')}\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent className=\"w-[380px]\" align=\"end\">\n                  <div className=\"space-y-3\">\n                    {/* Header with icon and title */}\n                    <div className=\"flex items-center gap-2 pb-2 border-b\">\n                      <Bug className=\"w-4 h-4\" />\n                      <h4 className=\"font-semibold text-sm\">\n                        {t('plugins.debugInfoTitle')}\n                      </h4>\n                    </div>\n\n                    {/* Debug URL row */}\n                    <div className=\"flex items-center gap-2\">\n                      <label className=\"text-sm font-medium whitespace-nowrap min-w-[50px]\">\n                        {t('plugins.debugUrl')}:\n                      </label>\n                      <Input\n                        value={debugInfo?.debug_url || ''}\n                        readOnly\n                        className=\"w-[220px] font-mono text-xs h-8\"\n                      />\n                      <Button\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        className=\"h-8 w-8 shrink-0\"\n                        onClick={() =>\n                          handleCopyDebugInfo(debugInfo?.debug_url || '', 'url')\n                        }\n                      >\n                        {copiedDebugUrl ? (\n                          <Check className=\"w-3.5 h-3.5 text-green-600\" />\n                        ) : (\n                          <Copy className=\"w-3.5 h-3.5\" />\n                        )}\n                      </Button>\n                    </div>\n\n                    {/* Debug Key row */}\n                    <div className=\"space-y-1\">\n                      <div className=\"flex items-center gap-2\">\n                        <label className=\"text-sm font-medium whitespace-nowrap min-w-[50px]\">\n                          {t('plugins.debugKey')}:\n                        </label>\n                        <Input\n                          value={\n                            debugInfo?.plugin_debug_key ||\n                            t('plugins.noDebugKey')\n                          }\n                          readOnly\n                          className=\"w-[220px] font-mono text-xs h-8\"\n                        />\n                        <Button\n                          variant=\"ghost\"\n                          size=\"icon\"\n                          className=\"h-8 w-8 shrink-0\"\n                          onClick={() =>\n                            handleCopyDebugInfo(\n                              debugInfo?.plugin_debug_key || '',\n                              'key',\n                            )\n                          }\n                          disabled={!debugInfo?.plugin_debug_key}\n                        >\n                          {copiedDebugKey ? (\n                            <Check className=\"w-3.5 h-3.5 text-green-600\" />\n                          ) : (\n                            <Copy className=\"w-3.5 h-3.5\" />\n                          )}\n                        </Button>\n                      </div>\n                      {!debugInfo?.plugin_debug_key && (\n                        <p className=\"text-xs text-muted-foreground ml-[58px]\">\n                          {t('plugins.debugKeyDisabled')}\n                        </p>\n                      )}\n                    </div>\n                  </div>\n                </PopoverContent>\n              </Popover>\n            )}\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <Button variant=\"default\" className=\"px-6 py-4 cursor-pointer\">\n                  <PlusIcon className=\"w-4 h-4\" />\n                  {activeTab === 'mcp-servers'\n                    ? t('mcp.add')\n                    : t('plugins.install')}\n                  <ChevronDownIcon className=\"ml-2 w-4 h-4\" />\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"end\">\n                {activeTab === 'mcp-servers' ? (\n                  <>\n                    <DropdownMenuItem\n                      onClick={async () => {\n                        if (!(await checkExtensionsLimit())) return;\n                        setActiveTab('mcp-servers');\n                        setIsEditMode(false);\n                        setEditingServerName(null);\n                        setMcpSSEModalOpen(true);\n                      }}\n                    >\n                      <PlusIcon className=\"w-4 h-4\" />\n                      {t('mcp.createServer')}\n                    </DropdownMenuItem>\n                  </>\n                ) : (\n                  <>\n                    {systemInfo.enable_marketplace && (\n                      <DropdownMenuItem\n                        onClick={() => {\n                          setActiveTab('market');\n                        }}\n                      >\n                        <StoreIcon className=\"w-4 h-4\" />\n                        {t('plugins.marketplace')}\n                      </DropdownMenuItem>\n                    )}\n                    <DropdownMenuItem onClick={handleFileSelect}>\n                      <UploadIcon className=\"w-4 h-4\" />\n                      {t('plugins.uploadLocal')}\n                    </DropdownMenuItem>\n                    <DropdownMenuItem\n                      onClick={async () => {\n                        if (!(await checkExtensionsLimit())) return;\n                        setInstallSource('github');\n                        setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);\n                        setInstallError(null);\n                        resetGithubState();\n                        setModalOpen(true);\n                      }}\n                    >\n                      <Github className=\"w-4 h-4\" />\n                      {t('plugins.installFromGithub')}\n                    </DropdownMenuItem>\n                  </>\n                )}\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </div>\n        </div>\n        <TabsContent value=\"installed\" className=\"flex-1 overflow-y-auto mt-0\">\n          <PluginInstalledComponent ref={pluginInstalledRef} />\n        </TabsContent>\n        <TabsContent value=\"market\" className=\"flex-1 overflow-y-auto mt-0\">\n          <MarketPage\n            installPlugin={async (plugin: PluginV4) => {\n              if (!(await checkExtensionsLimit())) return;\n              setInstallSource('marketplace');\n              setInstallInfo({\n                plugin_author: plugin.author,\n                plugin_name: plugin.name,\n                plugin_version: plugin.latest_version,\n              });\n              setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);\n              setModalOpen(true);\n            }}\n          />\n        </TabsContent>\n        <TabsContent\n          value=\"mcp-servers\"\n          className=\"flex-1 overflow-y-auto mt-0\"\n        >\n          <MCPServerComponent\n            key={refreshKey}\n            onEditServer={(serverName) => {\n              setEditingServerName(serverName);\n              setIsEditMode(true);\n              setMcpSSEModalOpen(true);\n            }}\n          />\n        </TabsContent>\n      </Tabs>\n\n      <Dialog\n        open={modalOpen}\n        onOpenChange={(open) => {\n          setModalOpen(open);\n          if (!open) {\n            resetGithubState();\n            setInstallError(null);\n          }\n        }}\n      >\n        <DialogContent className=\"w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto\">\n          <DialogHeader>\n            <DialogTitle className=\"flex items-center gap-4\">\n              {installSource === 'github' ? (\n                <Github className=\"size-6\" />\n              ) : (\n                <Download className=\"size-6\" />\n              )}\n              <span>{t('plugins.installPlugin')}</span>\n            </DialogTitle>\n          </DialogHeader>\n\n          {/* GitHub Install Flow */}\n          {installSource === 'github' &&\n            pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (\n              <div className=\"mt-4\">\n                <p className=\"mb-2\">{t('plugins.enterRepoUrl')}</p>\n                <Input\n                  placeholder={t('plugins.repoUrlPlaceholder')}\n                  value={githubURL}\n                  onChange={(e) => setGithubURL(e.target.value)}\n                  className=\"mb-4\"\n                />\n                {fetchingReleases && (\n                  <p className=\"text-sm text-gray-500\">\n                    {t('plugins.fetchingReleases')}\n                  </p>\n                )}\n              </div>\n            )}\n\n          {installSource === 'github' &&\n            pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && (\n              <div className=\"mt-4\">\n                <div className=\"flex items-center justify-between mb-4\">\n                  <p className=\"font-medium\">{t('plugins.selectRelease')}</p>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => {\n                      setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);\n                      setGithubReleases([]);\n                    }}\n                  >\n                    <ChevronLeft className=\"w-4 h-4 mr-1\" />\n                    {t('plugins.backToRepoUrl')}\n                  </Button>\n                </div>\n                <div className=\"max-h-[400px] overflow-y-auto space-y-2 pb-2\">\n                  {githubReleases.map((release) => (\n                    <Card\n                      key={release.id}\n                      className=\"cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-4\"\n                      onClick={() => handleReleaseSelect(release)}\n                    >\n                      <CardHeader className=\"flex flex-row items-start justify-between px-3 space-y-0\">\n                        <div className=\"flex-1\">\n                          <CardTitle className=\"text-sm\">\n                            {release.name || release.tag_name}\n                          </CardTitle>\n                          <CardDescription className=\"text-xs mt-1\">\n                            {t('plugins.releaseTag', { tag: release.tag_name })}{' '}\n                            •{' '}\n                            {t('plugins.publishedAt', {\n                              date: new Date(\n                                release.published_at,\n                              ).toLocaleDateString(),\n                            })}\n                          </CardDescription>\n                        </div>\n                        {release.prerelease && (\n                          <span className=\"text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded ml-2 shrink-0\">\n                            {t('plugins.prerelease')}\n                          </span>\n                        )}\n                      </CardHeader>\n                    </Card>\n                  ))}\n                </div>\n                {fetchingAssets && (\n                  <p className=\"text-sm text-gray-500 mt-4\">\n                    {t('plugins.loading')}\n                  </p>\n                )}\n              </div>\n            )}\n\n          {installSource === 'github' &&\n            pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && (\n              <div className=\"mt-4\">\n                <div className=\"flex items-center justify-between mb-4\">\n                  <p className=\"font-medium\">{t('plugins.selectAsset')}</p>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => {\n                      setPluginInstallStatus(\n                        PluginInstallStatus.SELECT_RELEASE,\n                      );\n                      setGithubAssets([]);\n                      setSelectedAsset(null);\n                    }}\n                  >\n                    <ChevronLeft className=\"w-4 h-4 mr-1\" />\n                    {t('plugins.backToReleases')}\n                  </Button>\n                </div>\n                {selectedRelease && (\n                  <div className=\"mb-4 p-2 bg-gray-50 dark:bg-gray-900 rounded\">\n                    <div className=\"text-sm font-medium\">\n                      {selectedRelease.name || selectedRelease.tag_name}\n                    </div>\n                    <div className=\"text-xs text-gray-500\">\n                      {selectedRelease.tag_name}\n                    </div>\n                  </div>\n                )}\n                <div className=\"max-h-[400px] overflow-y-auto space-y-2 pb-2\">\n                  {githubAssets.map((asset) => (\n                    <Card\n                      key={asset.id}\n                      className=\"cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-3\"\n                      onClick={() => handleAssetSelect(asset)}\n                    >\n                      <CardHeader className=\"px-3\">\n                        <CardTitle className=\"text-sm\">{asset.name}</CardTitle>\n                        <CardDescription className=\"text-xs\">\n                          {t('plugins.assetSize', {\n                            size: formatFileSize(asset.size),\n                          })}\n                        </CardDescription>\n                      </CardHeader>\n                    </Card>\n                  ))}\n                </div>\n              </div>\n            )}\n\n          {/* Marketplace Install Confirm */}\n          {installSource === 'marketplace' &&\n            pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (\n              <div className=\"mt-4\">\n                <p className=\"mb-2\">\n                  {t('plugins.askConfirm', {\n                    name: installInfo.plugin_name,\n                    version: installInfo.plugin_version,\n                  })}\n                </p>\n              </div>\n            )}\n\n          {/* GitHub Install Confirm */}\n          {installSource === 'github' &&\n            pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (\n              <div className=\"mt-4\">\n                <div className=\"flex items-center justify-between mb-4\">\n                  <p className=\"font-medium\">{t('plugins.confirmInstall')}</p>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => {\n                      setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);\n                      setSelectedAsset(null);\n                    }}\n                  >\n                    <ChevronLeft className=\"w-4 h-4 mr-1\" />\n                    {t('plugins.backToAssets')}\n                  </Button>\n                </div>\n                {selectedRelease && selectedAsset && (\n                  <div className=\"p-3 bg-gray-50 dark:bg-gray-900 rounded space-y-2\">\n                    <div>\n                      <span className=\"text-sm font-medium\">Repository: </span>\n                      <span className=\"text-sm\">\n                        {githubOwner}/{githubRepo}\n                      </span>\n                    </div>\n                    <div>\n                      <span className=\"text-sm font-medium\">Release: </span>\n                      <span className=\"text-sm\">\n                        {selectedRelease.tag_name}\n                      </span>\n                    </div>\n                    <div>\n                      <span className=\"text-sm font-medium\">File: </span>\n                      <span className=\"text-sm\">{selectedAsset.name}</span>\n                    </div>\n                  </div>\n                )}\n              </div>\n            )}\n\n          {/* Installing State */}\n          {pluginInstallStatus === PluginInstallStatus.INSTALLING && (\n            <div className=\"mt-4\">\n              <p className=\"mb-2\">{t('plugins.installing')}</p>\n            </div>\n          )}\n\n          {/* Error State */}\n          {pluginInstallStatus === PluginInstallStatus.ERROR && (\n            <div className=\"mt-4\">\n              <p className=\"mb-2\">{t('plugins.installFailed')}</p>\n              <p className=\"mb-2 text-red-500\">{installError}</p>\n            </div>\n          )}\n\n          <DialogFooter>\n            {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT &&\n              installSource === 'github' && (\n                <>\n                  <Button\n                    variant=\"outline\"\n                    onClick={() => {\n                      setModalOpen(false);\n                      resetGithubState();\n                    }}\n                  >\n                    {t('common.cancel')}\n                  </Button>\n                  <Button\n                    onClick={fetchGithubReleases}\n                    disabled={!githubURL.trim() || fetchingReleases}\n                  >\n                    {fetchingReleases\n                      ? t('plugins.loading')\n                      : t('common.confirm')}\n                  </Button>\n                </>\n              )}\n            {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (\n              <>\n                <Button variant=\"outline\" onClick={() => setModalOpen(false)}>\n                  {t('common.cancel')}\n                </Button>\n                <Button onClick={() => handleModalConfirm()}>\n                  {t('common.confirm')}\n                </Button>\n              </>\n            )}\n            {pluginInstallStatus === PluginInstallStatus.ERROR && (\n              <Button variant=\"default\" onClick={() => setModalOpen(false)}>\n                {t('common.close')}\n              </Button>\n            )}\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {isDragOver && (\n        <div className=\"fixed inset-0 bg-gray-500 bg-opacity-50 flex items-center justify-center z-50 pointer-events-none\">\n          <div className=\"bg-white rounded-lg p-8 shadow-lg border-2 border-dashed border-gray-500\">\n            <div className=\"text-center\">\n              <UploadIcon className=\"mx-auto h-12 w-12 text-gray-500 mb-4\" />\n              <p className=\"text-lg font-medium text-gray-700\">\n                {t('plugins.dragToUpload')}\n              </p>\n            </div>\n          </div>\n        </div>\n      )}\n\n      <MCPFormDialog\n        open={mcpSSEModalOpen}\n        onOpenChange={setMcpSSEModalOpen}\n        serverName={editingServerName}\n        isEditMode={isEditMode}\n        onSuccess={() => {\n          setEditingServerName(null);\n          setIsEditMode(false);\n          setRefreshKey((prev) => prev + 1);\n        }}\n        onDelete={() => {\n          setShowDeleteConfirmModal(true);\n        }}\n      />\n\n      <MCPDeleteConfirmDialog\n        open={showDeleteConfirmModal}\n        onOpenChange={setShowDeleteConfirmModal}\n        serverName={editingServerName}\n        onSuccess={() => {\n          setMcpSSEModalOpen(false);\n          setEditingServerName(null);\n          setIsEditMode(false);\n          setRefreshKey((prev) => prev + 1);\n        }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/home/plugins/plugins.module.css",
    "content": ".pageContainer {\n  width: 100%;\n}\n\n.marketComponentBody {\n  width: 100%;\n  height: calc(100% - 60px);\n}\n\n.pluginListContainer {\n  width: 100%;\n  padding-left: 0.8rem;\n  padding-right: 0.8rem;\n  padding-top: 2rem;\n  padding-bottom: 2rem;\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr));\n  gap: 2rem;\n  justify-items: stretch;\n  align-items: start;\n}\n"
  },
  {
    "path": "web/src/app/infra/basic-component/create-card-component/CreateCardComponent.tsx",
    "content": "import styles from './createCartComponent.module.css';\n\nexport default function CreateCardComponent({\n  height,\n  plusSize,\n  onClick,\n  width = '100%',\n}: {\n  height: string;\n  plusSize: string;\n  onClick: () => void;\n  width?: string;\n}) {\n  return (\n    <div\n      className={`${styles.cardContainer} ${styles.createCardContainer} `}\n      style={{\n        width: `${width}`,\n        height: `${height}`,\n        fontSize: `${plusSize}px`,\n      }}\n      onClick={onClick}\n    >\n      +\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/infra/basic-component/create-card-component/createCartComponent.module.css",
    "content": ".cardContainer {\n  background-color: #fff;\n  border-radius: 9px;\n  box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: space-evenly;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n:global(.dark) .cardContainer {\n  background-color: #1f1f22;\n  box-shadow: 0;\n}\n\n.cardContainer:hover {\n  box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.05);\n}\n\n:global(.dark) .cardContainer:hover {\n  box-shadow: 0;\n}\n\n.createCardContainer {\n  font-size: 90px;\n  color: #acacac;\n}\n\n:global(.dark) .createCardContainer {\n  color: #666666;\n}\n"
  },
  {
    "path": "web/src/app/infra/entities/api/index.ts",
    "content": "import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';\nimport { PipelineConfigTab } from '@/app/infra/entities/pipeline';\nimport { I18nObject } from '@/app/infra/entities/common';\nimport { Message } from '@/app/infra/entities/message';\nimport { Plugin, PluginV4 } from '@/app/infra/entities/plugin';\n\nexport interface ApiResponse<T> {\n  code: number;\n  data: T;\n  msg: string;\n}\n\nexport interface AsyncTaskCreatedResp {\n  task_id: number;\n}\n\nexport interface ApiRespProviderRequesters {\n  requesters: Requester[];\n}\n\nexport interface ApiRespProviderRequester {\n  requester: Requester;\n}\n\nexport interface Requester {\n  name: string;\n  label: I18nObject;\n  description: I18nObject;\n  icon?: string;\n  spec: {\n    config: IDynamicFormItemSchema[];\n    provider_category: string;\n  };\n}\n\nexport interface ApiRespProviderLLMModels {\n  models: LLMModel[];\n}\n\nexport interface ApiRespProviderLLMModel {\n  model: LLMModel;\n}\n\nexport interface ModelProvider {\n  uuid: string;\n  name: string;\n  requester: string;\n  base_url: string;\n  api_keys: string[];\n  llm_count?: number;\n  embedding_count?: number;\n  created_at?: string;\n  updated_at?: string;\n}\n\nexport interface ApiRespModelProviders {\n  providers: ModelProvider[];\n}\n\nexport interface ApiRespModelProvider {\n  provider: ModelProvider;\n}\n\nexport interface LLMModel {\n  uuid: string;\n  name: string;\n  provider_uuid: string;\n  provider?: ModelProvider;\n  abilities?: string[];\n  extra_args?: object;\n}\n\nexport interface ApiRespProviderEmbeddingModels {\n  models: EmbeddingModel[];\n}\n\nexport interface ApiRespProviderEmbeddingModel {\n  model: EmbeddingModel;\n}\n\nexport interface EmbeddingModel {\n  uuid: string;\n  name: string;\n  provider_uuid: string;\n  provider?: ModelProvider;\n  extra_args?: object;\n}\n\nexport interface ApiRespPipelines {\n  pipelines: Pipeline[];\n}\n\nexport interface Pipeline {\n  uuid?: string;\n  name: string;\n  description: string;\n  for_version?: string;\n  config: object;\n  stages?: string[];\n  is_default?: boolean;\n  created_at?: string;\n  updated_at?: string;\n  emoji?: string;\n}\n\nexport interface ApiRespPlatformAdapters {\n  adapters: Adapter[];\n}\n\nexport interface ApiRespPlatformAdapter {\n  adapter: Adapter;\n}\n\nexport interface Adapter {\n  name: string;\n  label: I18nObject;\n  description: I18nObject;\n  icon?: string;\n  spec: {\n    config: IDynamicFormItemSchema[];\n  };\n}\n\nexport interface ApiRespPlatformBots {\n  bots: Bot[];\n}\n\nexport interface ApiRespPlatformBot {\n  bot: Bot;\n}\n\nexport interface Bot {\n  uuid?: string;\n  name: string;\n  description: string;\n  enable?: boolean;\n  adapter: string;\n  adapter_config: object;\n  use_pipeline_name?: string;\n  use_pipeline_uuid?: string;\n  created_at?: string;\n  updated_at?: string;\n  adapter_runtime_values?: object;\n}\n\nexport interface ApiRespKnowledgeBases {\n  bases: KnowledgeBase[];\n}\n\nexport interface ApiRespKnowledgeBase {\n  base: KnowledgeBase;\n}\n\nexport interface KnowledgeBase {\n  uuid?: string;\n  name: string;\n  description: string;\n  created_at?: string;\n  updated_at?: string;\n  emoji?: string;\n  // New unified fields\n  knowledge_engine_plugin_id?: string;\n  creation_settings?: Record<string, unknown>;\n  retrieval_settings?: Record<string, unknown>;\n  knowledge_engine?: KnowledgeEngineInfo;\n}\n\n// Knowledge Engine types\nexport interface KnowledgeEngineInfo {\n  plugin_id: string | null;\n  name: I18nObject;\n  capabilities: string[];\n}\n\nexport interface KnowledgeEngine {\n  plugin_id: string;\n  name: I18nObject;\n  description?: I18nObject;\n  capabilities: string[];\n  // Schema format: Array of form field definitions (IDynamicFormItemSchema-like)\n  // Each item: { name, label, type, required, default, description?, options? }\n  creation_schema?: unknown[];\n  retrieval_schema?: unknown[];\n}\n\nexport interface ApiRespKnowledgeEngines {\n  engines: KnowledgeEngine[];\n}\n\nexport interface ParserInfo {\n  plugin_id: string;\n  name: I18nObject;\n  description?: I18nObject;\n  supported_mime_types: string[];\n}\n\nexport interface ApiRespParsers {\n  parsers: ParserInfo[];\n}\n\nexport interface ApiRespKnowledgeBaseFiles {\n  files: KnowledgeBaseFile[];\n}\n\nexport interface KnowledgeBaseFile {\n  uuid: string;\n  file_name: string;\n  status: string;\n}\n\n// plugins\nexport interface ApiRespPlugins {\n  plugins: Plugin[];\n}\n\nexport interface ApiRespPlugin {\n  plugin: Plugin;\n}\n\n// export interface Plugin {\n//   author: string;\n//   name: string;\n//   description: I18nLabel;\n//   label: I18nLabel;\n//   version: string;\n//   enabled: boolean;\n//   priority: number;\n//   status: string;\n//   tools: object[];\n//   event_handlers: object;\n//   main_file: string;\n//   pkg_path: string;\n//   repository: string;\n//   config_schema: IDynamicFormItemSchema[];\n// }\n\nexport interface ApiRespPluginConfig {\n  config: object;\n}\n\nexport interface PluginReorderElement {\n  author: string;\n  name: string;\n  priority: number;\n}\n\n// system\nexport interface SystemLimitation {\n  max_bots: number;\n  max_pipelines: number;\n  max_extensions: number;\n}\n\nexport interface ApiRespSystemInfo {\n  debug: boolean;\n  version: string;\n  edition: string;\n  cloud_service_url: string;\n  enable_marketplace: boolean;\n  allow_modify_login_info: boolean;\n  disable_models_service: boolean;\n  limitation: SystemLimitation;\n}\n\nexport interface RagMigrationStatusResp {\n  needed: boolean;\n  internal_kb_count: number;\n  external_kb_count: number;\n}\n\nexport interface ApiRespPluginSystemStatus {\n  is_enable: boolean;\n  is_connected: boolean;\n  plugin_connector_error: string;\n}\n\nexport interface ApiRespAsyncTasks {\n  tasks: AsyncTask[];\n}\n\nexport interface AsyncTaskRuntimeInfo {\n  done: boolean;\n  exception?: string;\n  result?: object;\n  state: string;\n}\n\nexport interface AsyncTaskTaskContext {\n  current_action: string;\n  log: string;\n}\n\nexport interface AsyncTask {\n  id: number;\n  kind: string;\n  name: string;\n  task_type: string; // system or user\n  runtime: AsyncTaskRuntimeInfo;\n  task_context: AsyncTaskTaskContext;\n}\n\nexport interface ApiRespUserToken {\n  token: string;\n}\n\nexport interface ApiRespMarketplacePlugins {\n  plugins: PluginV4[];\n  total: number;\n}\n\nexport interface ApiRespMarketplacePluginDetail {\n  plugin: PluginV4;\n}\n\ninterface GetPipelineConfig {\n  ai: object;\n  output: object;\n  safety: object;\n  trigger: object;\n}\n\ninterface GetPipeline {\n  config: GetPipelineConfig;\n  created_at: string;\n  description: string;\n  for_version: string;\n  is_default: boolean;\n  name: string;\n  stages: string[];\n  updated_at: string;\n  uuid: string;\n  emoji?: string;\n}\n\nexport interface GetPipelineResponseData {\n  pipeline: GetPipeline;\n}\n\nexport interface GetPipelineMetadataResponseData {\n  configs: PipelineConfigTab[];\n}\n\nexport interface ApiRespWebChatMessage {\n  message: Message;\n}\n\nexport interface ApiRespWebChatMessages {\n  messages: Message[];\n}\n\nexport interface RetrieveResultContent {\n  type: 'text' | 'image_url' | 'image_base64' | 'file_url';\n  text?: string;\n  file_name?: string;\n  file_url?: string;\n  image_url?: string;\n  image_base64?: string;\n}\n\nexport interface RetrieveResult {\n  id: string;\n  content?: RetrieveResultContent[];\n  metadata: {\n    file_id?: string;\n    text?: string;\n    uuid?: string;\n    [key: string]: unknown;\n  };\n  distance: number;\n}\n\nexport interface ApiRespKnowledgeBaseRetrieve {\n  results: RetrieveResult[];\n}\n\n// MCP\nexport interface ApiRespMCPServers {\n  servers: MCPServer[];\n}\n\nexport interface ApiRespMCPServer {\n  server: MCPServer;\n}\n\nexport interface MCPServerExtraArgsSSE {\n  url: string;\n  headers: Record<string, string>;\n  timeout: number;\n  ssereadtimeout: number;\n}\n\nexport interface MCPServerExtraArgsStdio {\n  command: string;\n  args: string[];\n  env: Record<string, string>;\n}\n\nexport interface MCPServerExtraArgsHttp {\n  url: string;\n  headers: Record<string, string>;\n  timeout: number;\n}\n\nexport enum MCPSessionStatus {\n  CONNECTING = 'connecting',\n  CONNECTED = 'connected',\n  ERROR = 'error',\n}\n\nexport interface MCPServerRuntimeInfo {\n  status: MCPSessionStatus;\n  error_message?: string;\n  tool_count: number;\n  tools: MCPTool[];\n}\n\nexport type MCPServer =\n  | {\n      uuid?: string;\n      name: string;\n      mode: 'sse';\n      enable: boolean;\n      extra_args: MCPServerExtraArgsSSE;\n      runtime_info?: MCPServerRuntimeInfo;\n      created_at?: string;\n      updated_at?: string;\n    }\n  | {\n      uuid?: string;\n      name: string;\n      mode: 'http';\n      enable: boolean;\n      extra_args: MCPServerExtraArgsHttp;\n      runtime_info?: MCPServerRuntimeInfo;\n      created_at?: string;\n      updated_at?: string;\n    }\n  | {\n      uuid?: string;\n      name: string;\n      mode: 'stdio';\n      enable: boolean;\n      extra_args: MCPServerExtraArgsStdio;\n      runtime_info?: MCPServerRuntimeInfo;\n      created_at?: string;\n      updated_at?: string;\n    };\n\nexport interface MCPTool {\n  name: string;\n  description: string;\n  parameters?: object;\n}\n"
  },
  {
    "path": "web/src/app/infra/entities/common.ts",
    "content": "export interface I18nObject {\n  en_US: string;\n  zh_Hans: string;\n  zh_Hant?: string;\n  ja_JP?: string;\n}\n\nexport interface ComponentManifest {\n  apiVersion: string;\n  kind: string;\n  metadata: {\n    name: string;\n    label: I18nObject;\n    description?: I18nObject;\n    icon?: string;\n    repository?: string;\n    version?: string;\n    author?: string;\n  };\n  spec: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any\n}\n\nexport interface CustomApiError {\n  msg?: string;\n}\n"
  },
  {
    "path": "web/src/app/infra/entities/form/dynamic.ts",
    "content": "import { I18nObject } from '@/app/infra/entities/common';\n\nexport interface IShowIfCondition {\n  field: string;\n  operator: 'eq' | 'neq' | 'in';\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  value: any;\n}\n\nexport interface IDynamicFormItemSchema {\n  id: string;\n  default: string | number | boolean | Array<unknown>;\n  label: I18nObject;\n  name: string;\n  required: boolean;\n  type: DynamicFormItemType;\n  description?: I18nObject;\n  options?: IDynamicFormItemOption[];\n  show_if?: IShowIfCondition;\n\n  /** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */\n  scopes?: string[];\n  accept?: string; // For file type: accepted MIME types\n}\n\nexport enum DynamicFormItemType {\n  INT = 'integer',\n  FLOAT = 'float',\n  BOOLEAN = 'boolean',\n  STRING = 'string',\n  TEXT = 'text',\n  STRING_ARRAY = 'array[string]',\n  FILE = 'file',\n  FILE_ARRAY = 'array[file]',\n  SELECT = 'select',\n  LLM_MODEL_SELECTOR = 'llm-model-selector',\n  EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',\n  MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',\n  PROMPT_EDITOR = 'prompt-editor',\n  UNKNOWN = 'unknown',\n  KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',\n  KNOWLEDGE_BASE_MULTI_SELECTOR = 'knowledge-base-multi-selector',\n  PLUGIN_SELECTOR = 'plugin-selector',\n  BOT_SELECTOR = 'bot-selector',\n}\n\nexport interface IFileConfig {\n  file_key: string;\n  mimetype: string;\n}\n\nexport interface IDynamicFormItemOption {\n  name: string;\n  label: I18nObject;\n}\n"
  },
  {
    "path": "web/src/app/infra/entities/message/index.ts",
    "content": "// Message component base interface\nexport interface MessageComponent {\n  type: string;\n}\n\n// Source component\nexport interface Source extends MessageComponent {\n  type: 'Source';\n  id: number | string;\n  timestamp: number;\n}\n\n// Plain text component\nexport interface Plain extends MessageComponent {\n  type: 'Plain';\n  text: string;\n}\n\n// Quote component\nexport interface Quote extends MessageComponent {\n  type: 'Quote';\n  id?: number;\n  group_id?: number | string;\n  sender_id?: number | string;\n  target_id?: number | string;\n  origin: MessageComponent[];\n}\n\n// At component\nexport interface At extends MessageComponent {\n  type: 'At';\n  target: number | string;\n  display?: string;\n}\n\n// AtAll component\nexport interface AtAll extends MessageComponent {\n  type: 'AtAll';\n}\n\n// Image component\nexport interface Image extends MessageComponent {\n  type: 'Image';\n  image_id?: string;\n  url?: string;\n  path?: string;\n  base64?: string;\n}\n\n// Voice component\nexport interface Voice extends MessageComponent {\n  type: 'Voice';\n  voice_id?: string;\n  url?: string;\n  path?: string;\n  base64?: string;\n  length?: number;\n}\n\n// File component\nexport interface File extends MessageComponent {\n  type: 'File';\n  id?: string;\n  name?: string;\n  size?: number;\n  url?: string;\n}\n\n// Unknown component\nexport interface Unknown extends MessageComponent {\n  type: 'Unknown';\n  text?: string;\n}\n\n// Forward message node\nexport interface ForwardMessageNode {\n  sender_id?: number | string;\n  sender_name?: string;\n  message_chain?: MessageComponent[];\n  message_id?: number;\n}\n\n// Forward message display\nexport interface ForwardMessageDisplay {\n  title?: string;\n  brief?: string;\n  source?: string;\n  preview?: string[];\n  summary?: string;\n}\n\n// Forward component\nexport interface Forward extends MessageComponent {\n  type: 'Forward';\n  display?: ForwardMessageDisplay;\n  node_list?: ForwardMessageNode[];\n}\n\n// WeChat specific components\nexport interface WeChatMiniPrograms extends MessageComponent {\n  type: 'WeChatMiniPrograms';\n  mini_app_id: string;\n  user_name: string;\n  display_name?: string;\n  page_path?: string;\n  title?: string;\n  image_url?: string;\n}\n\nexport interface WeChatEmoji extends MessageComponent {\n  type: 'WeChatEmoji';\n  emoji_md5: string;\n  emoji_size: number;\n}\n\nexport interface WeChatLink extends MessageComponent {\n  type: 'WeChatLink';\n  link_title?: string;\n  link_desc?: string;\n  link_url?: string;\n  link_thumb_url?: string;\n}\n\n// Union type for all message components\nexport type MessageChainComponent =\n  | Source\n  | Plain\n  | Quote\n  | At\n  | AtAll\n  | Image\n  | Voice\n  | File\n  | Unknown\n  | Forward\n  | WeChatMiniPrograms\n  | WeChatEmoji\n  | WeChatLink;\n\n// Message interface\nexport interface Message {\n  id: number;\n  role: 'user' | 'assistant';\n  content: string;\n  message_chain: MessageChainComponent[];\n  timestamp: string;\n  is_final?: boolean;\n}\n"
  },
  {
    "path": "web/src/app/infra/entities/pipeline/index.ts",
    "content": "import { I18nObject } from '@/app/infra/entities/common';\nimport { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';\n\nexport interface PipelineFormEntity {\n  basic: object;\n  ai: object;\n  trigger: object;\n  safety: object;\n  output: object;\n}\n\nexport interface PipelineConfigTab {\n  name: string;\n  label: I18nObject;\n  stages: PipelineConfigStage[];\n}\n\nexport interface PipelineConfigStage {\n  name: string;\n  label: I18nObject;\n  description?: I18nObject;\n  config: IDynamicFormItemSchema[];\n}\n"
  },
  {
    "path": "web/src/app/infra/entities/plugin/index.ts",
    "content": "import { ComponentManifest, I18nObject } from '@/app/infra/entities/common';\n\nexport interface Plugin {\n  status: 'intialized' | 'mounted' | 'unmounted';\n  priority: number;\n  plugin_config: object;\n  manifest: {\n    manifest: ComponentManifest;\n  };\n  debug: boolean;\n  enabled: boolean;\n  install_source: string;\n  install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any\n  components: PluginComponent[];\n}\n\nexport interface PluginComponent {\n  component_config: object;\n  manifest: {\n    manifest: ComponentManifest;\n  };\n}\n\n// marketplace plugin v4\nexport enum PluginV4Status {\n  Any = 'any',\n  Live = 'live',\n  Deleted = 'deleted',\n}\n\nexport interface PluginV4 {\n  id: number;\n  plugin_id: string;\n  author: string;\n  name: string;\n  label: I18nObject;\n  description: I18nObject;\n  icon: string;\n  repository: string;\n  tags: string[];\n  install_count: number;\n  latest_version: string;\n  components: Record<string, number>;\n  status: PluginV4Status;\n  created_at: string;\n  updated_at: string;\n}\n"
  },
  {
    "path": "web/src/app/infra/http/BackendClient.ts",
    "content": "import { BaseHttpClient } from './BaseHttpClient';\nimport {\n  ApiRespProviderRequesters,\n  ApiRespProviderRequester,\n  ApiRespProviderLLMModels,\n  ApiRespProviderLLMModel,\n  LLMModel,\n  ApiRespPipelines,\n  Pipeline,\n  ApiRespPlatformAdapters,\n  ApiRespPlatformAdapter,\n  ApiRespPlatformBots,\n  ApiRespPlatformBot,\n  Bot,\n  ApiRespPlugins,\n  ApiRespPlugin,\n  ApiRespPluginConfig,\n  AsyncTaskCreatedResp,\n  ApiRespSystemInfo,\n  ApiRespAsyncTasks,\n  ApiRespUserToken,\n  GetPipelineResponseData,\n  GetPipelineMetadataResponseData,\n  AsyncTask,\n  ApiRespWebChatMessages,\n  ApiRespKnowledgeBases,\n  ApiRespKnowledgeBase,\n  KnowledgeBase,\n  ApiRespKnowledgeBaseFiles,\n  ApiRespKnowledgeBaseRetrieve,\n  ApiRespProviderEmbeddingModels,\n  ApiRespProviderEmbeddingModel,\n  EmbeddingModel,\n  ApiRespPluginSystemStatus,\n  ApiRespMCPServers,\n  ApiRespMCPServer,\n  MCPServer,\n  ApiRespModelProviders,\n  ApiRespModelProvider,\n  ModelProvider,\n  ApiRespKnowledgeEngines,\n  ApiRespParsers,\n  RagMigrationStatusResp,\n} from '@/app/infra/entities/api';\nimport { Plugin } from '@/app/infra/entities/plugin';\nimport { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';\nimport { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';\n\n/**\n * 后端服务客户端\n * 负责与后端 API 的所有交互\n */\nexport class BackendClient extends BaseHttpClient {\n  constructor(baseURL: string) {\n    super(baseURL, false);\n  }\n\n  // ============ Provider API ============\n  public getProviderRequesters(\n    model_type?: string,\n  ): Promise<ApiRespProviderRequesters> {\n    return this.get('/api/v1/provider/requesters', { type: model_type });\n  }\n\n  public getProviderRequester(name: string): Promise<ApiRespProviderRequester> {\n    return this.get(`/api/v1/provider/requesters/${name}`);\n  }\n\n  public getProviderRequesterIconURL(name: string): string {\n    if (this.instance.defaults.baseURL === '/') {\n      const url = window.location.href;\n      const baseURL = url.split('/').slice(0, 3).join('/');\n      return `${baseURL}/api/v1/provider/requesters/${name}/icon`;\n    }\n    return (\n      this.instance.defaults.baseURL +\n      `/api/v1/provider/requesters/${name}/icon`\n    );\n  }\n\n  // ============ Model Providers ============\n  public getModelProviders(): Promise<ApiRespModelProviders> {\n    return this.get('/api/v1/provider/providers');\n  }\n\n  public getModelProvider(uuid: string): Promise<ApiRespModelProvider> {\n    return this.get(`/api/v1/provider/providers/${uuid}`);\n  }\n\n  public createModelProvider(\n    provider: Omit<ModelProvider, 'uuid'>,\n  ): Promise<{ uuid: string }> {\n    return this.post('/api/v1/provider/providers', provider);\n  }\n\n  public updateModelProvider(\n    uuid: string,\n    provider: Partial<ModelProvider>,\n  ): Promise<object> {\n    return this.put(`/api/v1/provider/providers/${uuid}`, provider);\n  }\n\n  public deleteModelProvider(uuid: string): Promise<object> {\n    return this.delete(`/api/v1/provider/providers/${uuid}`);\n  }\n\n  // ============ Provider Model LLM ============\n  public getProviderLLMModels(\n    providerUuid?: string,\n  ): Promise<ApiRespProviderLLMModels> {\n    const params = providerUuid ? { provider_uuid: providerUuid } : {};\n    return this.get('/api/v1/provider/models/llm', params);\n  }\n\n  public getProviderLLMModel(uuid: string): Promise<ApiRespProviderLLMModel> {\n    return this.get(`/api/v1/provider/models/llm/${uuid}`);\n  }\n\n  public createProviderLLMModel(model: LLMModel): Promise<object> {\n    return this.post('/api/v1/provider/models/llm', model);\n  }\n\n  public deleteProviderLLMModel(uuid: string): Promise<object> {\n    return this.delete(`/api/v1/provider/models/llm/${uuid}`);\n  }\n\n  public updateProviderLLMModel(\n    uuid: string,\n    model: LLMModel,\n  ): Promise<object> {\n    return this.put(`/api/v1/provider/models/llm/${uuid}`, model);\n  }\n\n  public testLLMModel(uuid: string, model: LLMModel): Promise<object> {\n    return this.post(`/api/v1/provider/models/llm/${uuid}/test`, model);\n  }\n\n  // ============ Provider Model Embedding ============\n  public getProviderEmbeddingModels(\n    providerUuid?: string,\n  ): Promise<ApiRespProviderEmbeddingModels> {\n    const params = providerUuid ? { provider_uuid: providerUuid } : {};\n    return this.get('/api/v1/provider/models/embedding', params);\n  }\n\n  public getProviderEmbeddingModel(\n    uuid: string,\n  ): Promise<ApiRespProviderEmbeddingModel> {\n    return this.get(`/api/v1/provider/models/embedding/${uuid}`);\n  }\n\n  public createProviderEmbeddingModel(model: EmbeddingModel): Promise<object> {\n    return this.post('/api/v1/provider/models/embedding', model);\n  }\n\n  public deleteProviderEmbeddingModel(uuid: string): Promise<object> {\n    return this.delete(`/api/v1/provider/models/embedding/${uuid}`);\n  }\n\n  public updateProviderEmbeddingModel(\n    uuid: string,\n    model: EmbeddingModel,\n  ): Promise<object> {\n    return this.put(`/api/v1/provider/models/embedding/${uuid}`, model);\n  }\n\n  public testEmbeddingModel(\n    uuid: string,\n    model: EmbeddingModel,\n  ): Promise<object> {\n    return this.post(`/api/v1/provider/models/embedding/${uuid}/test`, model);\n  }\n\n  // ============ Pipeline API ============\n  public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {\n    // as designed, this method will be deprecated, and only for developer to check the prefered config schema\n    return this.get('/api/v1/pipelines/_/metadata');\n  }\n\n  public getPipelines(\n    sortBy?: string,\n    sortOrder?: string,\n  ): Promise<ApiRespPipelines> {\n    const params = new URLSearchParams();\n    if (sortBy) params.append('sort_by', sortBy);\n    if (sortOrder) params.append('sort_order', sortOrder);\n    const queryString = params.toString();\n    return this.get(`/api/v1/pipelines${queryString ? `?${queryString}` : ''}`);\n  }\n\n  public getPipeline(uuid: string): Promise<GetPipelineResponseData> {\n    return this.get(`/api/v1/pipelines/${uuid}`);\n  }\n\n  public createPipeline(pipeline: Pipeline): Promise<{\n    uuid: string;\n  }> {\n    return this.post('/api/v1/pipelines', pipeline);\n  }\n\n  public updatePipeline(uuid: string, pipeline: Pipeline): Promise<object> {\n    return this.put(`/api/v1/pipelines/${uuid}`, pipeline);\n  }\n\n  public deletePipeline(uuid: string): Promise<object> {\n    return this.delete(`/api/v1/pipelines/${uuid}`);\n  }\n\n  public copyPipeline(uuid: string): Promise<{ uuid: string }> {\n    return this.post(`/api/v1/pipelines/${uuid}/copy`);\n  }\n\n  public getPipelineExtensions(uuid: string): Promise<{\n    enable_all_plugins: boolean;\n    enable_all_mcp_servers: boolean;\n    bound_plugins: Array<{ author: string; name: string }>;\n    available_plugins: Plugin[];\n    bound_mcp_servers: string[];\n    available_mcp_servers: MCPServer[];\n  }> {\n    return this.get(`/api/v1/pipelines/${uuid}/extensions`);\n  }\n\n  public updatePipelineExtensions(\n    uuid: string,\n    bound_plugins: Array<{ author: string; name: string }>,\n    bound_mcp_servers: string[],\n    enable_all_plugins: boolean = true,\n    enable_all_mcp_servers: boolean = true,\n  ): Promise<object> {\n    return this.put(`/api/v1/pipelines/${uuid}/extensions`, {\n      bound_plugins,\n      bound_mcp_servers,\n      enable_all_plugins,\n      enable_all_mcp_servers,\n    });\n  }\n\n  // ============ WebSocket Chat API ============\n  public getWebSocketHistoryMessages(\n    pipelineId: string,\n    sessionType: string,\n  ): Promise<ApiRespWebChatMessages> {\n    return this.get(\n      `/api/v1/pipelines/${pipelineId}/ws/messages/${sessionType}`,\n    );\n  }\n\n  public async uploadWebSocketImage(\n    pipelineId: string,\n    imageFile: File,\n  ): Promise<{ file_key: string }> {\n    const formData = new FormData();\n    formData.append('file', imageFile);\n\n    return this.postFile(`/api/v1/files/images`, formData);\n  }\n\n  public resetWebSocketSession(\n    pipelineId: string,\n    sessionType: string,\n  ): Promise<{ message: string }> {\n    return this.post(`/api/v1/pipelines/${pipelineId}/ws/reset/${sessionType}`);\n  }\n\n  public getWebSocketConnections(pipelineId: string): Promise<{\n    stats: {\n      total_connections: number;\n      pipelines: number;\n      connections_by_pipeline: Record<string, number>;\n      connections_by_session_type: Record<string, number>;\n    };\n    connections: Array<{\n      connection_id: string;\n      session_type: string;\n      created_at: string;\n      last_active: string;\n      is_active: boolean;\n    }>;\n  }> {\n    return this.get(`/api/v1/pipelines/${pipelineId}/ws/connections`);\n  }\n\n  public broadcastWebSocketMessage(\n    pipelineId: string,\n    message: string,\n  ): Promise<{ message: string }> {\n    return this.post(`/api/v1/pipelines/${pipelineId}/ws/broadcast`, {\n      message,\n    });\n  }\n\n  // ============ Platform API ============\n  public getAdapters(): Promise<ApiRespPlatformAdapters> {\n    return this.get('/api/v1/platform/adapters');\n  }\n\n  public getAdapter(name: string): Promise<ApiRespPlatformAdapter> {\n    return this.get(`/api/v1/platform/adapters/${name}`);\n  }\n\n  public getAdapterIconURL(name: string): string {\n    if (this.instance.defaults.baseURL === '/') {\n      // 获取用户访问的URL\n      const url = window.location.href;\n      const baseURL = url.split('/').slice(0, 3).join('/');\n      return `${baseURL}/api/v1/platform/adapters/${name}/icon`;\n    }\n    return (\n      this.instance.defaults.baseURL + `/api/v1/platform/adapters/${name}/icon`\n    );\n  }\n\n  // ============ Platform Bots ============\n  public getBots(): Promise<ApiRespPlatformBots> {\n    return this.get('/api/v1/platform/bots');\n  }\n\n  public getBot(uuid: string): Promise<ApiRespPlatformBot> {\n    return this.get(`/api/v1/platform/bots/${uuid}`);\n  }\n\n  public createBot(bot: Bot): Promise<{ uuid: string }> {\n    return this.post('/api/v1/platform/bots', bot);\n  }\n\n  public updateBot(uuid: string, bot: Bot): Promise<object> {\n    return this.put(`/api/v1/platform/bots/${uuid}`, bot);\n  }\n\n  public deleteBot(uuid: string): Promise<object> {\n    return this.delete(`/api/v1/platform/bots/${uuid}`);\n  }\n\n  public getBotLogs(\n    botId: string,\n    request: GetBotLogsRequest,\n  ): Promise<GetBotLogsResponse> {\n    return this.post(`/api/v1/platform/bots/${botId}/logs`, request);\n  }\n\n  public getBotSessions(\n    botId: string,\n    limit: number = 100,\n    offset: number = 0,\n  ): Promise<{\n    sessions: Array<{\n      session_id: string;\n      bot_id: string;\n      bot_name: string;\n      pipeline_id: string;\n      pipeline_name: string;\n      message_count: number;\n      start_time: string;\n      last_activity: string;\n      is_active: boolean;\n      platform: string | null;\n      user_id: string | null;\n      user_name: string | null;\n    }>;\n    total: number;\n  }> {\n    const queryParams = new URLSearchParams();\n    queryParams.append('botId', botId);\n    queryParams.append('limit', limit.toString());\n    queryParams.append('offset', offset.toString());\n    return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);\n  }\n\n  public getSessionMessages(\n    sessionId: string,\n    limit: number = 200,\n    offset: number = 0,\n  ): Promise<{\n    messages: Array<{\n      id: string;\n      timestamp: string;\n      bot_id: string;\n      bot_name: string;\n      pipeline_id: string;\n      pipeline_name: string;\n      message_content: string;\n      session_id: string;\n      status: string;\n      level: string;\n      platform: string | null;\n      user_id: string | null;\n      user_name: string | null;\n      runner_name: string | null;\n      variables: string | null;\n      role: string | null;\n    }>;\n    total: number;\n  }> {\n    const queryParams = new URLSearchParams();\n    queryParams.append('sessionId', sessionId);\n    queryParams.append('limit', limit.toString());\n    queryParams.append('offset', offset.toString());\n    return this.get(`/api/v1/monitoring/messages?${queryParams.toString()}`);\n  }\n\n  // ============ File management API ============\n  public uploadDocumentFile(file: File): Promise<{ file_id: string }> {\n    const formData = new FormData();\n    formData.append('file', file);\n\n    return this.request<{ file_id: string }>({\n      method: 'post',\n      url: '/api/v1/files/documents',\n      data: formData,\n      headers: {\n        'Content-Type': 'multipart/form-data',\n      },\n    });\n  }\n\n  // ============ Knowledge Base API ============\n  public getKnowledgeBases(): Promise<ApiRespKnowledgeBases> {\n    return this.get('/api/v1/knowledge/bases');\n  }\n\n  public getKnowledgeBase(uuid: string): Promise<ApiRespKnowledgeBase> {\n    return this.get(`/api/v1/knowledge/bases/${uuid}`);\n  }\n\n  public createKnowledgeBase(base: KnowledgeBase): Promise<{ uuid: string }> {\n    return this.post('/api/v1/knowledge/bases', base);\n  }\n\n  public updateKnowledgeBase(\n    uuid: string,\n    base: KnowledgeBase,\n  ): Promise<{ uuid: string }> {\n    return this.put(`/api/v1/knowledge/bases/${uuid}`, base);\n  }\n\n  public uploadKnowledgeBaseFile(\n    uuid: string,\n    file_id: string,\n    parserPluginId?: string,\n  ): Promise<object> {\n    return this.post(`/api/v1/knowledge/bases/${uuid}/files`, {\n      file_id,\n      parser_plugin_id: parserPluginId,\n    });\n  }\n\n  public getKnowledgeBaseFiles(\n    uuid: string,\n  ): Promise<ApiRespKnowledgeBaseFiles> {\n    return this.get(`/api/v1/knowledge/bases/${uuid}/files`);\n  }\n\n  public deleteKnowledgeBaseFile(\n    uuid: string,\n    file_id: string,\n  ): Promise<object> {\n    return this.delete(`/api/v1/knowledge/bases/${uuid}/files/${file_id}`);\n  }\n\n  public deleteKnowledgeBase(uuid: string): Promise<object> {\n    return this.delete(`/api/v1/knowledge/bases/${uuid}`);\n  }\n\n  public retrieveKnowledgeBase(\n    uuid: string,\n    query: string,\n    retrievalSettings?: Record<string, unknown>,\n  ): Promise<ApiRespKnowledgeBaseRetrieve> {\n    return this.post(`/api/v1/knowledge/bases/${uuid}/retrieve`, {\n      query,\n      retrieval_settings: retrievalSettings ?? {},\n    });\n  }\n\n  // ============ Knowledge Engines API ============\n  public getKnowledgeEngines(): Promise<ApiRespKnowledgeEngines> {\n    return this.get('/api/v1/knowledge/engines');\n  }\n\n  // ============ Parsers API ============\n  public listParsers(mimeType?: string): Promise<ApiRespParsers> {\n    const params = mimeType ? `?mime_type=${encodeURIComponent(mimeType)}` : '';\n    return this.get(`/api/v1/knowledge/parsers${params}`);\n  }\n\n  // ============ Plugins API ============\n  public getPlugins(): Promise<ApiRespPlugins> {\n    return this.get('/api/v1/plugins');\n  }\n\n  public getPlugin(author: string, name: string): Promise<ApiRespPlugin> {\n    return this.get(`/api/v1/plugins/${author}/${name}`);\n  }\n\n  public getPluginConfig(\n    author: string,\n    name: string,\n  ): Promise<ApiRespPluginConfig> {\n    return this.get(`/api/v1/plugins/${author}/${name}/config`);\n  }\n\n  public updatePluginConfig(\n    author: string,\n    name: string,\n    config: object,\n  ): Promise<object> {\n    return this.put(`/api/v1/plugins/${author}/${name}/config`, config);\n  }\n\n  public uploadPluginConfigFile(file: File): Promise<{ file_key: string }> {\n    const formData = new FormData();\n    formData.append('file', file);\n\n    return this.request<{ file_key: string }>({\n      method: 'post',\n      url: '/api/v1/plugins/config-files',\n      data: formData,\n      headers: {\n        'Content-Type': 'multipart/form-data',\n      },\n    });\n  }\n\n  public deletePluginConfigFile(\n    fileKey: string,\n  ): Promise<{ deleted: boolean }> {\n    return this.delete(`/api/v1/plugins/config-files/${fileKey}`);\n  }\n\n  public getPluginReadme(\n    author: string,\n    name: string,\n    language: string = 'en',\n  ): Promise<{ readme: string }> {\n    return this.get(\n      `/api/v1/plugins/${author}/${name}/readme?language=${language}`,\n    );\n  }\n\n  public getPluginAssetURL(\n    author: string,\n    name: string,\n    filepath: string,\n  ): string {\n    return (\n      this.instance.defaults.baseURL +\n      `/api/v1/plugins/${author}/${name}/assets/${filepath}`\n    );\n  }\n\n  public getPluginIconURL(author: string, name: string): string {\n    if (this.instance.defaults.baseURL === '/') {\n      const url = window.location.href;\n      const baseURL = url.split('/').slice(0, 3).join('/');\n      return `${baseURL}/api/v1/plugins/${author}/${name}/icon`;\n    }\n    return (\n      this.instance.defaults.baseURL + `/api/v1/plugins/${author}/${name}/icon`\n    );\n  }\n\n  public installPluginFromGithub(\n    assetUrl: string,\n    owner: string,\n    repo: string,\n    releaseTag: string,\n  ): Promise<AsyncTaskCreatedResp> {\n    return this.post('/api/v1/plugins/install/github', {\n      asset_url: assetUrl,\n      owner,\n      repo,\n      release_tag: releaseTag,\n    });\n  }\n\n  public getGithubReleases(repoUrl: string): Promise<{\n    releases: Array<{\n      id: number;\n      tag_name: string;\n      name: string;\n      published_at: string;\n      prerelease: boolean;\n      draft: boolean;\n    }>;\n    owner: string;\n    repo: string;\n  }> {\n    return this.post('/api/v1/plugins/github/releases', { repo_url: repoUrl });\n  }\n\n  public getGithubReleaseAssets(\n    owner: string,\n    repo: string,\n    releaseId: number,\n  ): Promise<{\n    assets: Array<{\n      id: number;\n      name: string;\n      size: number;\n      download_url: string;\n      content_type: string;\n    }>;\n  }> {\n    return this.post('/api/v1/plugins/github/release-assets', {\n      owner,\n      repo,\n      release_id: releaseId,\n    });\n  }\n\n  public installPluginFromLocal(file: File): Promise<AsyncTaskCreatedResp> {\n    const formData = new FormData();\n    formData.append('file', file);\n    return this.postFile('/api/v1/plugins/install/local', formData);\n  }\n\n  public installPluginFromMarketplace(\n    author: string,\n    name: string,\n    version: string,\n  ): Promise<AsyncTaskCreatedResp> {\n    return this.post('/api/v1/plugins/install/marketplace', {\n      plugin_author: author,\n      plugin_name: name,\n      plugin_version: version,\n    });\n  }\n\n  public removePlugin(\n    author: string,\n    name: string,\n    deleteData: boolean = false,\n  ): Promise<AsyncTaskCreatedResp> {\n    return this.delete(\n      `/api/v1/plugins/${author}/${name}?delete_data=${deleteData}`,\n    );\n  }\n\n  public upgradePlugin(\n    author: string,\n    name: string,\n  ): Promise<AsyncTaskCreatedResp> {\n    return this.post(`/api/v1/plugins/${author}/${name}/upgrade`);\n  }\n\n  // ============ MCP API ============\n  public getMCPServers(): Promise<ApiRespMCPServers> {\n    return this.get('/api/v1/mcp/servers');\n  }\n\n  public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {\n    return this.get(`/api/v1/mcp/servers/${serverName}`);\n  }\n\n  public createMCPServer(server: MCPServer): Promise<AsyncTaskCreatedResp> {\n    return this.post('/api/v1/mcp/servers', server);\n  }\n\n  public updateMCPServer(\n    serverName: string,\n    server: Partial<MCPServer>,\n  ): Promise<AsyncTaskCreatedResp> {\n    return this.put(`/api/v1/mcp/servers/${serverName}`, server);\n  }\n\n  public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {\n    return this.delete(`/api/v1/mcp/servers/${serverName}`);\n  }\n\n  public toggleMCPServer(\n    serverName: string,\n    target_enabled: boolean,\n  ): Promise<AsyncTaskCreatedResp> {\n    return this.put(`/api/v1/mcp/servers/${serverName}`, {\n      enable: target_enabled,\n    });\n  }\n\n  public testMCPServer(\n    serverName: string,\n    serverData: object,\n  ): Promise<AsyncTaskCreatedResp> {\n    return this.post(`/api/v1/mcp/servers/${serverName}/test`, serverData);\n  }\n\n  public installMCPServerFromGithub(\n    source: string,\n  ): Promise<AsyncTaskCreatedResp> {\n    return this.post('/api/v1/mcp/install/github', { source });\n  }\n\n  public installMCPServerFromSSE(\n    source: object,\n  ): Promise<AsyncTaskCreatedResp> {\n    return this.post('/api/v1/mcp/servers', { source });\n  }\n\n  // ============ System API ============\n  public getSystemInfo(): Promise<ApiRespSystemInfo> {\n    return this.get('/api/v1/system/info');\n  }\n\n  public getAsyncTasks(): Promise<ApiRespAsyncTasks> {\n    return this.get('/api/v1/system/tasks');\n  }\n\n  public getAsyncTask(id: number): Promise<AsyncTask> {\n    return this.get(`/api/v1/system/tasks/${id}`);\n  }\n\n  public getPluginSystemStatus(): Promise<ApiRespPluginSystemStatus> {\n    return this.get('/api/v1/system/status/plugin-system');\n  }\n\n  // ============ RAG Migration API ============\n  public getRagMigrationStatus(): Promise<RagMigrationStatusResp> {\n    return this.get('/api/v1/knowledge/migration/status');\n  }\n\n  public executeRagMigration(\n    installPlugin: boolean = true,\n  ): Promise<AsyncTaskCreatedResp> {\n    return this.post('/api/v1/knowledge/migration/execute', {\n      install_plugin: installPlugin,\n    });\n  }\n\n  public dismissRagMigration(): Promise<object> {\n    return this.post('/api/v1/knowledge/migration/dismiss');\n  }\n\n  public getPluginDebugInfo(): Promise<{\n    debug_url: string;\n    plugin_debug_key: string;\n  }> {\n    return this.get('/api/v1/plugins/debug-info');\n  }\n\n  // ============ User API ============\n  public checkIfInited(): Promise<{ initialized: boolean }> {\n    return this.get('/api/v1/user/init');\n  }\n\n  public initUser(user: string, password: string): Promise<object> {\n    return this.post('/api/v1/user/init', { user, password });\n  }\n\n  public authUser(user: string, password: string): Promise<ApiRespUserToken> {\n    return this.post('/api/v1/user/auth', { user, password });\n  }\n\n  public checkUserToken(): Promise<ApiRespUserToken> {\n    return this.get('/api/v1/user/check-token');\n  }\n\n  public resetPassword(\n    user: string,\n    recoveryKey: string,\n    newPassword: string,\n  ): Promise<{ user: string }> {\n    return this.post('/api/v1/user/reset-password', {\n      user,\n      recovery_key: recoveryKey,\n      new_password: newPassword,\n    });\n  }\n\n  public changePassword(\n    currentPassword: string,\n    newPassword: string,\n  ): Promise<{ user: string }> {\n    return this.post('/api/v1/user/change-password', {\n      current_password: currentPassword,\n      new_password: newPassword,\n    });\n  }\n\n  public getUserInfo(): Promise<{\n    user: string;\n    account_type: 'local' | 'space';\n    has_password: boolean;\n  }> {\n    return this.get('/api/v1/user/info');\n  }\n\n  public getSpaceCredits(): Promise<{ credits: number | null }> {\n    return this.get('/api/v1/user/space-credits');\n  }\n\n  public getAccountInfo(): Promise<{\n    initialized: boolean;\n    account_type?: 'local' | 'space';\n    has_password?: boolean;\n  }> {\n    return this.get('/api/v1/user/account-info');\n  }\n\n  public setPassword(\n    newPassword: string,\n    currentPassword?: string,\n  ): Promise<{ user: string }> {\n    return this.post('/api/v1/user/set-password', {\n      new_password: newPassword,\n      current_password: currentPassword,\n    });\n  }\n\n  public async bindSpaceAccount(\n    code: string,\n    state: string,\n  ): Promise<{\n    token: string;\n    user: string;\n    account_type: 'local' | 'space';\n  }> {\n    const response = await this.instance.post('/api/v1/user/bind-space', {\n      code,\n      state,\n    });\n    if (response.data.code !== 0) {\n      throw {\n        code: response.data.code,\n        msg: response.data.msg || 'Unknown error',\n      };\n    }\n    return response.data.data;\n  }\n\n  // ============ Space OAuth API (Redirect Flow) ============\n  public getSpaceAuthorizeUrl(\n    redirectUri: string,\n    state?: string,\n  ): Promise<{\n    authorize_url: string;\n  }> {\n    const params: Record<string, string> = { redirect_uri: redirectUri };\n    if (state) {\n      params.state = state;\n    }\n    return this.get('/api/v1/user/space/authorize-url', params);\n  }\n\n  public async exchangeSpaceOAuthCode(code: string): Promise<{\n    token: string;\n    user: string;\n  }> {\n    const response = await this.instance.post('/api/v1/user/space/callback', {\n      code,\n    });\n    if (response.data.code !== 0) {\n      throw {\n        code: response.data.code,\n        msg: response.data.msg || 'Unknown error',\n      };\n    }\n    return response.data.data;\n  }\n\n  // ============ Monitoring API ============\n  public getMonitoringData(params: {\n    botId?: string[];\n    pipelineId?: string[];\n    startTime?: string;\n    endTime?: string;\n    limit?: number;\n  }): Promise<{\n    overview: {\n      total_messages: number;\n      llm_calls: number;\n      embedding_calls: number;\n      model_calls: number;\n      success_rate: number;\n      active_sessions: number;\n    };\n    messages: Array<{\n      id: string;\n      timestamp: string;\n      bot_id: string;\n      bot_name: string;\n      pipeline_id: string;\n      pipeline_name: string;\n      message_content: string;\n      session_id: string;\n      status: string;\n      level: string;\n      platform?: string;\n      user_id?: string;\n      runner_name?: string;\n      variables?: string;\n    }>;\n    llmCalls: Array<{\n      id: string;\n      timestamp: string;\n      model_name: string;\n      input_tokens: number;\n      output_tokens: number;\n      total_tokens: number;\n      duration: number;\n      cost?: number;\n      status: string;\n      bot_id: string;\n      bot_name: string;\n      pipeline_id: string;\n      pipeline_name: string;\n      error_message?: string;\n      message_id?: string;\n    }>;\n    embeddingCalls: Array<{\n      id: string;\n      timestamp: string;\n      model_name: string;\n      prompt_tokens: number;\n      total_tokens: number;\n      duration: number;\n      input_count: number;\n      status: string;\n      error_message?: string;\n      knowledge_base_id?: string;\n      query_text?: string;\n      session_id?: string;\n      message_id?: string;\n      call_type?: string;\n    }>;\n    sessions: Array<{\n      session_id: string;\n      bot_id: string;\n      bot_name: string;\n      pipeline_id: string;\n      pipeline_name: string;\n      message_count: number;\n      last_activity: string;\n      start_time: string;\n      platform?: string;\n      user_id?: string;\n    }>;\n    errors: Array<{\n      id: string;\n      timestamp: string;\n      error_type: string;\n      error_message: string;\n      bot_id: string;\n      bot_name: string;\n      pipeline_id: string;\n      pipeline_name: string;\n      session_id?: string;\n      stack_trace?: string;\n      message_id?: string;\n    }>;\n    totalCount: {\n      messages: number;\n      llmCalls: number;\n      embeddingCalls: number;\n      sessions: number;\n      errors: number;\n    };\n  }> {\n    const queryParams = new URLSearchParams();\n    if (params.botId) {\n      params.botId.forEach((id) => queryParams.append('botId', id));\n    }\n    if (params.pipelineId) {\n      params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));\n    }\n    if (params.startTime) {\n      queryParams.append('startTime', params.startTime);\n    }\n    if (params.endTime) {\n      queryParams.append('endTime', params.endTime);\n    }\n    if (params.limit) {\n      queryParams.append('limit', params.limit.toString());\n    }\n\n    return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`);\n  }\n\n  public getMonitoringOverview(params: {\n    botId?: string[];\n    pipelineId?: string[];\n    startTime?: string;\n    endTime?: string;\n  }): Promise<{\n    total_messages: number;\n    llm_calls: number;\n    success_rate: number;\n    active_sessions: number;\n  }> {\n    const queryParams = new URLSearchParams();\n    if (params.botId) {\n      params.botId.forEach((id) => queryParams.append('botId', id));\n    }\n    if (params.pipelineId) {\n      params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));\n    }\n    if (params.startTime) {\n      queryParams.append('startTime', params.startTime);\n    }\n    if (params.endTime) {\n      queryParams.append('endTime', params.endTime);\n    }\n\n    return this.get(`/api/v1/monitoring/overview?${queryParams.toString()}`);\n  }\n\n  // ============ Survey API ============\n  public getSurveyPending(): Promise<{\n    survey: {\n      survey_id: string;\n      version: number;\n      title: Record<string, string>;\n      description: Record<string, string>;\n      questions: SurveyQuestion[];\n    } | null;\n  }> {\n    return this.get('/api/v1/survey/pending');\n  }\n\n  public submitSurveyResponse(\n    surveyId: string,\n    answers: Record<string, unknown>,\n    completed: boolean = true,\n  ): Promise<object> {\n    return this.post('/api/v1/survey/respond', {\n      survey_id: surveyId,\n      answers,\n      completed,\n    });\n  }\n\n  public dismissSurvey(surveyId: string): Promise<object> {\n    return this.post('/api/v1/survey/dismiss', { survey_id: surveyId });\n  }\n}\n\nexport interface SurveyQuestion {\n  id: string;\n  type: 'single_select' | 'multi_select' | 'text';\n  title: Record<string, string>;\n  subtitle?: Record<string, string>;\n  required: boolean;\n  options?: SurveyOption[];\n  placeholder?: Record<string, string>;\n  max_length?: number;\n}\n\nexport interface SurveyOption {\n  id: string;\n  label: Record<string, string>;\n  has_input?: boolean;\n}\n"
  },
  {
    "path": "web/src/app/infra/http/BaseHttpClient.ts",
    "content": "import axios, {\n  AxiosInstance,\n  AxiosRequestConfig,\n  AxiosResponse,\n  AxiosError,\n} from 'axios';\n\ntype JSONValue = string | number | boolean | JSONObject | JSONArray | null;\ninterface JSONObject {\n  [key: string]: JSONValue;\n}\ntype JSONArray = Array<JSONValue>;\n\nexport interface ResponseData<T = unknown> {\n  code: number;\n  message: string;\n  data: T;\n  timestamp: number;\n}\n\nexport interface RequestConfig extends AxiosRequestConfig {\n  isSSR?: boolean; // 服务端渲染标识\n  retry?: number; // 重试次数\n}\n\n/**\n * 基础 HTTP 客户端类\n * 提供通用的 HTTP 请求方法和拦截器配置\n */\nexport abstract class BaseHttpClient {\n  protected instance: AxiosInstance;\n  protected disableToken: boolean = false;\n  protected baseURL: string;\n\n  constructor(baseURL: string, disableToken?: boolean) {\n    this.baseURL = baseURL;\n    this.disableToken = disableToken || false;\n\n    this.instance = axios.create({\n      baseURL: baseURL,\n      timeout: 30000,\n      headers: {\n        'Content-Type': 'application/json',\n      },\n    });\n\n    this.initInterceptors();\n  }\n\n  // 外部获取baseURL的方法\n  public getBaseUrl(): string {\n    return this.baseURL;\n  }\n\n  // 更新 baseURL\n  public updateBaseURL(newBaseURL: string): void {\n    this.baseURL = newBaseURL;\n    this.instance.defaults.baseURL = newBaseURL;\n  }\n\n  // 同步获取Session\n  protected getSessionSync(): string | null {\n    if (typeof window !== 'undefined') {\n      return localStorage.getItem('token');\n    }\n    return null;\n  }\n\n  // 拦截器配置\n  protected initInterceptors(): void {\n    // 请求拦截\n    this.instance.interceptors.request.use(\n      async (config) => {\n        // 客户端添加认证头\n        if (typeof window !== 'undefined' && !this.disableToken) {\n          const session = this.getSessionSync();\n          if (session) {\n            config.headers.Authorization = `Bearer ${session}`;\n          }\n        }\n\n        return config;\n      },\n      (error) => Promise.reject(error),\n    );\n\n    // 响应拦截\n    this.instance.interceptors.response.use(\n      (response: AxiosResponse<ResponseData>) => {\n        return response;\n      },\n      (error: AxiosError<ResponseData>) => {\n        // 统一错误处理\n        if (error.response) {\n          const { status, data } = error.response;\n          const errMsg = (data as { msg?: string })?.msg || error.message;\n\n          switch (status) {\n            case 401:\n              if (typeof window !== 'undefined') {\n                localStorage.removeItem('token');\n                if (!error.request.responseURL.includes('/check-token')) {\n                  window.location.href = '/login';\n                }\n              }\n              break;\n            case 403:\n              console.error('Permission denied:', errMsg);\n              break;\n            case 500:\n              console.error('Server error:', errMsg);\n              break;\n          }\n\n          return Promise.reject({\n            code: data?.code || status,\n            msg: errMsg,\n            data: data?.data || null,\n          });\n        }\n\n        return Promise.reject({\n          code: -1,\n          msg: error.message || 'Network Error',\n          data: null,\n        });\n      },\n    );\n  }\n\n  // 转换下划线为驼峰\n  protected convertKeysToCamel(obj: JSONValue): JSONValue {\n    if (Array.isArray(obj)) {\n      return obj.map((v) => this.convertKeysToCamel(v));\n    } else if (obj !== null && typeof obj === 'object') {\n      return Object.keys(obj).reduce((acc, key) => {\n        const camelKey = key.replace(/_([a-z])/g, (_, letter) =>\n          letter.toUpperCase(),\n        );\n        acc[camelKey] = this.convertKeysToCamel((obj as JSONObject)[key]);\n        return acc;\n      }, {} as JSONObject);\n    }\n    return obj;\n  }\n\n  // 错误处理\n  protected handleError(error: object): never {\n    if (axios.isCancel(error)) {\n      throw { code: -2, msg: 'Request canceled', data: null };\n    }\n    throw error;\n  }\n\n  // 核心请求方法\n  public async request<T = unknown>(config: RequestConfig): Promise<T> {\n    try {\n      const response = await this.instance.request<ResponseData<T>>(config);\n      return response.data.data;\n    } catch (error) {\n      return this.handleError(error as object);\n    }\n  }\n\n  // 快捷方法\n  public get<T = unknown>(\n    url: string,\n    params?: object,\n    config?: RequestConfig,\n  ): Promise<T> {\n    return this.request<T>({ method: 'get', url, params, ...config });\n  }\n\n  public post<T = unknown>(\n    url: string,\n    data?: object,\n    config?: RequestConfig,\n  ): Promise<T> {\n    return this.request<T>({ method: 'post', url, data, ...config });\n  }\n\n  public put<T = unknown>(\n    url: string,\n    data?: object,\n    config?: RequestConfig,\n  ): Promise<T> {\n    return this.request<T>({ method: 'put', url, data, ...config });\n  }\n\n  public delete<T = unknown>(url: string, config?: RequestConfig): Promise<T> {\n    return this.request<T>({ method: 'delete', url, ...config });\n  }\n\n  public postFile<T = unknown>(\n    url: string,\n    formData: FormData,\n    config?: RequestConfig,\n  ): Promise<T> {\n    return this.request<T>({\n      method: 'post',\n      url,\n      data: formData,\n      headers: {\n        'Content-Type': 'multipart/form-data',\n      },\n      ...config,\n    });\n  }\n\n  public async downloadFile(\n    url: string,\n    config?: RequestConfig,\n  ): Promise<AxiosResponse<Blob>> {\n    try {\n      const response = await this.instance.get<Blob>(url, {\n        responseType: 'blob',\n        ...config,\n      });\n      return response;\n    } catch (error) {\n      return this.handleError(error as object);\n    }\n  }\n}\n"
  },
  {
    "path": "web/src/app/infra/http/CloudServiceClient.ts",
    "content": "import { BaseHttpClient } from './BaseHttpClient';\nimport {\n  ApiRespMarketplacePluginDetail,\n  ApiRespMarketplacePlugins,\n} from '@/app/infra/entities/api';\nimport { PluginV4 } from '@/app/infra/entities/plugin';\nimport { I18nObject } from '@/app/infra/entities/common';\n\n/**\n * 云服务客户端\n * 负责与 cloud service 的所有交互\n */\nexport class CloudServiceClient extends BaseHttpClient {\n  constructor(baseURL: string = '') {\n    // cloud service 不需要 token 认证\n    super(baseURL, true);\n  }\n\n  public getMarketplacePlugins(\n    page: number,\n    page_size: number,\n    sort_by?: string,\n    sort_order?: string,\n  ): Promise<ApiRespMarketplacePlugins> {\n    return this.get<ApiRespMarketplacePlugins>('/api/v1/marketplace/plugins', {\n      page,\n      page_size,\n      sort_by,\n      sort_order,\n    });\n  }\n\n  public searchMarketplacePlugins(\n    query: string,\n    page: number,\n    page_size: number,\n    sort_by?: string,\n    sort_order?: string,\n    component_filter?: string,\n    tags_filter?: string[],\n  ): Promise<ApiRespMarketplacePlugins> {\n    return this.post<ApiRespMarketplacePlugins>(\n      '/api/v1/marketplace/plugins/search',\n      {\n        query,\n        page,\n        page_size,\n        sort_by,\n        sort_order,\n        component_filter,\n        tags_filter,\n      },\n    );\n  }\n\n  public getPluginDetail(\n    author: string,\n    pluginName: string,\n  ): Promise<ApiRespMarketplacePluginDetail> {\n    return this.get<ApiRespMarketplacePluginDetail>(\n      `/api/v1/marketplace/plugins/${author}/${pluginName}`,\n    );\n  }\n\n  public getPluginREADME(\n    author: string,\n    pluginName: string,\n    language?: string,\n  ): Promise<{ readme: string }> {\n    return this.get<{ readme: string }>(\n      `/api/v1/marketplace/plugins/${author}/${pluginName}/resources/README`,\n      language ? { language } : undefined,\n    );\n  }\n\n  public getPluginIconURL(author: string, name: string): string {\n    return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${name}/resources/icon`;\n  }\n\n  public getPluginAssetURL(\n    author: string,\n    pluginName: string,\n    filepath: string,\n  ): string {\n    return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${pluginName}/resources/assets/${filepath}`;\n  }\n\n  public getPluginMarketplaceURL(\n    cloud_service_url: string,\n    author: string,\n    name: string,\n  ): string {\n    return `${cloud_service_url}/market/${author}/${name}`;\n  }\n\n  public getLangBotReleases(): Promise<GitHubRelease[]> {\n    return this.get<GitHubRelease[]>('/api/v1/dist/info/releases');\n  }\n\n  public getAllTags(): Promise<{ tags: PluginTag[] }> {\n    return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags');\n  }\n\n  public getRecommendationLists(): Promise<{ lists: RecommendationList[] }> {\n    return this.get<{ lists: RecommendationList[] }>(\n      '/api/v1/marketplace/recommendation-lists',\n    );\n  }\n}\n\nexport interface RecommendationList {\n  uuid: string;\n  label: I18nObject;\n  sort_order: number;\n  plugins: PluginV4[];\n}\n\nexport interface PluginTag {\n  tag: string;\n  display_name: {\n    zh_Hans?: string;\n    en_US?: string;\n    zh_Hant?: string;\n    ja_JP?: string;\n  };\n}\n\nexport interface GitHubRelease {\n  tag_name: string;\n  name: string;\n  body: string;\n  html_url: string;\n  published_at: string;\n  prerelease: boolean;\n  draft: boolean;\n}\n"
  },
  {
    "path": "web/src/app/infra/http/HttpClient.ts",
    "content": "/**\n * @deprecated 此文件仅用于向后兼容。请使用新的 client：\n * - import { backendClient } from '@/app/infra/http'\n * - import { getCloudServiceClient } from '@/app/infra/http'\n */\n\n// 重新导出新的客户端实现，保持向后兼容\nexport {\n  backendClient as httpClient,\n  systemInfo,\n  type ResponseData,\n  type RequestConfig,\n} from './index';\n\n// 为了兼容性，重新导出 BackendClient 作为 HttpClient\nimport { BackendClient } from './BackendClient';\nexport const HttpClient = BackendClient;\n"
  },
  {
    "path": "web/src/app/infra/http/README.md",
    "content": "# HTTP Client 架构说明\n\n## 概述\n\nHTTP Client 已经重构为更清晰的架构，将通用方法与业务逻辑分离，并为不同的服务创建了独立的客户端。\n\n## 文件结构\n\n- **BaseHttpClient.ts** - 基础 HTTP 客户端类，包含所有通用的 HTTP 方法和拦截器配置\n- **BackendClient.ts** - 后端服务客户端，处理与后端 API 的所有交互\n- **CloudServiceClient.ts** - 云服务客户端，处理与 cloud service 的交互（如插件市场）\n- **index.ts** - 主入口文件，管理客户端实例的创建和导出\n- **HttpClient.ts** - 仅用于向后兼容的文件（已废弃）\n\n## 使用方法\n\n### 新的推荐用法\n\n```typescript\n// 使用后端客户端\nimport { backendClient } from '@/app/infra/http';\n\n// 获取模型列表\nconst models = await backendClient.getProviderLLMModels();\n\n// 使用云服务客户端（异步方式，确保 URL 已初始化）\nimport { getCloudServiceClient } from '@/app/infra/http';\n\nconst cloudClient = await getCloudServiceClient();\nconst marketPlugins = await cloudClient.getMarketPlugins(1, 10, 'search term');\n\n// 使用云服务客户端（同步方式，可能使用默认 URL）\nimport { cloudServiceClient } from '@/app/infra/http';\n\nconst marketPlugins = await cloudServiceClient.getMarketPlugins(\n  1,\n  10,\n  'search term',\n);\n```\n\n### 向后兼容（不推荐）\n\n```typescript\n// 旧的用法仍然可以工作\nimport { httpClient, spaceClient } from '@/app/infra/http/HttpClient';\n\n// httpClient 现在指向 backendClient\nconst models = await httpClient.getProviderLLMModels();\n\n// spaceClient 现在指向 cloudServiceClient\nconst marketPlugins = await spaceClient.getMarketPlugins(1, 10, 'search term');\n```\n\n## 特点\n\n1. **清晰的职责分离**\n   - BaseHttpClient：通用 HTTP 功能\n   - BackendClient：后端 API 业务逻辑\n   - CloudServiceClient：云服务 API 业务逻辑\n\n2. **自动初始化**\n   - 应用启动时自动从后端获取 cloud service URL\n   - 云服务客户端会自动更新 baseURL\n\n3. **类型安全**\n   - 所有方法都有完整的 TypeScript 类型定义\n   - 请求和响应类型都从 `@/app/infra/entities/api` 导入\n\n4. **向后兼容**\n   - 旧代码无需修改即可继续工作\n   - 逐步迁移到新的 API\n"
  },
  {
    "path": "web/src/app/infra/http/index.ts",
    "content": "import { BackendClient } from './BackendClient';\nimport { CloudServiceClient } from './CloudServiceClient';\nimport { ApiRespSystemInfo } from '@/app/infra/entities/api';\n\n// 系统信息\nexport let systemInfo: ApiRespSystemInfo = {\n  debug: false,\n  version: '',\n  edition: 'community',\n  enable_marketplace: true,\n  cloud_service_url: '',\n  allow_modify_login_info: true,\n  disable_models_service: false,\n  limitation: {\n    max_bots: -1,\n    max_pipelines: -1,\n    max_extensions: -1,\n  },\n};\n\n// 用户信息\nexport let userInfo: {\n  user: string;\n  account_type: 'local' | 'space';\n  has_password: boolean;\n} | null = null;\n\n/**\n * 获取基础 URL\n */\nconst getBaseURL = (): string => {\n  if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_API_BASE_URL) {\n    return process.env.NEXT_PUBLIC_API_BASE_URL;\n  }\n  return '/';\n};\n\n// 创建后端客户端实例\nexport const backendClient = new BackendClient(getBaseURL());\n// 为了兼容性，也导出为 httpClient\nexport const httpClient = backendClient;\n\n// 创建云服务客户端实例（初始化时使用默认 URL）\nexport const cloudServiceClient = new CloudServiceClient(\n  'https://space.langbot.app',\n);\n\n// 应用启动时自动初始化系统信息\nif (typeof window !== 'undefined' && systemInfo.cloud_service_url === '') {\n  backendClient\n    .getSystemInfo()\n    .then((info) => {\n      systemInfo = info;\n      cloudServiceClient.updateBaseURL(info.cloud_service_url);\n    })\n    .catch((error) => {\n      console.error('Failed to initialize system info on startup:', error);\n    });\n}\n\n/**\n * 获取云服务客户端\n * 如果 cloud service URL 尚未初始化，会自动从后端获取\n */\nexport const getCloudServiceClient = async (): Promise<CloudServiceClient> => {\n  if (systemInfo.cloud_service_url === '') {\n    try {\n      systemInfo = await backendClient.getSystemInfo();\n      // 更新 cloud service client 的 baseURL\n      cloudServiceClient.updateBaseURL(systemInfo.cloud_service_url);\n    } catch (error) {\n      console.error('Failed to get system info:', error);\n      // 如果获取失败，继续使用默认 URL\n    }\n  }\n  return cloudServiceClient;\n};\n\n/**\n * 获取云服务客户端（同步版本）\n * 注意：如果 cloud service URL 尚未初始化，将使用默认 URL\n */\nexport const getCloudServiceClientSync = (): CloudServiceClient => {\n  return cloudServiceClient;\n};\n\n/**\n * 手动初始化系统信息\n * 可以在应用启动时调用此方法预先获取系统信息\n */\nexport const initializeSystemInfo = async (): Promise<void> => {\n  try {\n    systemInfo = await backendClient.getSystemInfo();\n    cloudServiceClient.updateBaseURL(systemInfo.cloud_service_url);\n  } catch (error) {\n    console.error('Failed to initialize system info:', error);\n  }\n};\n\n/**\n * 初始化用户信息\n * 应该在用户登录后调用此方法\n */\nexport const initializeUserInfo = async (): Promise<void> => {\n  try {\n    userInfo = await backendClient.getUserInfo();\n  } catch (error) {\n    console.error('Failed to initialize user info:', error);\n    userInfo = null;\n  }\n};\n\n/**\n * 清除用户信息\n * 应该在用户登出时调用此方法\n */\nexport const clearUserInfo = (): void => {\n  userInfo = null;\n};\n\n// 导出类型，以便其他地方使用\nexport type { ResponseData, RequestConfig } from './BaseHttpClient';\nexport { BaseHttpClient } from './BaseHttpClient';\nexport { BackendClient } from './BackendClient';\nexport { CloudServiceClient } from './CloudServiceClient';\n"
  },
  {
    "path": "web/src/app/infra/http/requestParam/bots/GetBotLogsRequest.ts",
    "content": "export interface GetBotLogsRequest {\n  from_index: number; // 从某索引开始往前找，-1代表结尾，也就是拉取最新的\n  max_count: number; // 最大拉取数量\n}\n"
  },
  {
    "path": "web/src/app/infra/http/requestParam/bots/GetBotLogsResponse.ts",
    "content": "export interface GetBotLogsResponse {\n  logs: BotLog[];\n  total_count: number;\n}\n\nexport interface BotLog {\n  images: [];\n  level: string;\n  message_session_id: string;\n  seq_id: number;\n  text: string;\n  timestamp: number;\n}\n"
  },
  {
    "path": "web/src/app/infra/websocket/WebSocketClient.ts",
    "content": "/**\n * WebSocket客户端类\n * 用于管理WebSocket连接和消息处理\n */\nexport interface WebSocketMessage {\n  id: number;\n  role: 'user' | 'assistant';\n  content: string;\n  message_chain: Array<{ type: string; text?: string; target?: string }>;\n  timestamp: string;\n  is_final?: boolean;\n  connection_id?: string;\n}\n\nexport interface WebSocketResponse {\n  type:\n    | 'connected'\n    | 'response'\n    | 'user_message'\n    | 'pong'\n    | 'broadcast'\n    | 'error';\n  connection_id?: string;\n  pipeline_uuid?: string;\n  session_type?: string;\n  timestamp?: string;\n  data?: WebSocketMessage;\n  message?: string;\n}\n\nexport class WebSocketClient {\n  private ws: WebSocket | null = null;\n  private connectionId: string | null = null;\n  private reconnectAttempts = 0;\n  private maxReconnectAttempts = 5;\n  private reconnectDelay = 3000; // 3秒重连间隔\n  private heartbeatInterval: NodeJS.Timeout | null = null;\n  private heartbeatIntervalMs = 30000; // 30秒\n  private isConnecting = false; // 防止重复连接\n\n  // 事件回调\n  private onConnectedCallback?: (data: WebSocketResponse) => void;\n  private onMessageCallback?: (data: WebSocketMessage) => void;\n  private onErrorCallback?: (error: Error) => void;\n  private onCloseCallback?: () => void;\n  private onBroadcastCallback?: (message: string) => void;\n\n  constructor(\n    private pipelineId: string,\n    private sessionType: 'person' | 'group' = 'person',\n    private token?: string,\n  ) {}\n\n  /**\n   * 连接到WebSocket服务器\n   */\n  public connect(): Promise<string> {\n    return new Promise((resolve, reject) => {\n      try {\n        // 防止重复连接\n        if (\n          this.isConnecting ||\n          (this.ws && this.ws.readyState === WebSocket.CONNECTING)\n        ) {\n          console.warn('WebSocket正在连接中，忽略重复连接请求');\n          reject(new Error('Connection already in progress'));\n          return;\n        }\n\n        // 如果已经连接，直接返回\n        if (this.ws && this.ws.readyState === WebSocket.OPEN) {\n          console.warn('WebSocket已连接，忽略重复连接请求');\n          resolve(this.connectionId || '');\n          return;\n        }\n\n        this.isConnecting = true;\n\n        // 构建WebSocket URL\n        const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n        // extract host from process.env.NEXT_PUBLIC_API_BASE_URL\n        // 如果环境变量未定义,使用当前页面的 host (适配生产环境)\n        const host =\n          process.env.NEXT_PUBLIC_API_BASE_URL?.split('://')[1] ||\n          window.location.host;\n        const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;\n\n        this.ws = new WebSocket(url);\n\n        // 连接打开\n        this.ws.onopen = () => {\n          this.reconnectAttempts = 0;\n          this.isConnecting = false;\n          this.startHeartbeat();\n        };\n\n        // 接收消息\n        this.ws.onmessage = (event) => {\n          try {\n            const data: WebSocketResponse = JSON.parse(event.data);\n            this.handleMessage(data);\n\n            // 第一次连接成功\n            if (data.type === 'connected' && data.connection_id) {\n              this.connectionId = data.connection_id;\n              resolve(data.connection_id);\n            }\n          } catch (error) {\n            console.error('解析WebSocket消息失败:', error);\n            this.onErrorCallback?.(error as Error);\n          }\n        };\n\n        // 连接关闭\n        this.ws.onclose = () => {\n          this.isConnecting = false;\n          this.stopHeartbeat();\n          this.onCloseCallback?.();\n\n          // 自动重连\n          if (this.reconnectAttempts < this.maxReconnectAttempts) {\n            this.reconnectAttempts++;\n            setTimeout(() => {\n              this.connect().catch(console.error);\n            }, this.reconnectDelay * this.reconnectAttempts);\n          }\n        };\n\n        // 连接错误\n        this.ws.onerror = (event) => {\n          console.error('WebSocket错误:', event);\n          this.isConnecting = false;\n          const error = new Error('WebSocket连接失败');\n          this.onErrorCallback?.(error);\n          reject(error);\n        };\n      } catch (error) {\n        this.isConnecting = false;\n        reject(error);\n      }\n    });\n  }\n\n  /**\n   * 处理接收到的消息\n   */\n  private handleMessage(data: WebSocketResponse) {\n    switch (data.type) {\n      case 'connected':\n        this.onConnectedCallback?.(data);\n        break;\n\n      case 'response':\n        // 检查 session_type 是否匹配 - 如果消息没有 session_type 或者不匹配当前session，都忽略\n        if (!data.session_type || data.session_type !== this.sessionType) {\n          // 忽略不匹配的 session_type 消息\n          console.debug(\n            `忽略不匹配的消息: 当前session=${this.sessionType}, 消息session=${data.session_type}`,\n          );\n          break;\n        }\n        if (data.data) {\n          this.onMessageCallback?.(data.data);\n        }\n        break;\n\n      case 'user_message':\n        // 检查 session_type 是否匹配 - 如果消息没有 session_type 或者不匹配当前session，都忽略\n        if (!data.session_type || data.session_type !== this.sessionType) {\n          // 忽略不匹配的 session_type 消息\n          console.debug(\n            `忽略不匹配的用户消息: 当前session=${this.sessionType}, 消息session=${data.session_type}`,\n          );\n          break;\n        }\n        // 用户消息广播（包括自己发送的消息）\n        if (data.data) {\n          this.onMessageCallback?.(data.data);\n        }\n        break;\n\n      case 'pong':\n        // 心跳响应\n        break;\n\n      case 'broadcast':\n        if (data.message) {\n          this.onBroadcastCallback?.(data.message);\n        }\n        break;\n\n      case 'error':\n        const error = new Error(data.message || '未知错误');\n        this.onErrorCallback?.(error);\n        break;\n\n      default:\n        console.warn('未知消息类型:', data);\n    }\n  }\n\n  /**\n   * 发送消息\n   */\n  public sendMessage(\n    messageChain: Array<{ type: string; text?: string; target?: string }>,\n    stream: boolean = true,\n  ) {\n    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n      throw new Error('WebSocket未连接');\n    }\n\n    const message = {\n      type: 'message',\n      message: messageChain,\n      stream: stream,\n    };\n\n    this.ws.send(JSON.stringify(message));\n  }\n\n  /**\n   * 发送心跳\n   */\n  private sendHeartbeat() {\n    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n      return;\n    }\n\n    this.ws.send(JSON.stringify({ type: 'ping' }));\n  }\n\n  /**\n   * 启动心跳\n   */\n  private startHeartbeat() {\n    this.stopHeartbeat();\n    this.heartbeatInterval = setInterval(() => {\n      this.sendHeartbeat();\n    }, this.heartbeatIntervalMs);\n  }\n\n  /**\n   * 停止心跳\n   */\n  private stopHeartbeat() {\n    if (this.heartbeatInterval) {\n      clearInterval(this.heartbeatInterval);\n      this.heartbeatInterval = null;\n    }\n  }\n\n  /**\n   * 断开连接\n   */\n  public disconnect() {\n    if (this.ws) {\n      this.stopHeartbeat();\n\n      // 停止自动重连\n      this.reconnectAttempts = this.maxReconnectAttempts;\n\n      // 发送断开消息\n      if (this.ws.readyState === WebSocket.OPEN) {\n        this.ws.send(JSON.stringify({ type: 'disconnect' }));\n      }\n\n      this.ws.close();\n      this.ws = null;\n      this.connectionId = null;\n      this.isConnecting = false;\n    }\n  }\n\n  /**\n   * 获取连接ID\n   */\n  public getConnectionId(): string | null {\n    return this.connectionId;\n  }\n\n  /**\n   * 获取连接状态\n   */\n  public isConnected(): boolean {\n    return this.ws !== null && this.ws.readyState === WebSocket.OPEN;\n  }\n\n  // ===== 事件回调设置 =====\n\n  public onConnected(callback: (data: WebSocketResponse) => void) {\n    this.onConnectedCallback = callback;\n    return this;\n  }\n\n  public onMessage(callback: (data: WebSocketMessage) => void) {\n    this.onMessageCallback = callback;\n    return this;\n  }\n\n  public onError(callback: (error: Error) => void) {\n    this.onErrorCallback = callback;\n    return this;\n  }\n\n  public onClose(callback: () => void) {\n    this.onCloseCallback = callback;\n    return this;\n  }\n\n  public onBroadcast(callback: (message: string) => void) {\n    this.onBroadcastCallback = callback;\n    return this;\n  }\n}\n"
  },
  {
    "path": "web/src/app/layout.tsx",
    "content": "import './global.css';\nimport 'react-photo-view/dist/react-photo-view.css';\nimport type { Metadata } from 'next';\nimport { Toaster } from '@/components/ui/sonner';\nimport I18nProvider from '@/i18n/I18nProvider';\nimport { ThemeProvider } from '@/components/providers/theme-provider';\n\nexport const metadata: Metadata = {\n  title: 'LangBot',\n  description:\n    'Production-grade platform for building agentic IM bots, integrated with Telegram, Slack, Discord, WeChat, QQ, etc.',\n};\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  return (\n    <html lang=\"zh\" suppressHydrationWarning>\n      <body className={``}>\n        <ThemeProvider>\n          <I18nProvider>\n            {children}\n            <Toaster />\n          </I18nProvider>\n        </ThemeProvider>\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "web/src/app/login/layout.tsx",
    "content": "'use client';\n\nimport React from 'react';\n\nexport default function LoginLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  return (\n    <div className=\"min-h-screen bg-background\">\n      <main className=\"min-h-screen\">{children}</main>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/login/page.tsx",
    "content": "'use client';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n  CardDescription,\n} from '@/components/ui/card';\nimport { LanguageSelector } from '@/components/ui/language-selector';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport * as z from 'zod';\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from '@/components/ui/form';\nimport { useEffect, useState } from 'react';\nimport { httpClient, initializeUserInfo } from '@/app/infra/http';\nimport { useRouter } from 'next/navigation';\nimport { Mail, Lock, Loader2, AlertCircle, RefreshCw } from 'lucide-react';\nimport langbotIcon from '@/app/assets/langbot-logo.webp';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport Link from 'next/link';\nimport { ThemeToggle } from '@/components/ui/theme-toggle';\nimport { LoadingSpinner } from '@/components/ui/loading-spinner';\n\nconst formSchema = (t: (key: string) => string) =>\n  z.object({\n    email: z.string().email(t('common.invalidEmail')),\n    password: z.string().min(1, t('common.emptyPassword')),\n  });\n\ntype AccountType = 'local' | 'space';\n\nexport default function Login() {\n  const router = useRouter();\n  const { t } = useTranslation();\n  const [spaceLoading, setSpaceLoading] = useState(false);\n  const [accountType, setAccountType] = useState<AccountType | null>(null);\n  const [hasPassword, setHasPassword] = useState(false);\n  const [loading, setLoading] = useState(true);\n  const [loadError, setLoadError] = useState<string | null>(null);\n  const [retrying, setRetrying] = useState(false);\n\n  const form = useForm<z.infer<ReturnType<typeof formSchema>>>({\n    resolver: zodResolver(formSchema(t)),\n    defaultValues: {\n      email: '',\n      password: '',\n    },\n  });\n\n  useEffect(() => {\n    checkAccountInfo();\n  }, []);\n\n  async function checkAccountInfo() {\n    try {\n      setLoadError(null);\n      const res = await httpClient.getAccountInfo();\n      if (!res.initialized) {\n        router.push('/register');\n        return;\n      }\n      setAccountType(res.account_type || 'local');\n      setHasPassword(res.has_password || false);\n      setLoading(false);\n\n      // Also check if already logged in\n      checkIfAlreadyLoggedIn();\n    } catch (err) {\n      const errorMessage =\n        err instanceof Error ? err.message : t('common.loginLoadError');\n      setLoadError(errorMessage);\n      setLoading(false);\n    }\n  }\n\n  async function handleRetry() {\n    setRetrying(true);\n    setLoading(true);\n    setLoadError(null);\n    await checkAccountInfo();\n    setRetrying(false);\n  }\n\n  function checkIfAlreadyLoggedIn() {\n    httpClient\n      .checkUserToken()\n      .then((res) => {\n        if (res.token) {\n          localStorage.setItem('token', res.token);\n          router.push('/home');\n        }\n      })\n      .catch(() => {});\n  }\n\n  function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {\n    handleLogin(values.email, values.password);\n  }\n\n  function handleLogin(username: string, password: string) {\n    httpClient\n      .authUser(username, password)\n      .then(async (res) => {\n        localStorage.setItem('token', res.token);\n        localStorage.setItem('userEmail', username);\n        await initializeUserInfo();\n        router.push('/home');\n        toast.success(t('common.loginSuccess'));\n      })\n      .catch(() => {\n        toast.error(t('common.loginFailed'));\n      });\n  }\n\n  const handleSpaceLoginClick = async () => {\n    setSpaceLoading(true);\n    try {\n      const currentOrigin = window.location.origin;\n      const redirectUri = `${currentOrigin}/auth/space/callback`;\n      const response = await httpClient.getSpaceAuthorizeUrl(redirectUri);\n      window.location.href = response.authorize_url;\n    } catch {\n      toast.error(t('common.spaceLoginFailed'));\n      setSpaceLoading(false);\n    }\n  };\n\n  if (loading) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900\">\n        <LoadingSpinner />\n      </div>\n    );\n  }\n\n  // Show error state when account info failed to load\n  if (loadError) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900\">\n        <Card className=\"w-[375px] shadow-lg dark:shadow-white/10\">\n          <CardHeader>\n            <div className=\"flex justify-between items-center mb-6\">\n              <ThemeToggle />\n              <LanguageSelector />\n            </div>\n            <img\n              src={langbotIcon.src}\n              alt=\"LangBot\"\n              className=\"w-16 h-16 mb-4 mx-auto\"\n            />\n            <CardTitle className=\"text-2xl text-center\">\n              {t('common.welcome')}\n            </CardTitle>\n          </CardHeader>\n          <CardContent className=\"space-y-4\">\n            <div className=\"flex flex-col items-center gap-3 py-4\">\n              <AlertCircle className=\"h-10 w-10 text-destructive\" />\n              <p className=\"text-sm text-center text-muted-foreground\">\n                {t('common.loginLoadErrorDesc')}\n              </p>\n              <code className=\"text-xs bg-muted px-3 py-2 rounded max-w-full overflow-x-auto block text-center text-muted-foreground\">\n                {loadError}\n              </code>\n              <Button\n                onClick={handleRetry}\n                disabled={retrying}\n                variant=\"outline\"\n                className=\"mt-2 cursor-pointer\"\n              >\n                {retrying ? (\n                  <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                ) : (\n                  <RefreshCw className=\"mr-2 h-4 w-4\" />\n                )}\n                {t('common.retry')}\n              </Button>\n            </div>\n          </CardContent>\n        </Card>\n      </div>\n    );\n  }\n\n  // Determine what to show based on account type\n  const showLocalLogin =\n    accountType === 'local' || (accountType === 'space' && hasPassword);\n  const showSpaceLogin = accountType === 'space';\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-gray-50 dark:dark:bg-neutral-900\">\n      <Card className=\"w-[375px] shadow-lg dark:shadow-white/10\">\n        <CardHeader>\n          <div className=\"flex justify-between items-center mb-6\">\n            <ThemeToggle />\n            <LanguageSelector />\n          </div>\n          <img\n            src={langbotIcon.src}\n            alt=\"LangBot\"\n            className=\"w-16 h-16 mb-4 mx-auto\"\n          />\n          <CardTitle className=\"text-2xl text-center\">\n            {t('common.welcome')}\n          </CardTitle>\n          <CardDescription className=\"text-center\">\n            {t('common.continueToLogin')}\n          </CardDescription>\n        </CardHeader>\n        <CardContent className=\"space-y-6\">\n          {/* Space Login - only show for space accounts */}\n          {showSpaceLogin && (\n            <div className=\"space-y-3\">\n              <Button\n                type=\"button\"\n                className=\"w-full cursor-pointer\"\n                onClick={handleSpaceLoginClick}\n                disabled={spaceLoading}\n              >\n                {spaceLoading ? (\n                  <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                ) : (\n                  <svg\n                    className=\"mr-2 h-4 w-4\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <path\n                      d=\"M12 2L2 7L12 12L22 7L12 2Z\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    />\n                    <path\n                      d=\"M2 17L12 22L22 17\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    />\n                    <path\n                      d=\"M2 12L12 17L22 12\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    />\n                  </svg>\n                )}\n                {t('common.loginWithSpace')}\n              </Button>\n            </div>\n          )}\n\n          {/* Divider - only show if both login methods are available */}\n          {showSpaceLogin && showLocalLogin && (\n            <div className=\"relative\">\n              <div className=\"absolute inset-0 flex items-center\">\n                <span className=\"w-full border-t\" />\n              </div>\n              <div className=\"relative flex justify-center text-xs uppercase\">\n                <span className=\"bg-white dark:bg-card px-2 text-muted-foreground\">\n                  {t('common.or')}\n                </span>\n              </div>\n            </div>\n          )}\n\n          {/* Local Account Login - show for local accounts or space accounts with password */}\n          {showLocalLogin && (\n            <Form {...form}>\n              <form\n                onSubmit={form.handleSubmit(onSubmit)}\n                className=\"space-y-6\"\n              >\n                <FormField\n                  control={form.control}\n                  name=\"email\"\n                  render={({ field }) => (\n                    <FormItem>\n                      <FormLabel>{t('common.email')}</FormLabel>\n                      <FormControl>\n                        <div className=\"relative\">\n                          <Mail className=\"absolute left-3 top-3 h-4 w-4 text-gray-400\" />\n                          <Input\n                            placeholder={t('common.enterEmail')}\n                            className=\"pl-10\"\n                            {...field}\n                          />\n                        </div>\n                      </FormControl>\n                      <FormMessage />\n                    </FormItem>\n                  )}\n                />\n\n                <FormField\n                  control={form.control}\n                  name=\"password\"\n                  render={({ field }) => (\n                    <FormItem>\n                      <div className=\"flex justify-between\">\n                        <FormLabel>{t('common.password')}</FormLabel>\n                        <Link\n                          href=\"/reset-password\"\n                          className=\"text-sm text-blue-500\"\n                        >\n                          {t('common.forgotPassword')}\n                        </Link>\n                      </div>\n\n                      <FormControl>\n                        <div className=\"relative\">\n                          <Lock className=\"absolute left-3 top-3 h-4 w-4 text-gray-400\" />\n                          <Input\n                            type=\"password\"\n                            placeholder={t('common.enterPassword')}\n                            className=\"pl-10\"\n                            {...field}\n                          />\n                        </div>\n                      </FormControl>\n                      <FormMessage />\n                    </FormItem>\n                  )}\n                />\n\n                <Button\n                  type=\"submit\"\n                  variant={showSpaceLogin ? 'outline' : 'default'}\n                  className=\"w-full cursor-pointer\"\n                >\n                  {t('common.loginWithPassword')}\n                </Button>\n              </form>\n            </Form>\n          )}\n\n          <p className=\"text-xs text-center text-muted-foreground\">\n            {t('common.agreementNotice')}{' '}\n            <a\n              href=\"https://langbot.app/privacy\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"underline hover:text-foreground transition-colors\"\n            >\n              {t('common.privacyPolicy')}\n            </a>{' '}\n            {t('common.and')}{' '}\n            <a\n              href={t('common.dataCollectionPolicyUrl')}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"underline hover:text-foreground transition-colors\"\n            >\n              {t('common.dataCollectionPolicy')}\n            </a>\n          </p>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/page.tsx",
    "content": "'use client';\n\nimport { useRouter } from 'next/navigation';\nimport { useEffect } from 'react';\n\nexport default function Home() {\n  const router = useRouter();\n  useEffect(() => {\n    router.push('/login');\n  }, []);\n  return <div className={``}></div>;\n}\n"
  },
  {
    "path": "web/src/app/register/layout.tsx",
    "content": "'use client';\n\nimport React from 'react';\n\nexport default function RegisterLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  return (\n    <div className=\"min-h-screen bg-background\">\n      <main className=\"min-h-screen\">{children}</main>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/register/page.tsx",
    "content": "'use client';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n  CardDescription,\n} from '@/components/ui/card';\nimport { LanguageSelector } from '@/components/ui/language-selector';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport * as z from 'zod';\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from '@/components/ui/form';\nimport { useEffect, useState } from 'react';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { useRouter } from 'next/navigation';\nimport { Mail, Lock, Loader2, Info } from 'lucide-react';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport langbotIcon from '@/app/assets/langbot-logo.webp';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport { ThemeToggle } from '@/components/ui/theme-toggle';\nimport { CustomApiError } from '@/app/infra/entities/common';\n\nconst formSchema = (t: (key: string) => string) =>\n  z.object({\n    email: z.string().email(t('common.invalidEmail')),\n    password: z.string().min(1, t('common.emptyPassword')),\n  });\n\nexport default function Register() {\n  const router = useRouter();\n  const { t } = useTranslation();\n  const [spaceLoading, setSpaceLoading] = useState(false);\n\n  const form = useForm<z.infer<ReturnType<typeof formSchema>>>({\n    resolver: zodResolver(formSchema(t)),\n    defaultValues: {\n      email: '',\n      password: '',\n    },\n  });\n\n  useEffect(() => {\n    getIsInitialized();\n  }, []);\n\n  function getIsInitialized() {\n    httpClient\n      .checkIfInited()\n      .then((res) => {\n        if (res.initialized) {\n          router.push('/login');\n        }\n      })\n      .catch(() => {});\n  }\n\n  function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {\n    handleRegister(values.email, values.password);\n  }\n\n  function handleRegister(username: string, password: string) {\n    httpClient\n      .initUser(username, password)\n      .then(() => {\n        toast.success(t('register.initSuccess'));\n        router.push('/login');\n      })\n      .catch((err: Error) => {\n        toast.error(t('register.initFailed') + (err as CustomApiError).msg);\n      });\n  }\n\n  // Space OAuth redirect handler\n  const handleSpaceLoginClick = async () => {\n    setSpaceLoading(true);\n\n    try {\n      // Build the redirect URI to the OAuth callback page\n      const currentOrigin = window.location.origin;\n      const redirectUri = `${currentOrigin}/auth/space/callback`;\n\n      // Get the authorization URL from backend\n      const response = await httpClient.getSpaceAuthorizeUrl(redirectUri);\n\n      // Redirect to Space authorization page\n      window.location.href = response.authorize_url;\n    } catch {\n      toast.error(t('common.spaceLoginFailed'));\n      setSpaceLoading(false);\n    }\n  };\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900\">\n      <Card className=\"w-[375px] shadow-lg dark:shadow-white/10\">\n        <CardHeader>\n          <div className=\"flex justify-between items-center mb-6\">\n            <ThemeToggle />\n            <LanguageSelector />\n          </div>\n          <img\n            src={langbotIcon.src}\n            alt=\"LangBot\"\n            className=\"w-16 h-16 mb-4 mx-auto\"\n          />\n          <CardTitle className=\"text-2xl text-center\">\n            {t('register.title')}\n          </CardTitle>\n          <CardDescription className=\"text-center\">\n            {t('register.description')}\n            <br />\n            {t('register.adminAccountNote')}\n          </CardDescription>\n        </CardHeader>\n        <CardContent className=\"space-y-6\">\n          {/* Space Login - Recommended */}\n          <div className=\"space-y-3\">\n            <Button\n              type=\"button\"\n              className=\"w-full cursor-pointer\"\n              onClick={handleSpaceLoginClick}\n              disabled={spaceLoading}\n            >\n              {spaceLoading ? (\n                <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n              ) : (\n                <svg\n                  className=\"mr-2 h-4 w-4\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                >\n                  <path\n                    d=\"M12 2L2 7L12 12L22 7L12 2Z\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"2\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                  />\n                  <path\n                    d=\"M2 17L12 22L22 17\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"2\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                  />\n                  <path\n                    d=\"M2 12L12 17L22 12\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"2\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                  />\n                </svg>\n              )}\n              {t('register.initWithSpace')}\n            </Button>\n            <p className=\"text-xs text-center text-muted-foreground flex items-center justify-center gap-1\">\n              {t('register.spaceRecommended')}\n              <Popover>\n                <PopoverTrigger asChild>\n                  <Info className=\"h-3.5 w-3.5 cursor-pointer hover:text-foreground transition-colors\" />\n                </PopoverTrigger>\n                <PopoverContent side=\"right\" className=\"w-80 text-sm\">\n                  <ul className=\"space-y-2 list-disc list-inside text-muted-foreground\">\n                    <li>{t('register.spaceInfoTip1')}</li>\n                    <li>{t('register.spaceInfoTip2')}</li>\n                    <li>{t('register.spaceInfoTip3')}</li>\n                  </ul>\n                </PopoverContent>\n              </Popover>\n            </p>\n          </div>\n\n          <div className=\"relative\">\n            <div className=\"absolute inset-0 flex items-center\">\n              <span className=\"w-full border-t\" />\n            </div>\n            <div className=\"relative flex justify-center text-xs uppercase\">\n              <span className=\"bg-white dark:bg-card px-2 text-muted-foreground\">\n                {t('common.or')}\n              </span>\n            </div>\n          </div>\n\n          {/* Local Account Registration */}\n          <Form {...form}>\n            <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-6\">\n              <FormField\n                control={form.control}\n                name=\"email\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t('common.email')}</FormLabel>\n                    <FormControl>\n                      <div className=\"relative\">\n                        <Mail className=\"absolute left-3 top-3 h-4 w-4 text-gray-400\" />\n                        <Input\n                          placeholder={t('common.enterEmail')}\n                          className=\"pl-10\"\n                          {...field}\n                        />\n                      </div>\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n\n              <FormField\n                control={form.control}\n                name=\"password\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t('common.password')}</FormLabel>\n                    <FormControl>\n                      <div className=\"relative\">\n                        <Lock className=\"absolute left-3 top-3 h-4 w-4 text-gray-400\" />\n                        <Input\n                          type=\"password\"\n                          placeholder={t('common.enterPassword')}\n                          className=\"pl-10\"\n                          {...field}\n                        />\n                      </div>\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n\n              <Button\n                type=\"submit\"\n                variant=\"outline\"\n                className=\"w-full cursor-pointer\"\n              >\n                {t('register.registerWithPassword')}\n              </Button>\n            </form>\n          </Form>\n\n          <p className=\"text-xs text-center text-muted-foreground\">\n            {t('common.agreementNotice')}{' '}\n            <a\n              href=\"https://langbot.app/privacy\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"underline hover:text-foreground transition-colors\"\n            >\n              {t('common.privacyPolicy')}\n            </a>{' '}\n            {t('common.and')}{' '}\n            <a\n              href={t('common.dataCollectionPolicyUrl')}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"underline hover:text-foreground transition-colors\"\n            >\n              {t('common.dataCollectionPolicy')}\n            </a>\n          </p>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/reset-password/layout.tsx",
    "content": "'use client';\n\nimport React from 'react';\n\nexport default function ResetPasswordLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  return (\n    <div className=\"min-h-screen bg-background\">\n      <main className=\"min-h-screen\">{children}</main>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/reset-password/page.tsx",
    "content": "'use client';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n  CardDescription,\n} from '@/components/ui/card';\nimport {\n  InputOTP,\n  InputOTPGroup,\n  InputOTPSlot,\n  InputOTPSeparator,\n} from '@/components/ui/input-otp';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport * as z from 'zod';\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n  FormDescription,\n} from '@/components/ui/form';\nimport { useState } from 'react';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { useRouter } from 'next/navigation';\nimport { Mail, Lock, ArrowLeft } from 'lucide-react';\nimport { toast } from 'sonner';\nimport { useTranslation } from 'react-i18next';\nimport Link from 'next/link';\nimport { ThemeToggle } from '@/components/ui/theme-toggle';\n\nconst REGEXP_ONLY_DIGITS_AND_CHARS = /^[0-9a-zA-Z]+$/;\n\nconst formSchema = (t: (key: string) => string) =>\n  z.object({\n    email: z.string().email(t('common.invalidEmail')),\n    recoveryKey: z.string().min(1, t('resetPassword.recoveryKeyRequired')),\n    newPassword: z.string().min(1, t('resetPassword.newPasswordRequired')),\n  });\n\nexport default function ResetPassword() {\n  const router = useRouter();\n  const { t } = useTranslation();\n  const [isResetting, setIsResetting] = useState(false);\n\n  const form = useForm<z.infer<ReturnType<typeof formSchema>>>({\n    resolver: zodResolver(formSchema(t)),\n    defaultValues: {\n      email: '',\n      recoveryKey: '',\n      newPassword: '',\n    },\n  });\n\n  function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {\n    handleResetPassword(values.email, values.recoveryKey, values.newPassword);\n  }\n\n  function handleResetPassword(\n    email: string,\n    recoveryKey: string,\n    newPassword: string,\n  ) {\n    setIsResetting(true);\n    httpClient\n      .resetPassword(email, recoveryKey, newPassword)\n      .then(() => {\n        toast.success(t('resetPassword.resetSuccess'));\n        router.push('/login');\n      })\n      .catch(() => {\n        toast.error(t('resetPassword.resetFailed'));\n      })\n      .finally(() => {\n        setIsResetting(false);\n      });\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900\">\n      <Card className=\"w-[375px] shadow-lg dark:shadow-white/10\">\n        <CardHeader>\n          <div className=\"flex justify-between items-center mb-6\">\n            <Link\n              href=\"/login\"\n              className=\"flex items-center text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 transition-colors\"\n            >\n              <ArrowLeft className=\"h-4 w-4 mr-1\" />\n              {t('resetPassword.backToLogin')}\n            </Link>\n            <ThemeToggle />\n          </div>\n          <CardTitle className=\"text-2xl text-center\">\n            {t('resetPassword.title')}\n          </CardTitle>\n          <CardDescription className=\"text-center\">\n            {t('resetPassword.description')}\n          </CardDescription>\n        </CardHeader>\n        <CardContent>\n          <Form {...form}>\n            <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-6\">\n              <FormField\n                control={form.control}\n                name=\"email\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t('common.email')}</FormLabel>\n                    <FormControl>\n                      <div className=\"relative\">\n                        <Mail className=\"absolute left-3 top-3 h-4 w-4 text-gray-400\" />\n                        <Input\n                          placeholder={t('common.enterEmail')}\n                          className=\"pl-10\"\n                          {...field}\n                        />\n                      </div>\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n\n              <FormField\n                control={form.control}\n                name=\"recoveryKey\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t('resetPassword.recoveryKey')}</FormLabel>\n                    <FormDescription>\n                      {t('resetPassword.recoveryKeyDescription')}\n                    </FormDescription>\n                    <FormControl>\n                      <InputOTP\n                        maxLength={6}\n                        value={field.value}\n                        pattern={REGEXP_ONLY_DIGITS_AND_CHARS.source}\n                        onChange={(value) => {\n                          // 将输入的值转换为大写\n                          const upperValue = value.toUpperCase();\n                          field.onChange(upperValue);\n                        }}\n                      >\n                        <InputOTPGroup>\n                          <InputOTPSlot index={0} />\n                          <InputOTPSlot index={1} />\n                          <InputOTPSlot index={2} />\n                        </InputOTPGroup>\n                        <InputOTPSeparator />\n                        <InputOTPGroup>\n                          <InputOTPSlot index={3} />\n                          <InputOTPSlot index={4} />\n                          <InputOTPSlot index={5} />\n                        </InputOTPGroup>\n                      </InputOTP>\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n\n              <FormField\n                control={form.control}\n                name=\"newPassword\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormLabel>{t('resetPassword.newPassword')}</FormLabel>\n                    <FormControl>\n                      <div className=\"relative\">\n                        <Lock className=\"absolute left-3 top-3 h-4 w-4 text-gray-400\" />\n                        <Input\n                          type=\"password\"\n                          placeholder={t('resetPassword.enterNewPassword')}\n                          className=\"pl-10\"\n                          {...field}\n                        />\n                      </div>\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n\n              <Button\n                type=\"submit\"\n                className=\"w-full mt-4 cursor-pointer\"\n                disabled={isResetting}\n              >\n                {isResetting\n                  ? t('resetPassword.resetting')\n                  : t('resetPassword.resetPassword')}\n              </Button>\n            </form>\n          </Form>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/app/utils/versionCompare.ts",
    "content": "/**\n * Compare two version strings and determine if the first is newer than the second.\n * Supports semantic versioning format (e.g., \"1.2.3\", \"1.0.0-beta.1\").\n *\n * @param version1 - The version to compare (potentially newer)\n * @param version2 - The version to compare against (base version)\n * @returns true if version1 is newer than version2, false otherwise\n */\nexport function isNewerVersion(version1: string, version2: string): boolean {\n  if (!version1 || !version2) {\n    return false;\n  }\n\n  // Remove any leading 'v' prefix\n  const v1 = version1.replace(/^v/, '');\n  const v2 = version2.replace(/^v/, '');\n\n  // Split into main version and pre-release parts\n  const [main1, pre1] = v1.split('-');\n  const [main2, pre2] = v2.split('-');\n\n  // Split main version into numeric parts\n  const parts1 = main1.split('.').map((p) => parseInt(p, 10) || 0);\n  const parts2 = main2.split('.').map((p) => parseInt(p, 10) || 0);\n\n  // Normalize length\n  const maxLen = Math.max(parts1.length, parts2.length);\n  while (parts1.length < maxLen) parts1.push(0);\n  while (parts2.length < maxLen) parts2.push(0);\n\n  // Compare main version parts\n  for (let i = 0; i < maxLen; i++) {\n    if (parts1[i] > parts2[i]) return true;\n    if (parts1[i] < parts2[i]) return false;\n  }\n\n  // Main versions are equal, compare pre-release\n  // A version without pre-release is newer than one with pre-release\n  if (!pre1 && pre2) return true;\n  if (pre1 && !pre2) return false;\n  if (!pre1 && !pre2) return false;\n\n  // Both have pre-release, compare lexicographically\n  return pre1! > pre2!;\n}\n"
  },
  {
    "path": "web/src/components/providers/theme-provider.tsx",
    "content": "'use client';\n\nimport { ThemeProvider as NextThemesProvider } from 'next-themes';\nimport { type ThemeProviderProps } from 'next-themes';\n\nexport function ThemeProvider({ children, ...props }: ThemeProviderProps) {\n  return (\n    <NextThemesProvider\n      attribute=\"class\"\n      defaultTheme=\"system\"\n      enableSystem\n      disableTransitionOnChange\n      {...props}\n    >\n      {children}\n    </NextThemesProvider>\n  );\n}\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';\n\nimport { cn } from '@/lib/utils';\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>\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": "web/src/components/ui/alert.tsx",
    "content": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/lib/utils';\n\nconst alertVariants = cva(\n  'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',\n  {\n    variants: {\n      variant: {\n        default: 'bg-card text-card-foreground',\n        destructive:\n          'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  },\n);\n\nfunction Alert({\n  className,\n  variant,\n  ...props\n}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {\n  return (\n    <div\n      data-slot=\"alert\"\n      role=\"alert\"\n      className={cn(alertVariants({ variant }), className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"alert-title\"\n      className={cn(\n        'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDescription({\n  className,\n  ...props\n}: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"alert-description\"\n      className={cn(\n        'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Alert, AlertTitle, AlertDescription };\n"
  },
  {
    "path": "web/src/components/ui/badge.tsx",
    "content": "import * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/lib/utils';\n\nconst badgeVariants = cva(\n  'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',\n  {\n    variants: {\n      variant: {\n        default:\n          'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',\n        secondary:\n          'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',\n        destructive:\n          'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',\n        outline:\n          'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  },\n);\n\nfunction Badge({\n  className,\n  variant,\n  asChild = false,\n  ...props\n}: React.ComponentProps<'span'> &\n  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : 'span';\n\n  return (\n    <Comp\n      data-slot=\"badge\"\n      className={cn(badgeVariants({ variant }), className)}\n      {...props}\n    />\n  );\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "web/src/components/ui/breadcrumb.tsx",
    "content": "import * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { ChevronRight, MoreHorizontal } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {\n  return <nav aria-label=\"breadcrumb\" data-slot=\"breadcrumb\" {...props} />;\n}\n\nfunction BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {\n  return (\n    <ol\n      data-slot=\"breadcrumb-list\"\n      className={cn(\n        'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {\n  return (\n    <li\n      data-slot=\"breadcrumb-item\"\n      className={cn('inline-flex items-center gap-1.5', className)}\n      {...props}\n    />\n  );\n}\n\nfunction BreadcrumbLink({\n  asChild,\n  className,\n  ...props\n}: React.ComponentProps<'a'> & {\n  asChild?: boolean;\n}) {\n  const Comp = asChild ? Slot : 'a';\n\n  return (\n    <Comp\n      data-slot=\"breadcrumb-link\"\n      className={cn('hover:text-foreground transition-colors', className)}\n      {...props}\n    />\n  );\n}\n\nfunction BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {\n  return (\n    <span\n      data-slot=\"breadcrumb-page\"\n      role=\"link\"\n      aria-disabled=\"true\"\n      aria-current=\"page\"\n      className={cn('text-foreground font-normal', className)}\n      {...props}\n    />\n  );\n}\n\nfunction BreadcrumbSeparator({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<'li'>) {\n  return (\n    <li\n      data-slot=\"breadcrumb-separator\"\n      role=\"presentation\"\n      aria-hidden=\"true\"\n      className={cn('[&>svg]:size-3.5', className)}\n      {...props}\n    >\n      {children ?? <ChevronRight />}\n    </li>\n  );\n}\n\nfunction BreadcrumbEllipsis({\n  className,\n  ...props\n}: React.ComponentProps<'span'>) {\n  return (\n    <span\n      data-slot=\"breadcrumb-ellipsis\"\n      role=\"presentation\"\n      aria-hidden=\"true\"\n      className={cn('flex size-9 items-center justify-center', className)}\n      {...props}\n    >\n      <MoreHorizontal className=\"size-4\" />\n      <span className=\"sr-only\">More</span>\n    </span>\n  );\n}\n\nexport {\n  Breadcrumb,\n  BreadcrumbList,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n  BreadcrumbEllipsis,\n};\n"
  },
  {
    "path": "web/src/components/ui/button.tsx",
    "content": "import * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/lib/utils';\n\nconst buttonVariants = cva(\n  \"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n  {\n    variants: {\n      variant: {\n        default:\n          'bg-[#2288ee] text-primary-foreground shadow-xs hover:bg-[#2277e0]',\n        destructive:\n          'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',\n        outline:\n          'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',\n        secondary:\n          'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',\n        ghost:\n          'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/100',\n        link: 'text-primary underline-offset-4 hover:underline',\n      },\n      size: {\n        default: 'h-9 px-4 py-2 has-[>svg]:px-3',\n        sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',\n        lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',\n        icon: 'size-9',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  },\n);\n\nfunction Button({\n  className,\n  variant,\n  size,\n  asChild = false,\n  ...props\n}: React.ComponentProps<'button'> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean;\n  }) {\n  const Comp = asChild ? Slot : 'button';\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  );\n}\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "web/src/components/ui/card.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Card({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card\"\n      className={cn(\n        'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-header\"\n      className={cn(\n        '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-title\"\n      className={cn('leading-none font-semibold', className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-description\"\n      className={cn('text-muted-foreground text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-action\"\n      className={cn(\n        'col-start-2 row-span-2 row-start-1 self-start justify-self-end',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-content\"\n      className={cn('px-6', className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-footer\"\n      className={cn('flex items-center px-6 [.border-t]:pt-6', className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardAction,\n  CardDescription,\n  CardContent,\n};\n"
  },
  {
    "path": "web/src/components/ui/checkbox.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as CheckboxPrimitive from '@radix-ui/react-checkbox';\nimport { CheckIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Checkbox({\n  className,\n  ...props\n}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {\n  return (\n    <CheckboxPrimitive.Root\n      data-slot=\"checkbox\"\n      className={cn(\n        'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-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 size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n        className,\n      )}\n      {...props}\n    >\n      <CheckboxPrimitive.Indicator\n        data-slot=\"checkbox-indicator\"\n        className=\"flex items-center justify-center text-current transition-none\"\n      >\n        <CheckIcon className=\"size-3.5\" />\n      </CheckboxPrimitive.Indicator>\n    </CheckboxPrimitive.Root>\n  );\n}\n\nexport { Checkbox };\n"
  },
  {
    "path": "web/src/components/ui/collapsible.tsx",
    "content": "'use client';\n\nimport * as CollapsiblePrimitive from '@radix-ui/react-collapsible';\n\nfunction Collapsible({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {\n  return <CollapsiblePrimitive.Root data-slot=\"collapsible\" {...props} />;\n}\n\nfunction CollapsibleTrigger({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleTrigger\n      data-slot=\"collapsible-trigger\"\n      {...props}\n    />\n  );\n}\n\nfunction CollapsibleContent({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleContent\n      data-slot=\"collapsible-content\"\n      {...props}\n    />\n  );\n}\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent };\n"
  },
  {
    "path": "web/src/components/ui/context-menu.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as ContextMenuPrimitive from '@radix-ui/react-context-menu';\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction ContextMenu({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {\n  return <ContextMenuPrimitive.Root data-slot=\"context-menu\" {...props} />;\n}\n\nfunction ContextMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {\n  return (\n    <ContextMenuPrimitive.Trigger data-slot=\"context-menu-trigger\" {...props} />\n  );\n}\n\nfunction ContextMenuGroup({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {\n  return (\n    <ContextMenuPrimitive.Group data-slot=\"context-menu-group\" {...props} />\n  );\n}\n\nfunction ContextMenuPortal({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {\n  return (\n    <ContextMenuPrimitive.Portal data-slot=\"context-menu-portal\" {...props} />\n  );\n}\n\nfunction ContextMenuSub({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {\n  return <ContextMenuPrimitive.Sub data-slot=\"context-menu-sub\" {...props} />;\n}\n\nfunction ContextMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {\n  return (\n    <ContextMenuPrimitive.RadioGroup\n      data-slot=\"context-menu-radio-group\"\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {\n  inset?: boolean;\n}) {\n  return (\n    <ContextMenuPrimitive.SubTrigger\n      data-slot=\"context-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto\" />\n    </ContextMenuPrimitive.SubTrigger>\n  );\n}\n\nfunction ContextMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {\n  return (\n    <ContextMenuPrimitive.SubContent\n      data-slot=\"context-menu-sub-content\"\n      className={cn(\n        'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {\n  return (\n    <ContextMenuPrimitive.Portal>\n      <ContextMenuPrimitive.Content\n        data-slot=\"context-menu-content\"\n        className={cn(\n          'bg-popover text-popover-foreground 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',\n          className,\n        )}\n        {...props}\n      />\n    </ContextMenuPrimitive.Portal>\n  );\n}\n\nfunction ContextMenuItem({\n  className,\n  inset,\n  variant = 'default',\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {\n  inset?: boolean;\n  variant?: 'default' | 'destructive';\n}) {\n  return (\n    <ContextMenuPrimitive.Item\n      data-slot=\"context-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {\n  return (\n    <ContextMenuPrimitive.CheckboxItem\n      data-slot=\"context-menu-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.CheckboxItem>\n  );\n}\n\nfunction ContextMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {\n  return (\n    <ContextMenuPrimitive.RadioItem\n      data-slot=\"context-menu-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.RadioItem>\n  );\n}\n\nfunction ContextMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {\n  inset?: boolean;\n}) {\n  return (\n    <ContextMenuPrimitive.Label\n      data-slot=\"context-menu-label\"\n      data-inset={inset}\n      className={cn(\n        'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {\n  return (\n    <ContextMenuPrimitive.Separator\n      data-slot=\"context-menu-separator\"\n      className={cn('bg-border -mx-1 my-1 h-px', className)}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<'span'>) {\n  return (\n    <span\n      data-slot=\"context-menu-shortcut\"\n      className={cn(\n        'text-muted-foreground ml-auto text-xs tracking-widest',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  ContextMenu,\n  ContextMenuTrigger,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuCheckboxItem,\n  ContextMenuRadioItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuGroup,\n  ContextMenuPortal,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuRadioGroup,\n};\n"
  },
  {
    "path": "web/src/components/ui/dialog.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as DialogPrimitive from '@radix-ui/react-dialog';\nimport { XIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Dialog({\n  onOpenChange,\n  open,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  const handleOpenChange = React.useCallback(\n    (isOpen: boolean) => {\n      onOpenChange?.(isOpen);\n\n      // 当对话框关闭时，确保清理 body 样式\n      if (!isOpen) {\n        // 立即清理\n        document.body.style.removeProperty('pointer-events');\n        document.body.style.removeProperty('overflow');\n\n        // 延迟再次清理，确保覆盖 Radix 的设置\n        setTimeout(() => {\n          document.body.style.removeProperty('pointer-events');\n          document.body.style.removeProperty('overflow');\n        }, 0);\n\n        setTimeout(() => {\n          document.body.style.removeProperty('pointer-events');\n          document.body.style.removeProperty('overflow');\n        }, 50);\n\n        setTimeout(() => {\n          document.body.style.removeProperty('pointer-events');\n          document.body.style.removeProperty('overflow');\n        }, 150);\n      }\n    },\n    [onOpenChange],\n  );\n\n  // 使用 effect 监控 open 状态变化\n  React.useEffect(() => {\n    if (open === false) {\n      const cleanup = () => {\n        document.body.style.removeProperty('pointer-events');\n        document.body.style.removeProperty('overflow');\n      };\n\n      cleanup();\n      const timer1 = setTimeout(cleanup, 0);\n      const timer2 = setTimeout(cleanup, 50);\n      const timer3 = setTimeout(cleanup, 150);\n      const timer4 = setTimeout(cleanup, 300);\n\n      return () => {\n        clearTimeout(timer1);\n        clearTimeout(timer2);\n        clearTimeout(timer3);\n        clearTimeout(timer4);\n      };\n    }\n  }, [open]);\n\n  return (\n    <DialogPrimitive.Root\n      data-slot=\"dialog\"\n      open={open}\n      {...props}\n      onOpenChange={handleOpenChange}\n    />\n  );\n}\n\nfunction DialogTrigger({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />;\n}\n\nfunction DialogPortal({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />;\n}\n\nfunction DialogClose({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />;\n}\n\nfunction DialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"dialog-overlay\"\n      className={cn(\n        'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DialogContent({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content>) {\n  return (\n    <DialogPortal data-slot=\"dialog-portal\">\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        data-slot=\"dialog-content\"\n        className={cn(\n          'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        <DialogPrimitive.Close className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\">\n          <XIcon />\n          <span className=\"sr-only\">Close</span>\n        </DialogPrimitive.Close>\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  );\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"dialog-header\"\n      className={cn('flex flex-col gap-2 text-center sm:text-left', className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"dialog-footer\"\n      className={cn(\n        'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"dialog-title\"\n      className={cn('text-lg leading-none font-semibold', className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"dialog-description\"\n      className={cn('text-muted-foreground text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger,\n};\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction DropdownMenu({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />;\n}\n\nfunction DropdownMenuPortal({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return (\n    <DropdownMenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />\n  );\n}\n\nfunction DropdownMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return (\n    <DropdownMenuPrimitive.Trigger\n      data-slot=\"dropdown-menu-trigger\"\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuContent({\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        data-slot=\"dropdown-menu-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          'bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',\n          className,\n        )}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  );\n}\n\nfunction DropdownMenuGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {\n  return (\n    <DropdownMenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />\n  );\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = 'default',\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean;\n  variant?: 'default' | 'destructive';\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      data-slot=\"dropdown-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      data-slot=\"dropdown-menu-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.CheckboxItem>\n  );\n}\n\nfunction DropdownMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {\n  return (\n    <DropdownMenuPrimitive.RadioGroup\n      data-slot=\"dropdown-menu-radio-group\"\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {\n  return (\n    <DropdownMenuPrimitive.RadioItem\n      data-slot=\"dropdown-menu-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.RadioItem>\n  );\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean;\n}) {\n  return (\n    <DropdownMenuPrimitive.Label\n      data-slot=\"dropdown-menu-label\"\n      data-inset={inset}\n      className={cn(\n        'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      data-slot=\"dropdown-menu-separator\"\n      className={cn('bg-border -mx-1 my-1 h-px', className)}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<'span'>) {\n  return (\n    <span\n      data-slot=\"dropdown-menu-shortcut\"\n      className={cn(\n        'text-muted-foreground ml-auto text-xs tracking-widest',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuSub({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {\n  return <DropdownMenuPrimitive.Sub data-slot=\"dropdown-menu-sub\" {...props} />;\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean;\n}) {\n  return (\n    <DropdownMenuPrimitive.SubTrigger\n      data-slot=\"dropdown-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto size-4\" />\n    </DropdownMenuPrimitive.SubTrigger>\n  );\n}\n\nfunction DropdownMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {\n  return (\n    <DropdownMenuPrimitive.SubContent\n      data-slot=\"dropdown-menu-sub-content\"\n      className={cn(\n        'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n};\n"
  },
  {
    "path": "web/src/components/ui/emoji-picker.tsx",
    "content": "import React, { useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\n\ninterface EmojiPickerProps {\n  value?: string;\n  onChange: (emoji: string) => void;\n  disabled?: boolean;\n}\n\n// 扩展的emoji分类\nconst EMOJI_CATEGORIES = {\n  common: [\n    '⚙️',\n    '📚',\n    '🔗',\n    '📁',\n    '💡',\n    '🎯',\n    '✨',\n    '🚀',\n    '📝',\n    '🔧',\n    '⚡',\n    '🔥',\n    '💎',\n    '🎨',\n    '🎭',\n  ],\n  objects: [\n    '📦',\n    '📂',\n    '📋',\n    '📌',\n    '🔖',\n    '💼',\n    '🗂️',\n    '📮',\n    '🗃️',\n    '📊',\n    '📈',\n    '📉',\n    '🗄️',\n    '📇',\n    '🗳️',\n  ],\n  symbols: [\n    '🔴',\n    '🟠',\n    '🟡',\n    '🟢',\n    '🔵',\n    '🟣',\n    '⚪',\n    '⚫',\n    '🟤',\n    '🔺',\n    '🔻',\n    '🔶',\n    '🔷',\n    '🔸',\n    '🔹',\n  ],\n  nature: [\n    '🌟',\n    '⭐',\n    '🌈',\n    '💧',\n    '🌍',\n    '🌙',\n    '☀️',\n    '🌱',\n    '🌲',\n    '🌳',\n    '🌴',\n    '🌵',\n    '🌾',\n    '🍀',\n    '🌻',\n  ],\n  faces: [\n    '😀',\n    '😊',\n    '🤔',\n    '😎',\n    '🤖',\n    '👾',\n    '💬',\n    '💭',\n    '❤️',\n    '⚠️',\n    '✅',\n    '❌',\n    '🎉',\n    '🎊',\n    '🎈',\n  ],\n  tech: [\n    '💻',\n    '📱',\n    '⌨️',\n    '🖥️',\n    '🖱️',\n    '💾',\n    '💿',\n    '📀',\n    '🔌',\n    '🔋',\n    '📡',\n    '🛰️',\n    '🖨️',\n    '🖲️',\n    '💽',\n  ],\n  science: [\n    '🔬',\n    '🔭',\n    '⚗️',\n    '🧪',\n    '🧬',\n    '🧫',\n    '🩺',\n    '💊',\n    '💉',\n    '🌡️',\n    '🧲',\n    '⚛️',\n    '🧬',\n    '🦠',\n    '🧫',\n  ],\n  business: [\n    '💼',\n    '📊',\n    '📈',\n    '💰',\n    '💵',\n    '💴',\n    '💶',\n    '💷',\n    '💳',\n    '💸',\n    '📉',\n    '💹',\n    '🏦',\n    '🏢',\n    '🏭',\n  ],\n};\n\nconst CATEGORY_LABELS: { [key: string]: string } = {\n  common: '常用',\n  objects: '物品',\n  symbols: '符号',\n  nature: '自然',\n  faces: '表情',\n  tech: '科技',\n  science: '科学',\n  business: '商业',\n};\n\n// 每个分类的代表性 emoji（用于分页按钮）\nconst CATEGORY_ICONS: { [key: string]: string } = {\n  common: '⭐',\n  objects: '📦',\n  symbols: '🔴',\n  nature: '🌟',\n  faces: '😀',\n  tech: '💻',\n  science: '🔬',\n  business: '💼',\n};\n\nexport default function EmojiPicker({\n  value,\n  onChange,\n  disabled,\n}: EmojiPickerProps) {\n  const [open, setOpen] = useState(false);\n  const [activeCategory, setActiveCategory] = useState<string>('common');\n\n  const handleEmojiSelect = (emoji: string) => {\n    onChange(emoji);\n    setOpen(false);\n  };\n\n  const currentEmojis =\n    EMOJI_CATEGORIES[activeCategory as keyof typeof EMOJI_CATEGORIES];\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          disabled={disabled}\n          className=\"w-16 h-16 text-3xl p-0 hover:bg-gray-100 dark:hover:bg-gray-800\"\n          type=\"button\"\n        >\n          {value || '😀'}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-80 p-4\" align=\"start\">\n        <div className=\"space-y-3\">\n          {/* 分类标题 */}\n          <h3 className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">\n            {CATEGORY_LABELS[activeCategory]}\n          </h3>\n\n          {/* Emoji 网格 */}\n          <div className=\"grid grid-cols-6 gap-1\">\n            {currentEmojis.map((emoji, index) => (\n              <button\n                key={`${activeCategory}-${index}`}\n                type=\"button\"\n                onClick={() => handleEmojiSelect(emoji)}\n                className={`w-10 h-10 text-xl rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors flex items-center justify-center ${\n                  value === emoji ? 'bg-gray-200 dark:bg-gray-700' : ''\n                }`}\n              >\n                {emoji}\n              </button>\n            ))}\n          </div>\n\n          {/* 分类切换按钮 */}\n          <div className=\"pt-2 border-t border-gray-200 dark:border-gray-700\">\n            <div className=\"flex justify-center gap-1\">\n              {Object.keys(EMOJI_CATEGORIES).map((category) => (\n                <button\n                  key={category}\n                  type=\"button\"\n                  onClick={() => setActiveCategory(category)}\n                  className={`w-7 h-7 text-base rounded transition-colors flex items-center justify-center ${\n                    activeCategory === category\n                      ? 'bg-gray-200 dark:bg-gray-700'\n                      : 'hover:bg-gray-100 dark:hover:bg-gray-800'\n                  }`}\n                  title={CATEGORY_LABELS[category]}\n                >\n                  {CATEGORY_ICONS[category]}\n                </button>\n              ))}\n            </div>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "web/src/components/ui/form.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as LabelPrimitive from '@radix-ui/react-label';\nimport { Slot } from '@radix-ui/react-slot';\nimport {\n  Controller,\n  FormProvider,\n  useFormContext,\n  useFormState,\n  type ControllerProps,\n  type FieldPath,\n  type FieldValues,\n} from 'react-hook-form';\n\nimport { cn } from '@/lib/utils';\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 } = useFormContext();\n  const formState = useFormState({ name: fieldContext.name });\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\nfunction FormItem({ className, ...props }: React.ComponentProps<'div'>) {\n  const id = React.useId();\n\n  return (\n    <FormItemContext.Provider value={{ id }}>\n      <div\n        data-slot=\"form-item\"\n        className={cn('grid gap-2', className)}\n        {...props}\n      />\n    </FormItemContext.Provider>\n  );\n}\n\nfunction FormLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  const { error, formItemId } = useFormField();\n\n  return (\n    <Label\n      data-slot=\"form-label\"\n      data-error={!!error}\n      className={cn('data-[error=true]:text-destructive', className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  );\n}\n\nfunction FormControl({ ...props }: React.ComponentProps<typeof Slot>) {\n  const { error, formItemId, formDescriptionId, formMessageId } =\n    useFormField();\n\n  return (\n    <Slot\n      data-slot=\"form-control\"\n      id={formItemId}\n      aria-describedby={\n        !error\n          ? `${formDescriptionId}`\n          : `${formDescriptionId} ${formMessageId}`\n      }\n      aria-invalid={!!error}\n      {...props}\n    />\n  );\n}\n\nfunction FormDescription({ className, ...props }: React.ComponentProps<'p'>) {\n  const { formDescriptionId } = useFormField();\n\n  return (\n    <p\n      data-slot=\"form-description\"\n      id={formDescriptionId}\n      className={cn('text-muted-foreground text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nfunction FormMessage({ className, ...props }: React.ComponentProps<'p'>) {\n  const { error, formMessageId } = useFormField();\n  const body = error ? String(error?.message ?? '') : props.children;\n\n  if (!body) {\n    return null;\n  }\n\n  return (\n    <p\n      data-slot=\"form-message\"\n      id={formMessageId}\n      className={cn('text-destructive text-sm', className)}\n      {...props}\n    >\n      {body}\n    </p>\n  );\n}\n\nexport {\n  useFormField,\n  Form,\n  FormItem,\n  FormLabel,\n  FormControl,\n  FormDescription,\n  FormMessage,\n  FormField,\n};\n"
  },
  {
    "path": "web/src/components/ui/hover-card.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as HoverCardPrimitive from '@radix-ui/react-hover-card';\n\nimport { cn } from '@/lib/utils';\n\nfunction HoverCard({\n  ...props\n}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {\n  return <HoverCardPrimitive.Root data-slot=\"hover-card\" {...props} />;\n}\n\nfunction HoverCardTrigger({\n  ...props\n}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {\n  return (\n    <HoverCardPrimitive.Trigger data-slot=\"hover-card-trigger\" {...props} />\n  );\n}\n\nfunction HoverCardContent({\n  className,\n  align = 'center',\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {\n  return (\n    <HoverCardPrimitive.Portal data-slot=\"hover-card-portal\">\n      <HoverCardPrimitive.Content\n        data-slot=\"hover-card-content\"\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          'bg-popover text-popover-foreground 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',\n          className,\n        )}\n        {...props}\n      />\n    </HoverCardPrimitive.Portal>\n  );\n}\n\nexport { HoverCard, HoverCardTrigger, HoverCardContent };\n"
  },
  {
    "path": "web/src/components/ui/input-otp.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport { OTPInput, OTPInputContext } from 'input-otp';\nimport { MinusIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction InputOTP({\n  className,\n  containerClassName,\n  ...props\n}: React.ComponentProps<typeof OTPInput> & {\n  containerClassName?: string;\n}) {\n  return (\n    <OTPInput\n      data-slot=\"input-otp\"\n      containerClassName={cn(\n        'flex items-center gap-2 has-disabled:opacity-50',\n        containerClassName,\n      )}\n      className={cn('disabled:cursor-not-allowed', className)}\n      {...props}\n    />\n  );\n}\n\nfunction InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"input-otp-group\"\n      className={cn('flex items-center', className)}\n      {...props}\n    />\n  );\n}\n\nfunction InputOTPSlot({\n  index,\n  className,\n  ...props\n}: React.ComponentProps<'div'> & {\n  index: number;\n}) {\n  const inputOTPContext = React.useContext(OTPInputContext);\n  const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};\n\n  return (\n    <div\n      data-slot=\"input-otp-slot\"\n      data-active={isActive}\n      className={cn(\n        'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',\n        className,\n      )}\n      {...props}\n    >\n      {char}\n      {hasFakeCaret && (\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n          <div className=\"animate-caret-blink bg-foreground h-4 w-px duration-1000\" />\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div data-slot=\"input-otp-separator\" role=\"separator\" {...props}>\n      <MinusIcon />\n    </div>\n  );\n}\n\nexport { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };\n"
  },
  {
    "path": "web/src/components/ui/input.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Input({ className, type, ...props }: React.ComponentProps<'input'>) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',\n        'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',\n        'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Input };\n"
  },
  {
    "path": "web/src/components/ui/item.tsx",
    "content": "import * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/lib/utils';\nimport { Separator } from '@/components/ui/separator';\n\nfunction ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      role=\"list\"\n      data-slot=\"item-group\"\n      className={cn('group/item-group flex flex-col', className)}\n      {...props}\n    />\n  );\n}\n\nfunction ItemSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"item-separator\"\n      orientation=\"horizontal\"\n      className={cn('my-0', className)}\n      {...props}\n    />\n  );\n}\n\nconst itemVariants = cva(\n  'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',\n  {\n    variants: {\n      variant: {\n        default: 'bg-transparent',\n        outline: 'border-border',\n        muted: 'bg-muted/50',\n      },\n      size: {\n        default: 'p-4 gap-4 ',\n        sm: 'py-3 px-4 gap-2.5',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  },\n);\n\nfunction Item({\n  className,\n  variant = 'default',\n  size = 'default',\n  asChild = false,\n  ...props\n}: React.ComponentProps<'div'> &\n  VariantProps<typeof itemVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : 'div';\n  return (\n    <Comp\n      data-slot=\"item\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(itemVariants({ variant, size, className }))}\n      {...props}\n    />\n  );\n}\n\nconst itemMediaVariants = cva(\n  'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',\n  {\n    variants: {\n      variant: {\n        default: 'bg-transparent',\n        icon: \"size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4\",\n        image:\n          'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  },\n);\n\nfunction ItemMedia({\n  className,\n  variant = 'default',\n  ...props\n}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {\n  return (\n    <div\n      data-slot=\"item-media\"\n      data-variant={variant}\n      className={cn(itemMediaVariants({ variant, className }))}\n      {...props}\n    />\n  );\n}\n\nfunction ItemContent({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"item-content\"\n      className={cn(\n        'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"item-title\"\n      className={cn(\n        'flex w-fit items-center gap-2 text-sm leading-snug font-medium',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {\n  return (\n    <p\n      data-slot=\"item-description\"\n      className={cn(\n        'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',\n        '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ItemActions({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"item-actions\"\n      className={cn('flex items-center gap-2', className)}\n      {...props}\n    />\n  );\n}\n\nfunction ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"item-header\"\n      className={cn(\n        'flex basis-full items-center justify-between gap-2',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"item-footer\"\n      className={cn(\n        'flex basis-full items-center justify-between gap-2',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemActions,\n  ItemGroup,\n  ItemSeparator,\n  ItemTitle,\n  ItemDescription,\n  ItemHeader,\n  ItemFooter,\n};\n"
  },
  {
    "path": "web/src/components/ui/label.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as LabelPrimitive from '@radix-ui/react-label';\n\nimport { cn } from '@/lib/utils';\n\nfunction Label({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  return (\n    <LabelPrimitive.Root\n      data-slot=\"label\"\n      className={cn(\n        'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Label };\n"
  },
  {
    "path": "web/src/components/ui/language-selector.tsx",
    "content": "'use client';\n\nimport { useState, useEffect } from 'react';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { Globe } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport i18n from '@/i18n';\n\ninterface LanguageSelectorProps {\n  className?: string;\n  triggerClassName?: string;\n  onOpenChange?: (open: boolean) => void;\n}\n\nexport function LanguageSelector({\n  triggerClassName,\n  onOpenChange,\n}: LanguageSelectorProps) {\n  const { t } = useTranslation();\n  const [currentLanguage, setCurrentLanguage] = useState<string>(i18n.language);\n\n  useEffect(() => {\n    initializeLanguage();\n  }, []);\n\n  const initializeLanguage = () => {\n    if (i18n.language === 'zh-CN' || i18n.language === 'zh-Hans') {\n      setCurrentLanguage('zh-Hans');\n      localStorage.setItem('langbot_language', 'zh-Hans');\n    } else if (i18n.language === 'zh-TW' || i18n.language === 'zh-Hant') {\n      setCurrentLanguage('zh-Hant');\n      localStorage.setItem('langbot_language', 'zh-Hant');\n    } else if (i18n.language === 'ja' || i18n.language === 'ja-JP') {\n      setCurrentLanguage('ja-JP');\n      localStorage.setItem('langbot_language', 'ja-JP');\n    } else {\n      setCurrentLanguage('en-US');\n      localStorage.setItem('langbot_language', 'en-US');\n    }\n\n    const savedLanguage = localStorage.getItem('langbot_language');\n    if (savedLanguage) {\n      i18n.changeLanguage(savedLanguage);\n      setCurrentLanguage(savedLanguage);\n    } else {\n      const browserLanguage = navigator.language;\n      if (browserLanguage) {\n        let detectedLanguage = 'zh-Hans';\n        if (browserLanguage === 'zh-CN') {\n          detectedLanguage = 'zh-Hans';\n        } else if (browserLanguage === 'zh-TW') {\n          detectedLanguage = 'zh-Hant';\n        } else if (browserLanguage === 'ja' || browserLanguage === 'ja-JP') {\n          detectedLanguage = 'ja-JP';\n        } else {\n          detectedLanguage = 'en-US';\n        }\n        i18n.changeLanguage(detectedLanguage);\n        setCurrentLanguage(detectedLanguage);\n        localStorage.setItem('langbot_language', detectedLanguage);\n      }\n    }\n  };\n\n  const handleLanguageChange = (value: string) => {\n    i18n.changeLanguage(value);\n    setCurrentLanguage(value);\n    localStorage.setItem('langbot_language', value);\n\n    // 刷新页面以应用新的语言设置\n    window.location.reload();\n  };\n\n  return (\n    <Select\n      value={currentLanguage}\n      onValueChange={handleLanguageChange}\n      onOpenChange={onOpenChange}\n    >\n      <SelectTrigger className={triggerClassName || 'w-[140px]'}>\n        <Globe className=\"h-4 w-4 mr-2\" />\n        <SelectValue placeholder={t('common.language')} />\n      </SelectTrigger>\n      <SelectContent>\n        <SelectItem value=\"zh-Hans\">简体中文</SelectItem>\n        <SelectItem value=\"zh-Hant\">繁體中文</SelectItem>\n        <SelectItem value=\"en-US\">English</SelectItem>\n        <SelectItem value=\"ja-JP\">日本語</SelectItem>\n      </SelectContent>\n    </Select>\n  );\n}\n"
  },
  {
    "path": "web/src/components/ui/loading-spinner.tsx",
    "content": "import { Loader2 } from 'lucide-react';\nimport { cn } from '@/lib/utils';\n\ninterface LoadingSpinnerProps {\n  /**\n   * Size variant of the spinner\n   * @default 'default'\n   */\n  size?: 'sm' | 'default' | 'lg';\n  /**\n   * Additional CSS classes\n   */\n  className?: string;\n  /**\n   * Loading text to display below the spinner\n   */\n  text?: string;\n  /**\n   * Whether to display as full page overlay\n   * @default false\n   */\n  fullPage?: boolean;\n}\n\nconst sizeMap = {\n  sm: 'h-4 w-4',\n  default: 'h-8 w-8',\n  lg: 'h-12 w-12',\n};\n\nconst textSizeMap = {\n  sm: 'text-xs',\n  default: 'text-sm',\n  lg: 'text-base',\n};\n\nexport function LoadingSpinner({\n  size = 'default',\n  className,\n  text = '加载中...',\n  fullPage = false,\n}: LoadingSpinnerProps) {\n  const spinner = (\n    <div className=\"flex flex-col items-center gap-4\">\n      <Loader2\n        className={cn('animate-spin text-primary', sizeMap[size], className)}\n      />\n      {text && (\n        <p className={cn('text-muted-foreground', textSizeMap[size])}>{text}</p>\n      )}\n    </div>\n  );\n\n  if (fullPage) {\n    return (\n      <div className=\"fixed inset-0 flex items-center justify-center bg-background\">\n        {spinner}\n      </div>\n    );\n  }\n\n  return spinner;\n}\n\n/**\n * Full page loading component for use in page.tsx or layout.tsx\n */\nexport function LoadingPage({ text }: { text?: string }) {\n  return <LoadingSpinner fullPage text={text} />;\n}\n\n/**\n * Inline loading component for use within components\n */\nexport function LoadingInline({\n  size,\n  text,\n}: {\n  size?: 'sm' | 'default' | 'lg';\n  text?: string;\n}) {\n  return <LoadingSpinner size={size} text={text} />;\n}\n"
  },
  {
    "path": "web/src/components/ui/pagination.tsx",
    "content": "import * as React from 'react';\nimport {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  MoreHorizontalIcon,\n} from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\nimport { Button, buttonVariants } from '@/components/ui/button';\n\nfunction Pagination({ className, ...props }: React.ComponentProps<'nav'>) {\n  return (\n    <nav\n      role=\"navigation\"\n      aria-label=\"pagination\"\n      data-slot=\"pagination\"\n      className={cn('mx-auto flex w-full justify-center', className)}\n      {...props}\n    />\n  );\n}\n\nfunction PaginationContent({\n  className,\n  ...props\n}: React.ComponentProps<'ul'>) {\n  return (\n    <ul\n      data-slot=\"pagination-content\"\n      className={cn('flex flex-row items-center gap-1', className)}\n      {...props}\n    />\n  );\n}\n\nfunction PaginationItem({ ...props }: React.ComponentProps<'li'>) {\n  return <li data-slot=\"pagination-item\" {...props} />;\n}\n\ntype PaginationLinkProps = {\n  isActive?: boolean;\n} & Pick<React.ComponentProps<typeof Button>, 'size'> &\n  React.ComponentProps<'a'>;\n\nfunction PaginationLink({\n  className,\n  isActive,\n  size = 'icon',\n  ...props\n}: PaginationLinkProps) {\n  return (\n    <a\n      aria-current={isActive ? 'page' : undefined}\n      data-slot=\"pagination-link\"\n      data-active={isActive}\n      className={cn(\n        buttonVariants({\n          variant: isActive ? 'outline' : 'ghost',\n          size,\n        }),\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction PaginationPrevious({\n  className,\n  ...props\n}: React.ComponentProps<typeof PaginationLink>) {\n  return (\n    <PaginationLink\n      aria-label=\"Go to previous page\"\n      size=\"default\"\n      className={cn('gap-1 px-2.5 sm:pl-2.5', className)}\n      {...props}\n    >\n      <ChevronLeftIcon className=\"text-black dark:text-white\" />\n      <span className=\"hidden sm:block\"></span>\n    </PaginationLink>\n  );\n}\n\nfunction PaginationNext({\n  className,\n  ...props\n}: React.ComponentProps<typeof PaginationLink>) {\n  return (\n    <PaginationLink\n      aria-label=\"Go to next page\"\n      size=\"default\"\n      className={cn('gap-1 px-2.5 sm:pr-2.5', className)}\n      {...props}\n    >\n      <span className=\"hidden sm:block\"></span>\n      <ChevronRightIcon className=\"text-black dark:text-white\" />\n    </PaginationLink>\n  );\n}\n\nfunction PaginationEllipsis({\n  className,\n  ...props\n}: React.ComponentProps<'span'>) {\n  return (\n    <span\n      aria-hidden\n      data-slot=\"pagination-ellipsis\"\n      className={cn('flex size-9 items-center justify-center', className)}\n      {...props}\n    >\n      <MoreHorizontalIcon className=\"size-4\" />\n      <span className=\"sr-only\">More pages</span>\n    </span>\n  );\n}\n\nexport {\n  Pagination,\n  PaginationContent,\n  PaginationLink,\n  PaginationItem,\n  PaginationPrevious,\n  PaginationNext,\n  PaginationEllipsis,\n};\n"
  },
  {
    "path": "web/src/components/ui/popover.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\n\nimport { cn } from '@/lib/utils';\n\nfunction Popover({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Root>) {\n  return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />;\n}\n\nfunction PopoverTrigger({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {\n  return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />;\n}\n\nfunction PopoverContent({\n  className,\n  align = 'center',\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Content>) {\n  return (\n    <PopoverPrimitive.Portal>\n      <PopoverPrimitive.Content\n        data-slot=\"popover-content\"\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          'bg-popover text-popover-foreground 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',\n          className,\n        )}\n        {...props}\n      />\n    </PopoverPrimitive.Portal>\n  );\n}\n\nfunction PopoverAnchor({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {\n  return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />;\n}\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };\n"
  },
  {
    "path": "web/src/components/ui/scroll-area.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';\n\nimport { cn } from '@/lib/utils';\n\nfunction ScrollArea({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {\n  return (\n    <ScrollAreaPrimitive.Root\n      data-slot=\"scroll-area\"\n      className={cn('relative', className)}\n      {...props}\n    >\n      <ScrollAreaPrimitive.Viewport\n        data-slot=\"scroll-area-viewport\"\n        className=\"focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1\"\n      >\n        {children}\n      </ScrollAreaPrimitive.Viewport>\n      <ScrollBar />\n      <ScrollAreaPrimitive.Corner />\n    </ScrollAreaPrimitive.Root>\n  );\n}\n\nfunction ScrollBar({\n  className,\n  orientation = 'vertical',\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {\n  return (\n    <ScrollAreaPrimitive.ScrollAreaScrollbar\n      data-slot=\"scroll-area-scrollbar\"\n      orientation={orientation}\n      className={cn(\n        'flex touch-none p-px transition-colors select-none',\n        orientation === 'vertical' &&\n          'h-full w-2.5 border-l border-l-transparent',\n        orientation === 'horizontal' &&\n          'h-2.5 flex-col border-t border-t-transparent',\n        className,\n      )}\n      {...props}\n    >\n      <ScrollAreaPrimitive.ScrollAreaThumb\n        data-slot=\"scroll-area-thumb\"\n        className=\"bg-border relative flex-1 rounded-full\"\n      />\n    </ScrollAreaPrimitive.ScrollAreaScrollbar>\n  );\n}\n\nexport { ScrollArea, ScrollBar };\n"
  },
  {
    "path": "web/src/components/ui/select.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as SelectPrimitive from '@radix-ui/react-select';\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Select({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />;\n}\n\nfunction SelectGroup({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return <SelectPrimitive.Group data-slot=\"select-group\" {...props} />;\n}\n\nfunction SelectValue({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />;\n}\n\nfunction SelectTrigger({\n  className,\n  size = 'default',\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: 'sm' | 'default';\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground 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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className=\"size-4 opacity-50\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  );\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = 'popper',\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        className={cn(\n          'bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',\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)] scroll-my-1',\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  );\n}\n\nfunction SelectLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  );\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot=\"select-scroll-up-button\"\n      className={cn(\n        'flex cursor-default items-center justify-center py-1',\n        className,\n      )}\n      {...props}\n    >\n      <ChevronUpIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollUpButton>\n  );\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot=\"select-scroll-down-button\"\n      className={cn(\n        'flex cursor-default items-center justify-center py-1',\n        className,\n      )}\n      {...props}\n    >\n      <ChevronDownIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollDownButton>\n  );\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n};\n"
  },
  {
    "path": "web/src/components/ui/separator.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as SeparatorPrimitive from '@radix-ui/react-separator';\n\nimport { cn } from '@/lib/utils';\n\nfunction Separator({\n  className,\n  orientation = 'horizontal',\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Separator };\n"
  },
  {
    "path": "web/src/components/ui/sheet.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as SheetPrimitive from '@radix-ui/react-dialog';\nimport { XIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {\n  return <SheetPrimitive.Root data-slot=\"sheet\" {...props} />;\n}\n\nfunction SheetTrigger({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {\n  return <SheetPrimitive.Trigger data-slot=\"sheet-trigger\" {...props} />;\n}\n\nfunction SheetClose({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Close>) {\n  return <SheetPrimitive.Close data-slot=\"sheet-close\" {...props} />;\n}\n\nfunction SheetPortal({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Portal>) {\n  return <SheetPrimitive.Portal data-slot=\"sheet-portal\" {...props} />;\n}\n\nfunction SheetOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {\n  return (\n    <SheetPrimitive.Overlay\n      data-slot=\"sheet-overlay\"\n      className={cn(\n        'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SheetContent({\n  className,\n  children,\n  side = 'right',\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Content> & {\n  side?: 'top' | 'right' | 'bottom' | 'left';\n}) {\n  return (\n    <SheetPortal>\n      <SheetOverlay />\n      <SheetPrimitive.Content\n        data-slot=\"sheet-content\"\n        className={cn(\n          'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',\n          side === 'right' &&\n            'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',\n          side === 'left' &&\n            'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',\n          side === 'top' &&\n            'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',\n          side === 'bottom' &&\n            'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        <SheetPrimitive.Close className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none\">\n          <XIcon className=\"size-4\" />\n          <span className=\"sr-only\">Close</span>\n        </SheetPrimitive.Close>\n      </SheetPrimitive.Content>\n    </SheetPortal>\n  );\n}\n\nfunction SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sheet-header\"\n      className={cn('flex flex-col gap-1.5 p-4', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sheet-footer\"\n      className={cn('mt-auto flex flex-col gap-2 p-4', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Title>) {\n  return (\n    <SheetPrimitive.Title\n      data-slot=\"sheet-title\"\n      className={cn('text-foreground font-semibold', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Description>) {\n  return (\n    <SheetPrimitive.Description\n      data-slot=\"sheet-description\"\n      className={cn('text-muted-foreground text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Sheet,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n};\n"
  },
  {
    "path": "web/src/components/ui/sidebar.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, VariantProps } from 'class-variance-authority';\nimport { PanelLeftIcon } from 'lucide-react';\n\nimport { useIsMobile } from '@/hooks/use-mobile';\nimport { cn } from '@/lib/utils';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} 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\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 SidebarContextProps = {\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<SidebarContextProps | 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\nfunction SidebarProvider({\n  defaultOpen = true,\n  open: openProp,\n  onOpenChange: setOpenProp,\n  className,\n  style,\n  children,\n  ...props\n}: React.ComponentProps<'div'> & {\n  defaultOpen?: boolean;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\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 ? setOpenMobile((open) => !open) : 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<SidebarContextProps>(\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          data-slot=\"sidebar-wrapper\"\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 has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',\n            className,\n          )}\n          {...props}\n        >\n          {children}\n        </div>\n      </TooltipProvider>\n    </SidebarContext.Provider>\n  );\n}\n\nfunction Sidebar({\n  side = 'left',\n  variant = 'sidebar',\n  collapsible = 'offcanvas',\n  className,\n  children,\n  ...props\n}: React.ComponentProps<'div'> & {\n  side?: 'left' | 'right';\n  variant?: 'sidebar' | 'floating' | 'inset';\n  collapsible?: 'offcanvas' | 'icon' | 'none';\n}) {\n  const { isMobile, state, openMobile, setOpenMobile } = useSidebar();\n\n  if (collapsible === 'none') {\n    return (\n      <div\n        data-slot=\"sidebar\"\n        className={cn(\n          'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',\n          className,\n        )}\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-slot=\"sidebar\"\n          data-mobile=\"true\"\n          className=\"bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden\"\n          style={\n            {\n              '--sidebar-width': SIDEBAR_WIDTH_MOBILE,\n            } as React.CSSProperties\n          }\n          side={side}\n        >\n          <SheetHeader className=\"sr-only\">\n            <SheetTitle>Sidebar</SheetTitle>\n            <SheetDescription>Displays the mobile sidebar.</SheetDescription>\n          </SheetHeader>\n          <div className=\"flex h-full w-full flex-col\">{children}</div>\n        </SheetContent>\n      </Sheet>\n    );\n  }\n\n  return (\n    <div\n      className=\"group peer text-sidebar-foreground hidden md:block\"\n      data-state={state}\n      data-collapsible={state === 'collapsed' ? collapsible : ''}\n      data-variant={variant}\n      data-side={side}\n      data-slot=\"sidebar\"\n    >\n      {/* This is what handles the sidebar gap on desktop */}\n      <div\n        data-slot=\"sidebar-gap\"\n        className={cn(\n          'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 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)+(--spacing(4)))]'\n            : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',\n        )}\n      />\n      <div\n        data-slot=\"sidebar-container\"\n        className={cn(\n          'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 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)+(--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          data-slot=\"sidebar-inner\"\n          className=\"bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm\"\n        >\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction SidebarTrigger({\n  className,\n  onClick,\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { toggleSidebar } = useSidebar();\n\n  return (\n    <Button\n      data-sidebar=\"trigger\"\n      data-slot=\"sidebar-trigger\"\n      variant=\"ghost\"\n      size=\"icon\"\n      className={cn('size-7', className)}\n      onClick={(event) => {\n        onClick?.(event);\n        toggleSidebar();\n      }}\n      {...props}\n    >\n      <PanelLeftIcon />\n      <span className=\"sr-only\">Toggle Sidebar</span>\n    </Button>\n  );\n}\n\nfunction SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {\n  const { toggleSidebar } = useSidebar();\n\n  return (\n    <button\n      data-sidebar=\"rail\"\n      data-slot=\"sidebar-rail\"\n      aria-label=\"Toggle Sidebar\"\n      tabIndex={-1}\n      onClick={toggleSidebar}\n      title=\"Toggle Sidebar\"\n      className={cn(\n        'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',\n        'in-data-[side=left]:cursor-w-resize in-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        'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',\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}\n\nfunction SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {\n  return (\n    <main\n      data-slot=\"sidebar-inset\"\n      className={cn(\n        'bg-background relative flex w-full flex-1 flex-col',\n        'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof Input>) {\n  return (\n    <Input\n      data-slot=\"sidebar-input\"\n      data-sidebar=\"input\"\n      className={cn('bg-background h-8 w-full shadow-none', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-header\"\n      data-sidebar=\"header\"\n      className={cn('flex flex-col gap-2 p-2', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-footer\"\n      data-sidebar=\"footer\"\n      className={cn('flex flex-col gap-2 p-2', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"sidebar-separator\"\n      data-sidebar=\"separator\"\n      className={cn('bg-sidebar-border mx-2 w-auto', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-content\"\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}\n\nfunction SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-group\"\n      data-sidebar=\"group\"\n      className={cn('relative flex w-full min-w-0 flex-col p-2', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupLabel({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<'div'> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : 'div';\n\n  return (\n    <Comp\n      data-slot=\"sidebar-group-label\"\n      data-sidebar=\"group-label\"\n      className={cn(\n        'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 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}\n\nfunction SidebarGroupAction({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<'button'> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : 'button';\n\n  return (\n    <Comp\n      data-slot=\"sidebar-group-action\"\n      data-sidebar=\"group-action\"\n      className={cn(\n        'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform 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 md:after:hidden',\n        'group-data-[collapsible=icon]:hidden',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupContent({\n  className,\n  ...props\n}: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-group-content\"\n      data-sidebar=\"group-content\"\n      className={cn('w-full text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu\"\n      data-sidebar=\"menu\"\n      className={cn('flex w-full min-w-0 flex-col gap-1', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-item\"\n      data-sidebar=\"menu-item\"\n      className={cn('group/menu-item relative', className)}\n      {...props}\n    />\n  );\n}\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-hidden 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\nfunction SidebarMenuButton({\n  asChild = false,\n  isActive = false,\n  variant = 'default',\n  size = 'default',\n  tooltip,\n  className,\n  ...props\n}: React.ComponentProps<'button'> & {\n  asChild?: boolean;\n  isActive?: boolean;\n  tooltip?: string | React.ComponentProps<typeof TooltipContent>;\n} & VariantProps<typeof sidebarMenuButtonVariants>) {\n  const Comp = asChild ? Slot : 'button';\n  const { isMobile, state } = useSidebar();\n\n  const button = (\n    <Comp\n      data-slot=\"sidebar-menu-button\"\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\nfunction SidebarMenuAction({\n  className,\n  asChild = false,\n  showOnHover = false,\n  ...props\n}: React.ComponentProps<'button'> & {\n  asChild?: boolean;\n  showOnHover?: boolean;\n}) {\n  const Comp = asChild ? Slot : 'button';\n\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-action\"\n      data-sidebar=\"menu-action\"\n      className={cn(\n        'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform 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 md:after: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          'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuBadge({\n  className,\n  ...props\n}: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-menu-badge\"\n      data-sidebar=\"menu-badge\"\n      className={cn(\n        'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-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  );\n}\n\nfunction SidebarMenuSkeleton({\n  className,\n  showIcon = false,\n  ...props\n}: React.ComponentProps<'div'> & {\n  showIcon?: boolean;\n}) {\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      data-slot=\"sidebar-menu-skeleton\"\n      data-sidebar=\"menu-skeleton\"\n      className={cn('flex h-8 items-center gap-2 rounded-md px-2', 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 max-w-(--skeleton-width) flex-1\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            '--skeleton-width': width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  );\n}\n\nfunction SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu-sub\"\n      data-sidebar=\"menu-sub\"\n      className={cn(\n        'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',\n        'group-data-[collapsible=icon]:hidden',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubItem({\n  className,\n  ...props\n}: React.ComponentProps<'li'>) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-sub-item\"\n      data-sidebar=\"menu-sub-item\"\n      className={cn('group/menu-sub-item relative', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubButton({\n  asChild = false,\n  size = 'md',\n  isActive = false,\n  className,\n  ...props\n}: React.ComponentProps<'a'> & {\n  asChild?: boolean;\n  size?: 'sm' | 'md';\n  isActive?: boolean;\n}) {\n  const Comp = asChild ? Slot : 'a';\n\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-sub-button\"\n      data-sidebar=\"menu-sub-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(\n        'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 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',\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}\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": "web/src/components/ui/skeleton.tsx",
    "content": "import { cn } from '@/lib/utils';\n\nfunction Skeleton({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"skeleton\"\n      className={cn('bg-accent animate-pulse rounded-md', className)}\n      {...props}\n    />\n  );\n}\n\nexport { Skeleton };\n"
  },
  {
    "path": "web/src/components/ui/sonner.tsx",
    "content": "'use client';\n\nimport { useTheme } from 'next-themes';\nimport { Toaster as Sonner, ToasterProps } from 'sonner';\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme = 'system' } = useTheme();\n\n  return (\n    <Sonner\n      theme={theme as ToasterProps['theme']}\n      className=\"toaster group\"\n      style={\n        {\n          '--normal-bg': 'var(--popover)',\n          '--normal-text': 'var(--popover-foreground)',\n          '--normal-border': 'var(--border)',\n        } as React.CSSProperties\n      }\n      {...props}\n    />\n  );\n};\n\nexport { Toaster };\n"
  },
  {
    "path": "web/src/components/ui/switch.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as SwitchPrimitive from '@radix-ui/react-switch';\n\nimport { cn } from '@/lib/utils';\n\nfunction Switch({\n  className,\n  ...props\n}: React.ComponentProps<typeof SwitchPrimitive.Root>) {\n  return (\n    <SwitchPrimitive.Root\n      data-slot=\"switch\"\n      className={cn(\n        'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n        className,\n      )}\n      {...props}\n    >\n      <SwitchPrimitive.Thumb\n        data-slot=\"switch-thumb\"\n        className={cn(\n          'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',\n        )}\n      />\n    </SwitchPrimitive.Root>\n  );\n}\n\nexport { Switch };\n"
  },
  {
    "path": "web/src/components/ui/table.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Table({ className, ...props }: React.ComponentProps<'table'>) {\n  return (\n    <div\n      data-slot=\"table-container\"\n      className=\"relative w-full overflow-x-auto\"\n    >\n      <table\n        data-slot=\"table\"\n        className={cn('w-full caption-bottom text-sm', className)}\n        {...props}\n      />\n    </div>\n  );\n}\n\nfunction TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {\n  return (\n    <thead\n      data-slot=\"table-header\"\n      className={cn('[&_tr]:border-b', className)}\n      {...props}\n    />\n  );\n}\n\nfunction TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {\n  return (\n    <tbody\n      data-slot=\"table-body\"\n      className={cn('[&_tr:last-child]:border-0', className)}\n      {...props}\n    />\n  );\n}\n\nfunction TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {\n  return (\n    <tfoot\n      data-slot=\"table-footer\"\n      className={cn(\n        'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TableRow({ className, ...props }: React.ComponentProps<'tr'>) {\n  return (\n    <tr\n      data-slot=\"table-row\"\n      className={cn(\n        'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TableHead({ className, ...props }: React.ComponentProps<'th'>) {\n  return (\n    <th\n      data-slot=\"table-head\"\n      className={cn(\n        'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TableCell({ className, ...props }: React.ComponentProps<'td'>) {\n  return (\n    <td\n      data-slot=\"table-cell\"\n      className={cn(\n        'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TableCaption({\n  className,\n  ...props\n}: React.ComponentProps<'caption'>) {\n  return (\n    <caption\n      data-slot=\"table-caption\"\n      className={cn('text-muted-foreground mt-4 text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n};\n"
  },
  {
    "path": "web/src/components/ui/tabs.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as TabsPrimitive from '@radix-ui/react-tabs';\n\nimport { cn } from '@/lib/utils';\n\nfunction Tabs({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Root>) {\n  return (\n    <TabsPrimitive.Root\n      data-slot=\"tabs\"\n      className={cn('flex flex-col gap-2', className)}\n      {...props}\n    />\n  );\n}\n\nfunction TabsList({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.List>) {\n  return (\n    <TabsPrimitive.List\n      data-slot=\"tabs-list\"\n      className={cn(\n        'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TabsTrigger({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {\n  return (\n    <TabsPrimitive.Trigger\n      data-slot=\"tabs-trigger\"\n      className={cn(\n        \"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TabsContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Content>) {\n  return (\n    <TabsPrimitive.Content\n      data-slot=\"tabs-content\"\n      className={cn('flex-1 outline-none', className)}\n      {...props}\n    />\n  );\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\n"
  },
  {
    "path": "web/src/components/ui/textarea.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {\n  return (\n    <textarea\n      data-slot=\"textarea\"\n      className={cn(\n        'border-input placeholder:text-muted-foreground 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 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Textarea };\n"
  },
  {
    "path": "web/src/components/ui/theme-toggle.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport { Moon, Sun, Monitor } from 'lucide-react';\nimport { useTheme } from 'next-themes';\n\nimport { Button } from '@/components/ui/button';\n\nexport function ThemeToggle() {\n  const { theme, setTheme } = useTheme();\n\n  return (\n    <Button\n      variant=\"outline\"\n      size=\"icon\"\n      onClick={() =>\n        setTheme(\n          theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light',\n        )\n      }\n      className=\"h-9 w-9\"\n    >\n      {theme === 'light' && <Sun className=\"h-[1.2rem] w-[1.2rem]\" />}\n      {theme === 'dark' && <Moon className=\"h-[1.2rem] w-[1.2rem]\" />}\n      {theme === 'system' && <Monitor className=\"h-[1.2rem] w-[1.2rem]\" />}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "web/src/components/ui/toggle-group.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';\nimport { type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/lib/utils';\nimport { toggleVariants } from '@/components/ui/toggle';\n\nconst ToggleGroupContext = React.createContext<\n  VariantProps<typeof toggleVariants> & {\n    spacing?: number;\n  }\n>({\n  size: 'default',\n  variant: 'default',\n  spacing: 0,\n});\n\nfunction ToggleGroup({\n  className,\n  variant,\n  size,\n  spacing = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &\n  VariantProps<typeof toggleVariants> & {\n    spacing?: number;\n  }) {\n  return (\n    <ToggleGroupPrimitive.Root\n      data-slot=\"toggle-group\"\n      data-variant={variant}\n      data-size={size}\n      data-spacing={spacing}\n      style={{ '--gap': spacing } as React.CSSProperties}\n      className={cn(\n        'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',\n        className,\n      )}\n      {...props}\n    >\n      <ToggleGroupContext.Provider value={{ variant, size, spacing }}>\n        {children}\n      </ToggleGroupContext.Provider>\n    </ToggleGroupPrimitive.Root>\n  );\n}\n\nfunction ToggleGroupItem({\n  className,\n  children,\n  variant,\n  size,\n  ...props\n}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &\n  VariantProps<typeof toggleVariants>) {\n  const context = React.useContext(ToggleGroupContext);\n\n  return (\n    <ToggleGroupPrimitive.Item\n      data-slot=\"toggle-group-item\"\n      data-variant={context.variant || variant}\n      data-size={context.size || size}\n      data-spacing={context.spacing}\n      className={cn(\n        toggleVariants({\n          variant: context.variant || variant,\n          size: context.size || size,\n        }),\n        'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',\n        'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </ToggleGroupPrimitive.Item>\n  );\n}\n\nexport { ToggleGroup, ToggleGroupItem };\n"
  },
  {
    "path": "web/src/components/ui/toggle.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as TogglePrimitive from '@radix-ui/react-toggle';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/lib/utils';\n\nconst toggleVariants = cva(\n  \"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:data-[state=on]:bg-slate-700 dark:data-[state=on]:text-white [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap\",\n  {\n    variants: {\n      variant: {\n        default: 'bg-transparent',\n        outline:\n          'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',\n      },\n      size: {\n        default: 'h-9 px-2 min-w-9',\n        sm: 'h-8 px-1.5 min-w-8',\n        lg: 'h-10 px-2.5 min-w-10',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  },\n);\n\nfunction Toggle({\n  className,\n  variant,\n  size,\n  ...props\n}: React.ComponentProps<typeof TogglePrimitive.Root> &\n  VariantProps<typeof toggleVariants>) {\n  return (\n    <TogglePrimitive.Root\n      data-slot=\"toggle\"\n      className={cn(toggleVariants({ variant, size, className }))}\n      {...props}\n    />\n  );\n}\n\nexport { Toggle, toggleVariants };\n"
  },
  {
    "path": "web/src/components/ui/tooltip.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip';\n\nimport { cn } from '@/lib/utils';\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  );\n}\n\nfunction Tooltip({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  );\n}\n\nfunction TooltipTrigger({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />;\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          'bg-primary text-primary-foreground 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  );\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };\n"
  },
  {
    "path": "web/src/hooks/use-mobile.ts",
    "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": "web/src/hooks/useAsyncTask.ts",
    "content": "import { useState, useEffect, useRef } from 'react';\nimport { httpClient } from '@/app/infra/http/HttpClient';\nimport { AsyncTask } from '@/app/infra/entities/api';\n\nexport enum AsyncTaskStatus {\n  WAIT_INPUT = 'WAIT_INPUT',\n  RUNNING = 'RUNNING',\n  SUCCESS = 'SUCCESS',\n  ERROR = 'ERROR',\n}\n\nexport interface UseAsyncTaskOptions {\n  onSuccess?: () => void;\n  onError?: (error: string) => void;\n  pollInterval?: number;\n}\n\nexport interface UseAsyncTaskResult {\n  status: AsyncTaskStatus;\n  error: string | null;\n  startTask: (taskId: number) => void;\n  reset: () => void;\n}\n\nexport function useAsyncTask(\n  options: UseAsyncTaskOptions = {},\n): UseAsyncTaskResult {\n  const { onSuccess, onError, pollInterval = 1000 } = options;\n\n  const [status, setStatus] = useState<AsyncTaskStatus>(\n    AsyncTaskStatus.WAIT_INPUT,\n  );\n  const [error, setError] = useState<string | null>(null);\n  const intervalRef = useRef<NodeJS.Timeout | null>(null);\n  const alreadySuccessRef = useRef<boolean>(false);\n\n  const clearPollingInterval = () => {\n    if (intervalRef.current) {\n      clearInterval(intervalRef.current);\n      intervalRef.current = null;\n    }\n  };\n\n  const reset = () => {\n    clearPollingInterval();\n    setStatus(AsyncTaskStatus.WAIT_INPUT);\n    setError(null);\n    alreadySuccessRef.current = false;\n  };\n\n  const startTask = (taskId: number) => {\n    setStatus(AsyncTaskStatus.RUNNING);\n    setError(null);\n    alreadySuccessRef.current = false;\n\n    const interval = setInterval(() => {\n      httpClient\n        .getAsyncTask(taskId)\n        .then((res: AsyncTask) => {\n          if (res.runtime.done) {\n            clearPollingInterval();\n            if (res.runtime.exception) {\n              setError(res.runtime.exception);\n              setStatus(AsyncTaskStatus.ERROR);\n              onError?.(res.runtime.exception);\n            } else {\n              if (!alreadySuccessRef.current) {\n                alreadySuccessRef.current = true;\n                setStatus(AsyncTaskStatus.SUCCESS);\n                onSuccess?.();\n              }\n            }\n          }\n        })\n        .catch((error) => {\n          clearPollingInterval();\n          const errorMessage = error.message || 'Unknown error';\n          setError(errorMessage);\n          setStatus(AsyncTaskStatus.ERROR);\n          onError?.(errorMessage);\n        });\n    }, pollInterval);\n\n    intervalRef.current = interval;\n  };\n\n  useEffect(() => {\n    return () => {\n      clearPollingInterval();\n    };\n  }, []);\n\n  return {\n    status,\n    error,\n    startTask,\n    reset,\n  };\n}\n"
  },
  {
    "path": "web/src/i18n/I18nProvider.tsx",
    "content": "'use client';\n\nimport { ReactNode } from 'react';\nimport '@/i18n';\nimport { I18nObject } from '@/app/infra/entities/common';\nimport i18n from 'i18next';\n\ninterface I18nProviderProps {\n  children: ReactNode;\n}\n\nexport default function I18nProvider({ children }: I18nProviderProps) {\n  return <>{children}</>;\n}\n// export function extractI18nObject(i18nLabel: I18nObject): string {\n//   const language = localStorage.getItem('langbot_language');\n//   if ((language === 'zh-Hans' && i18nLabel.zh_Hans) || !i18nLabel.en_US) {\n//     return i18nLabel.zh_Hans;\n//   }\n//   return i18nLabel.en_US;\n// }\n\nexport const extractI18nObject = (i18nObject: I18nObject): string => {\n  // 根据当前语言返回对应的值, fallback优先级：en_US、zh_Hans、zh_Hant、ja_JP\n  const language = i18n.language.replace('-', '_');\n  if (language === 'en_US' && i18nObject.en_US) return i18nObject.en_US;\n  if (language === 'zh_Hans' && i18nObject.zh_Hans) return i18nObject.zh_Hans;\n  if (language === 'zh_Hant' && i18nObject.zh_Hant) return i18nObject.zh_Hant;\n  if (language === 'ja_JP' && i18nObject.ja_JP) return i18nObject.ja_JP;\n  return (\n    i18nObject.en_US ||\n    i18nObject.zh_Hans ||\n    i18nObject.zh_Hant ||\n    i18nObject.ja_JP ||\n    ''\n  );\n};\n\n// 工具函数：将 i18n 语言代码转换为 API 语言代码\n// i18n 使用：zh-Hans, en-US, ja-JP\n// API 使用：zh_Hans, en, ja_JP\nexport const getAPILanguageCode = (): string => {\n  const language = i18n.language;\n  // zh-Hans -> zh_Hans\n  if (language === 'zh-Hans') return 'zh_Hans';\n  // zh-Hant -> zh_Hant\n  if (language === 'zh-Hant') return 'zh_Hant';\n  // en-US -> en\n  if (language === 'en-US') return 'en';\n  // ja-JP -> ja_JP\n  if (language === 'ja-JP') return 'ja_JP';\n  // 默认返回 en\n  return 'en';\n};\n"
  },
  {
    "path": "web/src/i18n/index.ts",
    "content": "'use client';\n\nimport i18n from 'i18next';\nimport { initReactI18next } from 'react-i18next';\nimport LanguageDetector from 'i18next-browser-languagedetector';\n\nimport enUS from './locales/en-US';\nimport zhHans from './locales/zh-Hans';\nimport zhHant from './locales/zh-Hant';\nimport jaJP from './locales/ja-JP';\n\ni18n\n  .use(LanguageDetector)\n  .use(initReactI18next)\n  .init({\n    resources: {\n      'en-US': {\n        translation: enUS,\n      },\n      'zh-Hans': {\n        translation: zhHans,\n      },\n      'zh-Hant': {\n        translation: zhHant,\n      },\n      'ja-JP': {\n        translation: jaJP,\n      },\n    },\n    fallbackLng: 'zh-Hans',\n    debug: process.env.NODE_ENV === 'development',\n    interpolation: {\n      escapeValue: false, // React already escapes values\n    },\n    detection: {\n      order: ['localStorage', 'navigator'],\n      lookupLocalStorage: 'langbot_language',\n      caches: ['localStorage'],\n    },\n  });\n\nexport default i18n;\n"
  },
  {
    "path": "web/src/i18n/locales/en-US.ts",
    "content": "const enUS = {\n  common: {\n    login: 'Login',\n    logout: 'Logout',\n    accountOptions: 'Settings',\n    account: 'Account',\n    integration: 'Integration',\n    email: 'Email',\n    password: 'Password',\n    welcome: 'Welcome back to LangBot 👋',\n    continueToLogin: 'Login to continue',\n    loginSuccess: 'Login successful',\n    loginFailed: 'Login failed, please check your email and password',\n    loginLoadError: 'Unable to connect to server',\n    loginLoadErrorDesc:\n      'Unable to connect to the LangBot backend. Please make sure the service is running and try again.',\n    retry: 'Retry',\n    enterEmail: 'Enter email address',\n    enterPassword: 'Enter password',\n    invalidEmail: 'Please enter a valid email address',\n    emptyPassword: 'Please enter your password',\n    language: 'Language',\n    helpDocs: 'Get Help',\n    featureRequest: 'Feature Request',\n    create: 'Create',\n    edit: 'Edit',\n    delete: 'Delete',\n    add: 'Add',\n    select: 'Select',\n    cancel: 'Cancel',\n    submit: 'Submit',\n    error: 'Error',\n    success: 'Success',\n    save: 'Save',\n    saving: 'Saving...',\n    confirm: 'Confirm',\n    confirmDelete: 'Confirm Delete',\n    deleteConfirmation: 'Are you sure you want to delete this?',\n    selectOption: 'Select an option',\n    required: 'Required',\n    enable: 'Enable',\n    name: 'Name',\n    description: 'Description',\n    icon: 'Icon',\n    close: 'Close',\n    deleteSuccess: 'Deleted successfully',\n    deleteError: 'Delete failed: ',\n    addRound: 'Add Round',\n    copy: 'Copy',\n    copySuccess: 'Copy Successfully',\n    copyFailed: 'Copy Failed',\n    test: 'Test',\n    forgotPassword: 'Forgot Password?',\n    agreementNotice: 'By continuing, you agree to our',\n    privacyPolicy: 'Privacy Policy',\n    and: 'and',\n    dataCollectionPolicy: 'Data Collection Policy',\n    dataCollectionPolicyUrl:\n      'https://docs.langbot.app/en/insight/data-collection-policy',\n    loading: 'Loading...',\n    fieldRequired: 'This field is required',\n    or: 'or',\n    loginWithSpace: 'Login with Space',\n    spaceLoginRecommended:\n      'Recommended: Use official stable model APIs and cloud services',\n    loginLocal: 'Login with local account',\n    loginWithPassword: 'Login with password',\n    spaceLoginTitle: 'Login with Space',\n    spaceLoginDescription:\n      'Scan the QR code or visit the link below to authorize',\n    spaceLoginUserCode: 'Your code',\n    spaceLoginExpires: 'Code expires in {{seconds}} seconds',\n    spaceLoginWaiting: 'Waiting for authorization...',\n    spaceLoginSuccess: 'Authorization successful',\n    spaceLoginFailed: 'Space login failed',\n    spaceLoginExpired: 'Authorization code expired, please try again',\n    spaceLoginCancel: 'Cancel',\n    spaceLoginVisitLink: 'Visit link',\n    spaceLoginProcessing: 'Logging in with Space',\n    spaceLoginProcessingDescription:\n      'Please wait while we complete your login...',\n    spaceLoginSuccessDescription: 'Redirecting to LangBot...',\n    spaceLoginError: 'Login Failed',\n    spaceLoginNoCode: 'Missing authorization code',\n    backToLogin: 'Back to Login',\n    backToHome: 'Back to Home',\n    spaceAccountCannotChangePassword:\n      'Space accounts cannot change password here',\n    theme: 'Theme',\n    changePassword: 'Change Password',\n    currentPassword: 'Current Password',\n    newPassword: 'New Password',\n    confirmNewPassword: 'Confirm New Password',\n    enterCurrentPassword: 'Enter current password',\n    enterNewPassword: 'Enter new password',\n    enterConfirmPassword: 'Confirm new password',\n    currentPasswordRequired: 'Current password is required',\n    newPasswordRequired: 'New password is required',\n    confirmPasswordRequired: 'Confirm password is required',\n    passwordsDoNotMatch: 'Passwords do not match',\n    changePasswordSuccess: 'Password changed successfully',\n    changePasswordFailed:\n      'Failed to change password, please check your current password',\n    apiIntegration: 'API Integration',\n    apiKeys: 'API Keys',\n    manageApiIntegration: 'Manage API Integration',\n    manageApiKeys: 'Manage API Keys',\n    createApiKey: 'Create API Key',\n    apiKeyName: 'API Key Name',\n    apiKeyDescription: 'API Key Description',\n    apiKeyValue: 'API Key Value',\n    apiKeyCreated: 'API key created successfully',\n    apiKeyDeleted: 'API key deleted successfully',\n    apiKeyDeleteConfirm: 'Are you sure you want to delete this API key?',\n    apiKeyNameRequired: 'API key name is required',\n    copyApiKey: 'Copy API Key',\n    apiKeyCopied: 'API key copied to clipboard',\n    noApiKeys: 'No API keys configured',\n    apiKeyHint:\n      'API keys allow external systems to access LangBot Service APIs',\n    webhooks: 'Webhooks',\n    createWebhook: 'Create Webhook',\n    webhookName: 'Webhook Name',\n    webhookUrl: 'Webhook URL',\n    webhookDescription: 'Webhook Description',\n    webhookEnabled: 'Enabled',\n    webhookCreated: 'Webhook created successfully',\n    webhookDeleted: 'Webhook deleted successfully',\n    webhookDeleteConfirm: 'Are you sure you want to delete this webhook?',\n    webhookNameRequired: 'Webhook name is required',\n    webhookUrlRequired: 'Webhook URL is required',\n    noWebhooks: 'No webhooks configured',\n    webhookHint:\n      'Webhooks allow LangBot to push person and group message events to external systems',\n    actions: 'Actions',\n    apiKeyCreatedMessage:\n      'Please copy this API key, if the button is invalid, please copy manually.',\n    none: 'None',\n  },\n  notFound: {\n    title: 'Page not found',\n    description: 'The page you are looking for does not exist.',\n    back: 'Back',\n    home: 'Home',\n    help: 'Get Help',\n  },\n  models: {\n    title: 'Models',\n    description: 'Configure and manage models that can be used in pipelines',\n    createModel: 'Create Model',\n    editModel: 'Edit Model',\n    getModelListError: 'Failed to get model list: ',\n    modelName: 'Model Name',\n    modelProvider: 'Model Provider',\n    modelBaseURL: 'Base URL',\n    modelAbilities: 'Model Abilities',\n    saveSuccess: 'Saved successfully',\n    saveError: 'Save failed: ',\n    createSuccess: 'Created successfully',\n    createError: 'Creation failed: ',\n    deleteSuccess: 'Deleted successfully',\n    deleteError: 'Delete failed: ',\n    deleteConfirmation: 'Are you sure you want to delete this model?',\n    modelNameRequired: 'Model name cannot be empty',\n    modelProviderRequired: 'Model provider cannot be empty',\n    requestURLRequired: 'Request URL cannot be empty',\n    apiKeyRequired: 'API Key cannot be empty',\n    keyNameRequired: 'Key name cannot be empty',\n    mustBeValidNumber: 'Must be a valid number',\n    mustBeTrueOrFalse: 'Must be true or false',\n    requestURL: 'Request URL',\n    apiKey: 'API Key',\n    abilities: 'Abilities',\n    selectModelAbilities: 'Select model abilities',\n    visionAbility: 'Vision Ability',\n    functionCallAbility: 'Function Call',\n    extraParameters: 'Extra Parameters',\n    addParameter: 'Add Parameter',\n    keyName: 'Key Name',\n    type: 'Type',\n    value: 'Value',\n    string: 'String',\n    number: 'Number',\n    boolean: 'Boolean',\n    selectModelProvider: 'Select Model Provider',\n    modelProviderDescription:\n      'Please fill in the model name provided by the provider',\n    modelManufacturer: 'Model Manufacturer',\n    aggregationPlatform: 'Aggregation Platform',\n    selfDeployed: 'Self-deployed',\n    builtin: 'Built-in',\n    selectModel: 'Select Model',\n    testSuccess: 'Test successful',\n    testError: 'Test failed, please check your model configuration',\n    llmModels: 'LLM Models',\n    localProvider: 'Local',\n    localProviderDescription: 'Models configured and managed locally',\n    spaceProviderDescription: 'Models synced from your Space account',\n    spaceDisabledForLocalAccount: 'Login with Space to use cloud models',\n    syncModels: 'Sync',\n    syncSuccess: 'Sync complete: {{created}} created, {{updated}} updated',\n    syncError: 'Sync failed: ',\n    spaceModelReadOnly: 'Space models are read-only',\n    noSpaceModels: 'No Space models. Click Sync to fetch models from Space.',\n    noLocalModels: 'No local models. Click Create to add a model.',\n    providerCount: '{{count}} providers',\n    // New keys for provider-based structure\n    addModel: 'Add Model',\n    addLLMModel: 'Add LLM Model',\n    addEmbeddingModel: 'Add Embedding Model',\n    provider: 'Provider',\n    existingProvider: 'Existing Provider',\n    newProvider: 'New Provider',\n    selectProvider: 'Select Provider',\n    requester: 'Provider Type',\n    selectRequester: 'Select Provider Type',\n    langbotModelsDescription: 'Cloud models powered by LangBot Space',\n    credits: 'Credits',\n    loginWithSpace: 'Login with Space',\n    loginToUseModels: 'Login with Space to use cloud models',\n    noModels: 'No models configured',\n    editProvider: 'Edit Provider',\n    addProvider: 'Add Provider',\n    addProviderHint: 'Add providers to use models from other sources',\n    addProviderHintSimple: 'Add providers to use models',\n    noProviders: 'No providers yet',\n    providerName: 'Provider Name',\n    providerNameRequired: 'Provider name is required',\n    requesterRequired: 'Provider type is required',\n    providerSaved: 'Provider saved',\n    providerCreated: 'Provider created',\n    providerSaveError: 'Failed to save provider: ',\n    providerDeleted: 'Provider deleted',\n    providerDeleteError: 'Failed to delete provider: ',\n    deleteProviderConfirmation:\n      'Are you sure you want to delete this provider?',\n    loadError: 'Failed to load data',\n    chat: 'Chat',\n    embedding: 'Embedding',\n    modelsCount: '{{count}} model(s)',\n    expandModels: 'Expand',\n    collapseModels: 'Collapse',\n    fallback: {\n      primary: 'Primary Model',\n      fallbackList: 'Fallback Models',\n      addFallback: 'Add Fallback Model',\n    },\n  },\n  bots: {\n    title: 'Bots',\n    description:\n      'Create and manage bots, which are the entry points for LangBot to connect with various platforms',\n    createBot: 'Create Bot',\n    editBot: 'Edit Bot',\n    getBotListError: 'Failed to get bot list: ',\n    botName: 'Bot Name',\n    botDescription: 'Bot Description',\n    botNameRequired: 'Bot name cannot be empty',\n    botDescriptionRequired: 'Bot description cannot be empty',\n    adapterRequired: 'Adapter cannot be empty',\n    defaultDescription: 'A bot',\n    getBotConfigError: 'Failed to get bot configuration: ',\n    saveSuccess: 'Saved successfully',\n    saveError: 'Save failed: ',\n    createSuccess:\n      'Created successfully. Please enable or modify the bound pipeline',\n    createError: 'Creation failed: ',\n    deleteSuccess: 'Deleted successfully',\n    deleteError: 'Delete failed: ',\n    deleteConfirmation: 'Are you sure you want to delete this bot?',\n    platformAdapter: 'Platform/Adapter Selection',\n    selectAdapter: 'Select Adapter',\n    adapterConfig: 'Adapter Configuration',\n    bindPipeline: 'Bind Pipeline',\n    selectPipeline: 'Select Pipeline',\n    selectBot: 'Select Bot',\n    botLogTitle: 'Bot Log',\n    enableAutoRefresh: 'Enable Auto Refresh',\n    session: 'Session',\n    yesterday: 'Yesterday',\n    earlier: 'Earlier',\n    dateFormat: '{{month}}/{{day}}',\n    setBotEnableError: 'Failed to set bot enable status',\n    log: 'Log',\n    configuration: 'Configuration',\n    logs: 'Logs',\n    webhookUrl: 'Webhook Callback URL',\n    webhookUrlCopied: 'Webhook URL copied',\n    webhookUrlHint:\n      'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',\n    webhookUrlHintEither:\n      'Use either of the two URLs above in your platform configuration',\n    logLevel: 'Log Level',\n    allLevels: 'All Levels',\n    selectLevel: 'Select Level',\n    levelsSelected: 'levels selected',\n    viewDetailedLogs: 'View Detailed Logs',\n    viewDetails: 'Details',\n    collapse: 'Collapse',\n    imagesAttached: 'image(s) attached',\n    sessionMonitor: {\n      title: 'Sessions',\n      sessions: 'Sessions',\n      noSessions: 'No sessions found',\n      selectSession: 'Select a session to view messages',\n      noMessages: 'No messages in this session',\n      messages: 'messages',\n      messageCount: '{{count}} messages',\n      loading: 'Loading...',\n      loadingSessions: 'Loading sessions...',\n      loadingMessages: 'Loading messages...',\n      user: 'User',\n      variables: 'Variables',\n      platform: 'Platform',\n      lastActive: 'Last active',\n      refresh: 'Refresh',\n      active: 'Active',\n      inactive: 'Inactive',\n    },\n  },\n  plugins: {\n    title: 'Extensions',\n    description:\n      'Install and configure plugins to extend functionality, please select them in the pipeline configuration',\n    createPlugin: 'Create Plugin',\n    editPlugin: 'Edit Plugin',\n    installed: 'Installed',\n    marketplace: 'Marketplace',\n    arrange: 'Sort Plugins',\n    install: 'Install',\n    installPlugin: 'Install Plugin',\n    onlySupportGithub: 'Currently only supports installation from GitHub',\n    enterGithubLink: 'Enter GitHub link of the plugin',\n    installing: 'Installing plugin...',\n    installSuccess: 'Plugin installed successfully',\n    installFailed: 'Plugin installation failed:',\n    searchPlugin: 'Search plugins',\n    sortBy: 'Sort by',\n    mostStars: 'Most stars',\n    recentlyAdded: 'Recently added',\n    recentlyUpdated: 'Recently updated',\n    noMatchingPlugins: 'No matching plugins found',\n    loading: 'Loading...',\n    getPluginListError: 'Failed to get plugin list:',\n    noPluginInstalled: 'No plugins installed',\n    pluginConfig: 'Plugin Configuration',\n    pluginSort: 'Plugin Sort',\n    pluginSortDescription:\n      'Plugin order affects the processing order within the same event, please drag the plugin card to sort',\n    pluginSortSuccess: 'Plugin sort successful',\n    pluginSortError: 'Plugin sort failed: ',\n    pluginNoConfig: 'The plugin has no configuration items.',\n    systemDisabled: 'Plugin System Disabled',\n    systemDisabledDesc:\n      'Plugin system is not enabled, please modify the configuration according to the documentation',\n    connectionError: 'Plugin System Connection Error',\n    connectionErrorDesc:\n      'Please check the plugin system configuration or contact the administrator.',\n    errorDetails: 'Error Details',\n    loadingStatus: 'Checking plugin system status...',\n    failedToGetStatus: 'Failed to get plugin system status',\n    pluginSystemNotReady:\n      'Plugin system is not ready, cannot perform this operation',\n    debugInfo: 'Debug Info',\n    debugInfoTitle: 'Plugin Debug Information',\n    debugUrl: 'Debug URL',\n    debugKey: 'Debug Key',\n    noDebugKey: '(Not Set)',\n    debugKeyDisabled:\n      'Debug key is not set, plugin debugging does not require authentication',\n    failedToGetDebugInfo: 'Failed to get debug information',\n    copiedToClipboard: 'Copied to clipboard',\n    deleting: 'Deleting...',\n    deletePlugin: 'Delete Plugin',\n    cancel: 'Cancel',\n    saveConfig: 'Save Config',\n    saving: 'Saving...',\n    confirmDeletePlugin:\n      'Are you sure you want to delete the plugin ({{author}}/{{name}})?',\n    deleteDataCheckbox:\n      'Also delete plugin configuration and persistence storage',\n    confirmDelete: 'Confirm Delete',\n    deleteError: 'Delete failed: ',\n    close: 'Close',\n    deleteConfirm: 'Delete Confirmation',\n    deleteSuccess: 'Delete successful',\n    modifyFailed: 'Modify failed: ',\n    componentName: {\n      Tool: 'Tool',\n      EventListener: 'Event Listener',\n      Command: 'Command',\n      KnowledgeEngine: 'Knowledge Engine',\n      Parser: 'Parser',\n    },\n    uploadLocal: 'Upload Local',\n    debugging: 'Debugging',\n    uploadLocalPlugin: 'Upload Local Plugin',\n    dragToUpload: 'Drag plugin file here to upload',\n    unsupportedFileType:\n      'Unsupported file type, only .lbpkg and .zip files are supported',\n    uploadingPlugin: 'Uploading plugin...',\n    uploadSuccess: 'Upload successful',\n    uploadFailed: 'Upload failed',\n    selectFileToUpload: 'Select plugin file to upload',\n    askConfirm: 'Are you sure to install plugin \"{{name}}\" ({{version}})?',\n    fromGithub: 'From GitHub',\n    fromLocal: 'From Local',\n    fromMarketplace: 'From Marketplace',\n    componentsList: 'Components: ',\n    noComponents: 'No components',\n    delete: 'Delete Plugin',\n    update: 'Update Plugin',\n    new: 'New',\n    updateConfirm: 'Update Confirmation',\n    confirmUpdatePlugin:\n      'Are you sure you want to update the plugin ({{author}}/{{name}})?',\n    confirmUpdate: 'Confirm Update',\n    updating: 'Updating...',\n    updateSuccess: 'Plugin updated successfully',\n    updateError: 'Update failed: ',\n    saveConfigSuccessNormal: 'Configuration saved successfully',\n    saveConfigError: 'Configuration save failed: ',\n    config: 'Configuration',\n    readme: 'Documentation',\n    viewSource: 'View Source',\n    loadingReadme: 'Loading documentation...',\n    noReadme: 'This plugin does not provide README documentation',\n    fileUpload: {\n      tooLarge: 'File size exceeds 10MB limit',\n      success: 'File uploaded successfully',\n      failed: 'File upload failed',\n      uploading: 'Uploading...',\n      chooseFile: 'Choose File',\n      addFile: 'Add File',\n    },\n    installFromGithub: 'From GitHub',\n    enterRepoUrl: 'Enter GitHub repository URL',\n    repoUrlPlaceholder: 'e.g., https://github.com/owner/repo',\n    fetchingReleases: 'Fetching releases...',\n    selectRelease: 'Select Release',\n    noReleasesFound: 'No releases found',\n    fetchReleasesError: 'Failed to fetch releases: ',\n    selectAsset: 'Select file to install',\n    noAssetsFound: 'No .lbpkg files available in this release',\n    fetchAssetsError: 'Failed to fetch assets: ',\n    backToReleases: 'Back to releases',\n    backToRepoUrl: 'Back to repository URL',\n    backToAssets: 'Back to assets',\n    releaseTag: 'Tag: {{tag}}',\n    releaseName: 'Name: {{name}}',\n    publishedAt: 'Published at: {{date}}',\n    prerelease: 'Pre-release',\n    assetSize: 'Size: {{size}}',\n    confirmInstall: 'Confirm Install',\n    installFromGithubDesc: 'Install plugin from GitHub Release',\n  },\n  market: {\n    searchPlaceholder: 'Search plugins...',\n    searchResults: 'Found {{count}} plugins',\n    totalPlugins: 'Total {{count}} plugins',\n    noPlugins: 'No plugins available',\n    noResults: 'No relevant plugins found',\n    loadingMore: 'Loading more...',\n    loading: 'Loading...',\n    allLoaded: 'All plugins displayed',\n    install: 'Install',\n    installConfirm:\n      'Are you sure you want to install plugin \"{{name}}\" ({{version}})?',\n    downloadComplete: 'Plugin \"{{name}}\" download completed',\n    installFailed: 'Installation failed, please try again later',\n    loadFailed: 'Failed to get plugin list, please try again later',\n    noDescription: 'No description available',\n    notFound: 'Plugin information not found',\n    sortBy: 'Sort by',\n    sort: {\n      recentlyAdded: 'Recently Added',\n      recentlyUpdated: 'Recently Updated',\n      mostDownloads: 'Most Downloads',\n      leastDownloads: 'Least Downloads',\n    },\n    downloads: 'downloads',\n    download: 'Download',\n    repository: 'Repository',\n    downloadFailed: 'Download failed',\n    noReadme: 'This plugin does not provide README documentation',\n    description: 'Description',\n    tagLabel: 'Tags',\n    submissionTitle: 'You have a plugin submission under review: {{name}}',\n    submissionPending: 'Your plugin submission is under review: {{name}}',\n    submissionApproved: 'Your plugin submission has been approved: {{name}}',\n    submissionRejected: 'Your plugin submission has been rejected: {{name}}',\n    clickToRevoke: 'Revoke',\n    revokeSuccess: 'Revoke success',\n    revokeFailed: 'Revoke failed',\n    submissionDetails: 'Plugin Submission Details',\n    markAsRead: 'Mark as Read',\n    markAsReadSuccess: 'Marked as read',\n    markAsReadFailed: 'Mark as read failed',\n    filterByComponent: 'Component',\n    allComponents: 'All Components',\n    requestPlugin: 'Request Plugin',\n    viewDetails: 'View Details',\n    deprecated: 'Deprecated',\n    deprecatedTooltip:\n      'Please install the corresponding Knowledge Engine plugin.',\n    tags: {\n      filterByTags: 'Filter by Tags',\n      selected: 'selected',\n      selectTags: 'Select Tags',\n      clearAll: 'Clear All',\n      noTags: 'No tags available',\n    },\n  },\n  mcp: {\n    title: 'MCP',\n    createServer: 'Add MCP Server',\n    editServer: 'Edit MCP Server',\n    deleteServer: 'Delete MCP Server',\n    confirmDeleteServer: 'Are you sure you want to delete this MCP server?',\n    confirmDeleteTitle: 'Delete MCP Server',\n    getServerListError: 'Failed to get MCP server list: ',\n    serverName: 'Server Name',\n    serverMode: 'Connection Mode',\n    selectMode: 'Select Mode',\n    stdio: 'Stdio Mode',\n    sse: 'SSE Mode',\n    http: 'HTTP Mode',\n    noServerInstalled: 'No MCP servers configured',\n    serverNameRequired: 'Server name cannot be empty',\n    commandRequired: 'Command cannot be empty',\n    urlRequired: 'URL cannot be empty',\n    timeoutMustBePositive: 'Timeout must be a positive number',\n    command: 'Command',\n    args: 'Arguments',\n    env: 'Environment Variables',\n    url: 'URL',\n    headers: 'Headers',\n    timeout: 'Timeout',\n    addArgument: 'Add Argument',\n    addEnvVar: 'Add Environment Variable',\n    addHeader: 'Add Header',\n    keyName: 'Key Name',\n    value: 'Value',\n    testing: 'Testing...',\n    connecting: 'Connecting...',\n    testSuccess: 'Test successful',\n    testFailed: 'Test failed: ',\n    testError: 'Test error',\n    refreshSuccess: 'Refresh successful',\n    refreshFailed: 'Refresh failed: ',\n    connectionSuccess: 'Connection successful',\n    connectionFailed: 'Connection failed, please check URL',\n    connectionFailedStatus: 'Connection Failed',\n    toolsFound: 'tools',\n    unknownError: 'Unknown error',\n    noToolsFound: 'No tools found',\n    parseResultFailed: 'Failed to parse test result',\n    noResultReturned: 'Test returned no result',\n    getTaskFailed: 'Failed to get task status',\n    noTaskId: 'No task ID obtained',\n    deleteSuccess: 'Deleted successfully',\n    deleteFailed: 'Delete failed: ',\n    deleteError: 'Delete failed: ',\n    saveSuccess: 'Saved successfully',\n    saveError: 'Save failed: ',\n    createSuccess: 'Created successfully',\n    createFailed: 'Creation failed: ',\n    createError: 'Creation failed: ',\n    loadFailed: 'Load failed',\n    modifyFailed: 'Modify failed: ',\n    toolCount: 'Tools: {{count}}',\n    statusConnected: 'Connected',\n    statusDisconnected: 'Disconnected',\n    statusError: 'Connection Error',\n    statusDisabled: 'Disabled',\n    loading: 'Loading...',\n    starCount: 'Stars: {{count}}',\n    install: 'Install',\n    installFromGithub: 'Install MCP Server from GitHub',\n    add: 'Add',\n    name: 'Name',\n    nameRequired: 'Name cannot be empty',\n    sseTimeout: 'SSE Timeout',\n    sseTimeoutDescription: 'Timeout for establishing SSE connection',\n    extraParametersDescription:\n      'Additional parameters for configuring specific MCP server behavior',\n    timeoutMustBeNumber: 'Timeout must be a number',\n    timeoutNonNegative: 'Timeout cannot be negative',\n    sseTimeoutMustBeNumber: 'SSE timeout must be a number',\n    sseTimeoutNonNegative: 'SSE timeout cannot be negative',\n    updateSuccess: 'Updated successfully',\n    updateFailed: 'Update failed: ',\n  },\n  pipelines: {\n    title: 'Pipelines',\n    description:\n      'Pipelines define the processing flow for message events, used to bind to bots',\n    createPipeline: 'Create Pipeline',\n    editPipeline: 'Edit Pipeline',\n    chat: 'Chat',\n    configuration: 'Configuration',\n    debugChat: 'Debug Chat',\n    getPipelineListError: 'Failed to get pipeline list: ',\n    daysAgo: 'days ago',\n    today: 'Today',\n    updateTime: 'Updated ',\n    defaultBadge: 'Default',\n    sortBy: 'Sort by',\n    newestCreated: 'Newest Created',\n    earliestCreated: 'Earliest Created',\n    recentlyEdited: 'Recently Edited',\n    earliestEdited: 'Earliest Edited',\n    basicInfo: 'Basic',\n    aiCapabilities: 'AI',\n    triggerConditions: 'Trigger',\n    safetyControls: 'Safety',\n    outputProcessing: 'Output',\n    nameRequired: 'Name cannot be empty',\n    descriptionRequired: 'Description cannot be empty',\n    createSuccess: 'Created successfully. Please edit pipeline parameters',\n    createError: 'Creation failed: ',\n    saveSuccess: 'Saved successfully',\n    saveError: 'Save failed: ',\n    copySuffix: ' Copy',\n    deleteConfirmation:\n      'Are you sure you want to delete this pipeline? Bots bound to this pipeline will not work.',\n    defaultPipelineCannotDelete: 'Default pipeline cannot be deleted',\n    deleteSuccess: 'Deleted successfully',\n    deleteError: 'Delete failed: ',\n    copyConfirmTitle: 'Confirm Copy',\n    copyConfirmation:\n      'Are you sure you want to copy this pipeline? This will create a new pipeline with all configurations.',\n    unsavedChanges: 'You have unsaved changes',\n    extensions: {\n      title: 'Extensions',\n      loadError: 'Failed to load plugins',\n      saveSuccess: 'Saved successfully',\n      saveError: 'Save failed',\n      noPluginsAvailable: 'No plugins available',\n      disabled: 'Disabled',\n      noPluginsSelected: 'No plugins selected',\n      addPlugin: 'Add Plugin',\n      selectPlugins: 'Select Plugins',\n      pluginsTitle: 'Plugins',\n      mcpServersTitle: 'MCP Servers',\n      noMCPServersSelected: 'No MCP servers selected',\n      addMCPServer: 'Add MCP Server',\n      selectMCPServers: 'Select MCP Servers',\n      toolCount: '{{count}} tools',\n      noPluginsInstalled: 'No installed plugins',\n      noMCPServersConfigured: 'No configured MCP servers',\n      selectAll: 'Select All',\n      enableAllPlugins: 'Enable All Plugins',\n      enableAllMCPServers: 'Enable All MCP Servers',\n      allPluginsEnabled: 'All plugins enabled',\n      allMCPServersEnabled: 'All MCP servers enabled',\n    },\n    debugDialog: {\n      title: 'Pipeline Chat',\n      selectPipeline: 'Select Pipeline',\n      sessionType: 'Session Type',\n      privateChat: 'Private Chat',\n      groupChat: 'Group Chat',\n      send: 'Send',\n      reset: 'Reset Conversation',\n      inputPlaceholder: 'Send {{type}} message...',\n      noMessages: 'No messages',\n      userMessage: 'User',\n      botMessage: 'Bot',\n      sendFailed: 'Send failed',\n      resetSuccess: 'Conversation reset successfully',\n      resetFailed: 'Reset failed',\n      loadMessagesFailed: 'Failed to load messages',\n      loadPipelinesFailed: 'Failed to load pipelines',\n      atTips: 'Mention the bot',\n      streaming: 'Streaming',\n      streamOutput: 'Stream',\n      connected: 'WebSocket connected',\n      disconnected: 'WebSocket disconnected',\n      connectionError: 'WebSocket connection error',\n      connectionFailed: 'WebSocket connection failed',\n      notConnected: 'WebSocket not connected, please try again later',\n      imageUploadFailed: 'Image upload failed',\n      reply: 'Reply',\n      replyTo: 'Reply to',\n      showMarkdown: 'Show Markdown',\n      showRaw: 'Show Raw',\n    },\n    monitoring: {\n      title: 'Monitoring',\n      description:\n        'View execution logs and errors for this pipeline (last 24 hours)',\n      detailedLogs: 'Detailed Logs',\n    },\n  },\n  knowledge: {\n    title: 'Knowledge',\n    createKnowledgeBase: 'Create Knowledge Base',\n    editKnowledgeBase: 'Edit Knowledge Base',\n    selectKnowledgeBase: 'Select Knowledge Base',\n    selectKnowledgeBases: 'Select Knowledge Bases',\n    addKnowledgeBase: 'Add Knowledge Base',\n    noKnowledgeBaseSelected: 'No knowledge bases selected',\n    empty: 'Empty',\n    editDocument: 'Documents',\n    description: 'Configuring knowledge bases for improved LLM responses',\n    metadata: 'Metadata',\n    documents: 'Documents',\n    kbNameRequired: 'Knowledge base name cannot be empty',\n    kbDescriptionRequired: 'Knowledge base description cannot be empty',\n    embeddingModelUUIDRequired: 'Embedding model cannot be empty',\n    daysAgo: 'days ago',\n    today: 'Today',\n    kbName: 'Knowledge Base Name',\n    kbDescription: 'Knowledge Base Description',\n    topK: 'Top K',\n    topKRequired: 'Top K cannot be empty',\n    topKMax: 'Top K maximum value is 30',\n    topKdescription:\n      'Used to specify the number of relevant documents to retrieve, ranging from 1 to 30.',\n    defaultDescription: 'A knowledge base',\n    embeddingModelUUID: 'Embedding Model',\n    selectEmbeddingModel: 'Select Embedding Model',\n    embeddingModelDescription:\n      'Used to vectorize the text, you can configure it in the Models page',\n    updateTime: 'Updated ',\n    cannotChangeEmbeddingModel:\n      'Knowledge base created cannot be modified embedding model',\n    updateKnowledgeBaseSuccess: 'Knowledge base updated successfully',\n    updateKnowledgeBaseFailed: 'Knowledge base update failed: ',\n    documentsTab: {\n      name: 'Name',\n      status: 'Status',\n      noResults: 'No documents',\n      dragAndDrop: 'Drag and drop files here or click to upload',\n      uploading: 'Uploading...',\n      supportedFormats:\n        'Supports PDF, Word, TXT, Markdown, HTML, ZIP and other document formats',\n      uploadSuccess: 'File uploaded successfully!',\n      uploadError: 'File upload failed: ',\n      uploadingFile: 'Uploading file...',\n      fileSizeExceeded:\n        'File size exceeds 10MB limit. Please split into smaller files.',\n      actions: 'Actions',\n      delete: 'Delete File',\n      fileDeleteSuccess: 'File deleted successfully',\n      fileDeleteFailed: 'File deletion failed: ',\n      processing: 'Processing',\n      completed: 'Completed',\n      failed: 'Failed',\n      selectParser: 'Select Parser',\n      builtInParser: 'Provided by Knowledge engine',\n      noParserAvailable:\n        'No parser supports this file type. Please install a parser plugin that can handle this format.',\n      confirmUpload: 'Upload',\n      cancelUpload: 'Cancel',\n    },\n    deleteKnowledgeBaseConfirmation:\n      'Are you sure you want to delete this knowledge base? All documents in this knowledge base will be deleted.',\n    retrieve: 'Retrieve Test',\n    retrieveTest: 'Retrieve Test',\n    query: 'Query',\n    queryPlaceholder: 'Enter query text...',\n    distance: 'Distance',\n    content: 'Content',\n    fileName: 'File Name',\n    noResults: 'No results',\n    retrieveError: 'Retrieve failed: ',\n    unknownEngine: 'Unknown Engine',\n    knowledgeEngine: 'Knowledge Engine',\n    knowledgeEngineRequired: 'Knowledge engine is required',\n    selectKnowledgeEngine: 'Select Knowledge Engine',\n    builtInEngine: 'Built-in Engine',\n    cannotChangeKnowledgeEngine:\n      'Knowledge engine cannot be changed after creation',\n    engineSettings: 'Engine Settings',\n    engineSettingsReadonly: 'read-only in edit mode',\n    retrievalSettings: 'Retrieval Settings',\n    noEnginesAvailable: 'No knowledge base engines available',\n    installEngineHint: 'Please install a \"Knowledge Engine\" plugin first',\n    createKnowledgeBaseFailed: 'Failed to create knowledge base: ',\n    loadKnowledgeBaseFailed: 'Failed to load knowledge base: ',\n    deleteKnowledgeBaseFailed: 'Failed to delete knowledge base: ',\n    getKnowledgeBaseListError: 'Failed to get knowledge base list: ',\n    embeddingModel: 'Embedding Model',\n    embeddingModelRequired: 'Embedding model is required for this engine',\n    addExternal: 'Add External Knowledge Base',\n    createExternalSuccess: 'External knowledge base created successfully',\n    updateExternalSuccess: 'External knowledge base updated successfully',\n    deleteExternalSuccess: 'External knowledge base deleted successfully',\n    retriever: 'Retriever',\n    selectRetriever: 'Select a retriever...',\n    retrieverConfiguration: 'Retriever Configuration',\n    retrieverInstallInfo: 'You can install Knowledge Retriever plugins from',\n    retrieverMarketLink: 'here',\n    migration: {\n      title: 'Knowledge Base Migration',\n      description:\n        'The new version has refactored the knowledge base into a plugin-based architecture, unifying built-in and external knowledge bases as \"Knowledge Engine\" plugins. Migration of legacy knowledge base data is required. Your old data has been automatically backed up in the database.',\n      detected:\n        'Found {{total}} knowledge base(s) to migrate ({{internal}} internal, {{external}} external).',\n      startWithInstall: 'Auto-install Plugin & Migrate',\n      startDataOnly: 'Migrate Data Only',\n      dataOnlyHint:\n        '\"Migrate Data Only\" is for offline/intranet environments. Please install the corresponding plugin manually after migration.',\n      dismiss: 'Discard Original Data',\n      running: 'Migrating knowledge bases, please wait...',\n      success: 'Knowledge base migration completed',\n      error: 'Knowledge base migration failed: ',\n      dismissError: 'Operation failed',\n      retry: 'Retry',\n    },\n  },\n  register: {\n    title: 'Initialize LangBot 👋',\n    description: 'This is your first time starting LangBot',\n    adminAccountNote:\n      'The account you use here will be set as the administrator account',\n    register: 'Register',\n    initWithSpace: 'Initialize with Space',\n    spaceRecommended:\n      'Recommended: Use official stable model APIs and cloud services',\n    spaceInfoTip1:\n      'Space provides unified account authentication services without uploading any of your sensitive information.',\n    spaceInfoTip2:\n      'Logging in with a Space account gives you access to LangBot Models and other cloud services, including free model call credits to help you get started quickly.',\n    spaceInfoTip3:\n      'Your login method does not affect other features. You can configure and use models from other sources at any time.',\n    registerLocal: 'Register local account',\n    registerWithPassword: 'Register with email and password',\n    initSuccess: 'Initialization successful, please login',\n    initFailed: 'Initialization failed: ',\n  },\n  resetPassword: {\n    title: 'Reset Password 🔐',\n    description:\n      'Enter your recovery key and new password to reset your account password',\n    recoveryKey: 'Recovery Key',\n    recoveryKeyDescription:\n      'Stored in `system.recovery_key` of config file `data/config.yaml`',\n    newPassword: 'New Password',\n    enterRecoveryKey: 'Enter recovery key',\n    enterNewPassword: 'Enter new password',\n    recoveryKeyRequired: 'Recovery key cannot be empty',\n    newPasswordRequired: 'New password cannot be empty',\n    resetPassword: 'Reset Password',\n    resetting: 'Resetting...',\n    resetSuccess: 'Password reset successfully, please login',\n    resetFailed:\n      'Password reset failed, please check your email and recovery key',\n    backToLogin: 'Back to Login',\n  },\n  embedding: {\n    description: 'Manage Embedding models for text vectorization',\n    createModel: 'Create Embedding Model',\n    editModel: 'Edit Embedding Model',\n    getModelListError: 'Failed to get Embedding model list: ',\n    embeddingModels: 'Embedding',\n    extraParametersDescription:\n      'Will be attached to the request body, such as encoding_format, dimensions, etc.',\n  },\n  llm: {\n    description: 'Manage LLM models for conversation generation',\n    llmModels: 'LLM',\n    extraParametersDescription:\n      'Will be attached to the request body, such as max_tokens, temperature, top_p, etc.',\n  },\n  version: {\n    newVersionAvailable: 'New Version Available',\n    viewUpdateGuide: 'View Update Guide',\n    noReleaseNotes: 'No release notes available',\n  },\n  account: {\n    settings: 'Account Settings',\n    setPassword: 'Set Password',\n    passwordSetSuccess: 'Password set successfully',\n    passwordStatus: 'Local Password',\n    passwordSet: 'Set',\n    passwordNotSet: 'Not Set',\n    passwordSetDescription:\n      'Password is set, you can login with email and password',\n    spaceStatus: 'Space Account',\n    spaceBound: 'Bound',\n    spaceNotBound: 'Not Bound',\n    spaceBoundDescription:\n      'Space account bound, official model APIs and cloud services available',\n    bindSpace: 'Bind Space Account',\n    bindSpaceDescription: 'Bind to use official model APIs and cloud services',\n    bindSpaceButton: 'Bind',\n    bindSpaceConfirmTitle: 'Confirm Binding',\n    bindSpaceConfirmDescription:\n      'You are about to bind your local instance to a Space account',\n    bindSpaceWarning:\n      'After binding, your login email will be changed from {{localEmail}} to the Space account email.',\n    bindSpaceSuccess: 'Space account bound successfully',\n    bindSpaceFailed: 'Failed to bind Space account',\n    bindSpaceInvalidState:\n      'Invalid bind request. Please try again from account settings.',\n    setPasswordHint: 'Set a password to login with email and password',\n    spaceEmailMismatch:\n      'Space login email does not match the local account email',\n  },\n  monitoring: {\n    title: 'Monitoring',\n    description: 'Monitor bot activities, LLM calls, and system performance',\n    overview: 'Overview',\n    totalMessages: 'Total Messages',\n    llmCallsCount: 'LLM Calls',\n    modelCallsCount: 'Model Calls',\n    successRate: 'Success Rate',\n    activeSessions: 'Active Sessions',\n    last24Hours: 'Last 24 hours',\n    filters: {\n      title: 'Filters',\n      bot: 'Bot',\n      pipeline: 'Pipeline',\n      allBots: 'All Bots',\n      selectBot: 'Select Bot',\n      allPipelines: 'All Pipelines',\n      selectPipeline: 'Select Pipeline',\n      loading: 'Loading...',\n      timeRange: 'Time Range',\n      customRange: 'Custom Range',\n      from: 'From',\n      to: 'To',\n      apply: 'Apply',\n      reset: 'Reset Filters',\n      lastHour: 'Last 1 hour',\n      last6Hours: 'Last 6 hours',\n      last24Hours: 'Last 24 hours',\n      last7Days: 'Last 7 days',\n      last30Days: 'Last 30 days',\n    },\n    tabs: {\n      messages: 'Message Records',\n      llmCalls: 'LLM Calls',\n      embeddingCalls: 'Embedding Calls',\n      modelCalls: 'Model Calls',\n      sessions: 'Session Analysis',\n      errors: 'Error Logs',\n    },\n    messageList: {\n      timestamp: 'Timestamp',\n      bot: 'Bot',\n      pipeline: 'Pipeline',\n      message: 'Message',\n      sessionId: 'Session ID',\n      status: 'Status',\n      actions: 'Actions',\n      viewDetails: 'View Details',\n      copyId: 'Copy ID',\n      noMessages: 'No messages found',\n      noMessagesDescription: 'Try adjusting your filters or check back later',\n      loading: 'Loading messages...',\n      loadMore: 'Load More',\n      autoRefresh: 'Auto Refresh',\n      platform: 'Role',\n      user: 'User',\n      level: 'Level',\n      runner: 'Runner',\n      viewConversation: 'View Conversation',\n    },\n    llmCalls: {\n      title: 'LLM Calls',\n      model: 'Model',\n      tokens: 'Tokens',\n      duration: 'Duration',\n      cost: 'Cost',\n      noData: 'No LLM calls found',\n      inputTokens: 'Input Tokens',\n      outputTokens: 'Output Tokens',\n      totalTokens: 'Total Tokens',\n      avgDuration: 'Avg Duration',\n      calls: 'Calls',\n    },\n    embeddingCalls: {\n      title: 'Embedding Calls',\n      model: 'Model',\n      tokens: 'Tokens',\n      duration: 'Duration',\n      noData: 'No embedding calls found',\n      promptTokens: 'Prompt Tokens',\n      totalTokens: 'Total Tokens',\n      inputCount: 'Input Count',\n      knowledgeBase: 'Knowledge Base',\n      queryText: 'Query',\n    },\n    modelCalls: {\n      title: 'Model Calls',\n      llmModel: 'LLM',\n      embeddingModel: 'Embedding',\n      embeddingCall: 'Embedding',\n      retrieveCall: 'Retrieve',\n      noData: 'No model calls found',\n    },\n    sessions: {\n      sessionId: 'Session ID',\n      messageCount: 'Messages',\n      duration: 'Duration',\n      lastActivity: 'Last Activity',\n      noSessions: 'No sessions found',\n      startTime: 'Start Time',\n      messageStats: 'Message Statistics',\n      totalMessages: 'Total Messages',\n      successMessages: 'Successful',\n      errorMessages: 'Failed',\n      llmStats: 'LLM Statistics',\n      noData: 'Session not found',\n    },\n    errors: {\n      title: 'Errors',\n      errorType: 'Error Type',\n      errorMessage: 'Error Message',\n      occurredAt: 'Occurred At',\n      noErrors: 'No errors found',\n      stackTrace: 'Stack Trace',\n    },\n    queries: {\n      title: 'Queries',\n    },\n    messageDetails: {\n      noData: 'No LLM calls or errors for this query',\n    },\n    queryVariables: {\n      title: 'Query Variables',\n    },\n    trafficChart: {\n      title: 'Traffic Overview',\n      messages: 'Messages',\n      llmCalls: 'LLM Calls',\n      noData: 'No traffic data available',\n    },\n    viewMonitoring: 'View Monitoring',\n    refreshData: 'Refresh Data',\n    exportData: 'Export Data',\n    export: {\n      title: 'Export Data',\n      exporting: 'Exporting...',\n      messages: 'Messages',\n      llmCalls: 'LLM Calls',\n      embeddingCalls: 'Embedding Calls',\n      errors: 'Error Logs',\n      sessions: 'Sessions',\n    },\n  },\n  limitation: {\n    maxBotsReached:\n      'Maximum number of bots ({{max}}) reached. Please remove an existing bot before creating a new one.',\n    maxPipelinesReached:\n      'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',\n    maxExtensionsReached:\n      'Maximum number of extensions ({{max}}) reached. Please remove an existing MCP server or plugin before adding a new one.',\n  },\n};\n\nexport default enUS;\n"
  },
  {
    "path": "web/src/i18n/locales/ja-JP.ts",
    "content": "﻿const jaJP = {\n  common: {\n    login: 'ログイン',\n    logout: 'ログアウト',\n    accountOptions: 'システム設定',\n    account: 'アカウント',\n    integration: '連携',\n    email: 'メールアドレス',\n    password: 'パスワード',\n    welcome: 'LangBot へおかえりなさい 👋',\n    continueToLogin: 'ログインしてください',\n    loginSuccess: 'ログインに成功しました',\n    loginFailed:\n      'ログインに失敗しました。メールアドレスまたはパスワードをご確認ください',\n    loginLoadError: 'サーバーに接続できません',\n    loginLoadErrorDesc:\n      'LangBot バックエンドに接続できません。サービスが起動していることを確認してから再試行してください。',\n    retry: '再試行',\n    enterEmail: 'メールアドレスを入力',\n    enterPassword: 'パスワードを入力',\n    invalidEmail: '有効なメールアドレスを入力してください',\n    emptyPassword: 'パスワードを入力してください',\n    language: '言語',\n    helpDocs: 'ヘルプドキュメント',\n    featureRequest: '機能リクエスト',\n    create: '作成',\n    edit: '編集',\n    delete: '削除',\n    add: '追加',\n    select: '選択してください',\n    cancel: 'キャンセル',\n    submit: '送信',\n    error: 'エラー',\n    success: '成功',\n    save: '保存',\n    saving: '保存中...',\n    confirm: '確認',\n    confirmDelete: '削除の確認',\n    deleteConfirmation: '本当に削除しますか？',\n    selectOption: 'オプションを選択',\n    required: '必須',\n    enable: '有効にする',\n    name: '名前',\n    description: '説明',\n    icon: 'アイコン',\n    close: '閉じる',\n    deleteSuccess: '削除に成功しました',\n    deleteError: '削除に失敗しました：',\n    addRound: 'ラウンドを追加',\n    copy: 'コピー',\n    copySuccess: 'コピーに成功しました',\n    copyFailed: 'コピーに失敗しました',\n    test: 'テスト',\n    forgotPassword: 'パスワードを忘れた？',\n    agreementNotice: '続行することで、以下に同意したものとみなされます：',\n    privacyPolicy: 'プライバシーポリシー',\n    and: 'および',\n    dataCollectionPolicy: 'データ収集ポリシー',\n    dataCollectionPolicyUrl:\n      'https://docs.langbot.app/ja/insight/data-collection-policy',\n    loading: '読み込み中...',\n    fieldRequired: 'この項目は必須です',\n    or: 'または',\n    loginWithSpace: 'Space でログイン',\n    spaceLoginRecommended:\n      'おすすめ：公式の安定したモデル API とクラウドサービスを利用',\n    loginLocal: 'ローカルアカウントでログイン',\n    loginWithPassword: 'パスワードでログイン',\n    spaceLoginTitle: 'Space でログイン',\n    spaceLoginDescription:\n      'QRコードをスキャンするか、下のリンクにアクセスして認証してください',\n    spaceLoginUserCode: '認証コード',\n    spaceLoginExpires: 'コードは {{seconds}} 秒後に期限切れになります',\n    spaceLoginWaiting: '認証を待っています...',\n    spaceLoginSuccess: '認証に成功しました',\n    spaceLoginFailed: 'Space ログインに失敗しました',\n    spaceLoginExpired:\n      '認証コードの有効期限が切れました。もう一度お試しください',\n    spaceLoginCancel: 'キャンセル',\n    spaceLoginVisitLink: 'リンクにアクセス',\n    spaceLoginProcessing: 'Space でログイン中',\n    spaceLoginProcessingDescription:\n      'ログインを完了しています。しばらくお待ちください...',\n    spaceLoginSuccessDescription: 'LangBot にリダイレクト中...',\n    spaceLoginError: 'ログインに失敗しました',\n    spaceLoginNoCode: '認証コードがありません',\n    backToLogin: 'ログインに戻る',\n    backToHome: 'ホームに戻る',\n    spaceAccountCannotChangePassword:\n      'Space アカウントはここでパスワードを変更できません',\n    theme: 'テーマ',\n    changePassword: 'パスワードを変更',\n    currentPassword: '現在のパスワード',\n    newPassword: '新しいパスワード',\n    confirmNewPassword: '新しいパスワードを確認',\n    enterCurrentPassword: '現在のパスワードを入力',\n    enterNewPassword: '新しいパスワードを入力',\n    enterConfirmPassword: '新しいパスワードを確認',\n    currentPasswordRequired: '現在のパスワードは必須です',\n    newPasswordRequired: '新しいパスワードは必須です',\n    confirmPasswordRequired: '新しいパスワードを確認してください',\n    passwordsDoNotMatch: '新しいパスワードが一致しません',\n    changePasswordSuccess: 'パスワードの変更に成功しました',\n    changePasswordFailed:\n      'パスワードの変更に失敗しました。現在のパスワードを確認してください',\n    apiIntegration: 'API統合',\n    apiKeys: 'API キー',\n    manageApiIntegration: 'API統合の管理',\n    manageApiKeys: 'API キーの管理',\n    createApiKey: 'API キーを作成',\n    apiKeyName: 'API キー名',\n    apiKeyDescription: 'API キーの説明',\n    apiKeyValue: 'API キー値',\n    apiKeyCreated: 'API キーの作成に成功しました',\n    apiKeyDeleted: 'API キーの削除に成功しました',\n    apiKeyDeleteConfirm: 'この API キーを削除してもよろしいですか？',\n    apiKeyNameRequired: 'API キー名は必須です',\n    copyApiKey: 'API キーをコピー',\n    apiKeyCopied: 'API キーをクリップボードにコピーしました',\n    noApiKeys: 'API キーが設定されていません',\n    apiKeyHint:\n      'API キーを使用すると、外部システムが LangBot Service API にアクセスできます',\n    webhooks: 'Webhooks',\n    createWebhook: 'Webhook を作成',\n    webhookName: 'Webhook 名',\n    webhookUrl: 'Webhook URL',\n    webhookDescription: 'Webhook の説明',\n    webhookEnabled: '有効',\n    webhookCreated: 'Webhook が正常に作成されました',\n    webhookDeleted: 'Webhook が正常に削除されました',\n    webhookDeleteConfirm: 'この Webhook を削除してもよろしいですか？',\n    webhookNameRequired: 'Webhook 名は必須です',\n    webhookUrlRequired: 'Webhook URL は必須です',\n    noWebhooks: 'Webhook が設定されていません',\n    webhookHint:\n      'Webhook を使用すると、LangBot は個人メッセージとグループメッセージイベントを外部システムにプッシュできます',\n    actions: 'アクション',\n    apiKeyCreatedMessage:\n      'この API キーをコピーしてください。もしボタンが無効な場合は手動でコピーしてください。',\n    none: 'なし',\n  },\n  notFound: {\n    title: 'ページが見つかりません',\n    description:\n      'お探しのページは存在しないようです。入力したURLが正しいか確認するか、ホームページに戻ってください。',\n    back: '戻る',\n    home: 'ホームに戻る',\n    help: 'ヘルプドキュメントを見る',\n  },\n  models: {\n    title: 'モデル設定',\n    description: 'パイプラインで使用できるモデルを設定・管理',\n    createModel: 'モデルを作成',\n    editModel: 'モデルを編集',\n    getModelListError: 'モデルリストの取得に失敗しました：',\n    modelName: 'モデル名',\n    modelProvider: 'モデルプロバイダー',\n    modelBaseURL: 'ベースURL',\n    modelAbilities: 'モデル機能',\n    saveSuccess: '保存に成功しました',\n    saveError: '保存に失敗しました：',\n    createSuccess: '作成に成功しました',\n    createError: '作成に失敗しました：',\n    deleteSuccess: '削除に成功しました',\n    deleteError: '削除に失敗しました：',\n    deleteConfirmation: '本当にこのモデルを削除しますか？',\n    modelNameRequired: 'モデル名は必須です',\n    modelProviderRequired: 'モデルプロバイダーは必須です',\n    requestURLRequired: 'リクエストURLは必須です',\n    apiKeyRequired: 'APIキーは必須です',\n    keyNameRequired: 'キー名は必須です',\n    mustBeValidNumber: '有効な数値である必要があります',\n    mustBeTrueOrFalse: 'true または false である必要があります',\n    requestURL: 'リクエストURL',\n    apiKey: 'APIキー',\n    abilities: '機能',\n    selectModelAbilities: 'モデル機能を選択',\n    visionAbility: '視覚機能',\n    functionCallAbility: '関数呼び出し',\n    extraParameters: '追加パラメータ',\n    addParameter: 'パラメータを追加',\n    keyName: 'キー名',\n    type: 'タイプ',\n    value: '値',\n    string: '文字列',\n    number: '数値',\n    boolean: 'ブール値',\n    extraParametersDescription:\n      'リクエストボディに追加されるパラメータ（max_tokens、temperature、top_p など）',\n    selectModelProvider: 'モデルプロバイダーを選択',\n    modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください',\n    modelManufacturer: 'モデルメーカー',\n    aggregationPlatform: 'アグリゲーションプラットフォーム',\n    selfDeployed: 'セルフデプロイ',\n    builtin: 'ビルトイン',\n    selectModel: 'モデルを選択してください',\n    testSuccess: 'テストに成功しました',\n    testError: 'テストに失敗しました。モデル設定を確認してください',\n    llmModels: 'LLM モデル',\n    localProvider: 'ローカル',\n    localProviderDescription: 'ローカルで設定・管理されているモデル',\n    spaceProviderDescription: 'Space アカウントから同期されたモデル',\n    spaceDisabledForLocalAccount: 'Space でログインしてクラウドモデルを使用',\n    syncModels: '同期',\n    syncSuccess: '同期完了：{{created}} 件作成、{{updated}} 件更新',\n    syncError: '同期に失敗しました：',\n    spaceModelReadOnly: 'Space モデルは読み取り専用です',\n    noSpaceModels:\n      'Space モデルがありません。同期ボタンをクリックして Space からモデルを取得してください。',\n    noLocalModels:\n      'ローカルモデルがありません。作成ボタンをクリックしてモデルを追加してください。',\n    providerCount: '{{count}} 件のプロバイダー',\n    addModel: 'モデルを追加',\n    addLLMModel: 'LLMモデルを追加',\n    addEmbeddingModel: '埋め込みモデルを追加',\n    provider: 'プロバイダー',\n    existingProvider: '既存のプロバイダー',\n    newProvider: '新規プロバイダー',\n    selectProvider: 'プロバイダーを選択',\n    requester: 'プロバイダータイプ',\n    selectRequester: 'プロバイダータイプを選択',\n    langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',\n    credits: 'クレジット',\n    loginWithSpace: 'Space でログイン',\n    loginToUseModels: 'Space でログインしてクラウドモデルを使用',\n    noModels: 'モデルがありません',\n    editProvider: 'プロバイダーを編集',\n    addProvider: 'プロバイダーを追加',\n    addProviderHint:\n      '他のソースのモデルを使用するにはプロバイダーを追加してください',\n    addProviderHintSimple: 'モデルを使用するにはプロバイダーを追加してください',\n    noProviders: 'プロバイダーがありません',\n    providerName: 'プロバイダー名',\n    providerNameRequired: 'プロバイダー名は必須です',\n    requesterRequired: 'プロバイダータイプは必須です',\n    providerSaved: 'プロバイダーを保存しました',\n    providerCreated: 'プロバイダーを作成しました',\n    providerSaveError: 'プロバイダーの保存に失敗しました：',\n    providerDeleted: 'プロバイダーを削除しました',\n    providerDeleteError: 'プロバイダーの削除に失敗しました：',\n    deleteProviderConfirmation: 'このプロバイダーを削除してもよろしいですか？',\n    loadError: 'データの読み込みに失敗しました',\n    chat: 'チャット',\n    embedding: '埋め込み',\n    modelsCount: '{{count}} 個のモデル',\n    expandModels: '展開',\n    collapseModels: '折りたたむ',\n    fallback: {\n      primary: 'プライマリモデル',\n      fallbackList: 'フォールバックモデル',\n      addFallback: 'フォールバックモデルを追加',\n    },\n  },\n  bots: {\n    title: 'ボット',\n    description:\n      'ボットの作成と管理を行います。LangBotと各プラットフォームを接続するためのエントリーポイントです',\n    createBot: 'ボットを作成',\n    editBot: 'ボットを編集',\n    getBotListError: 'ボットリストの取得に失敗しました：',\n    botName: 'ボット名',\n    botDescription: 'ボットの説明',\n    botNameRequired: 'ボット名は必須です',\n    botDescriptionRequired: 'ボットの説明は必須です',\n    adapterRequired: 'アダプターは必須です',\n    defaultDescription: 'ボット',\n    getBotConfigError: 'ボット設定の取得に失敗しました：',\n    saveSuccess: '保存に成功しました',\n    saveError: '保存に失敗しました：',\n    createSuccess:\n      '作成が完了しました。有効化するか、パイプラインの設定を行ってください',\n    createError: '作成に失敗しました：',\n    deleteSuccess: '削除に成功しました',\n    deleteError: '削除に失敗しました：',\n    deleteConfirmation: '本当にこのボットを削除しますか？',\n    platformAdapter: 'プラットフォーム/アダプター選択',\n    selectAdapter: 'アダプターを選択',\n    adapterConfig: 'アダプター設定',\n    bindPipeline: 'パイプラインを紐付け',\n    selectPipeline: 'パイプラインを選択',\n    selectBot: 'ボットを選択してください',\n    botLogTitle: 'ボットログ',\n    enableAutoRefresh: '自動更新を有効にする',\n    session: 'セッション',\n    yesterday: '昨日',\n    earlier: 'それ以前',\n    dateFormat: '{{month}}月{{day}}日',\n    setBotEnableError: 'ボットの有効状態の設定に失敗しました',\n    log: 'ログ',\n    configuration: '設定',\n    logs: 'ログ',\n    webhookUrl: 'Webhook コールバック URL',\n    webhookUrlCopied: 'Webhook URL をコピーしました',\n    webhookUrlHint:\n      '入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',\n    webhookUrlHintEither:\n      '上記の2つのURLのいずれかをプラットフォーム設定に使用してください',\n    logLevel: 'ログレベル',\n    allLevels: 'すべてのレベル',\n    selectLevel: 'レベルを選択',\n    levelsSelected: 'レベル選択済み',\n    sessionMonitor: {\n      title: 'セッション監視',\n      sessions: 'セッション一覧',\n      noSessions: 'セッションが見つかりません',\n      selectSession: 'セッションを選択してメッセージを表示',\n      noMessages: 'このセッションにはメッセージがありません',\n      messages: '件のメッセージ',\n      messageCount: '{{count}} 件のメッセージ',\n      loading: '読み込み中...',\n      loadingSessions: 'セッションを読み込み中...',\n      loadingMessages: 'メッセージを読み込み中...',\n      user: 'ユーザー',\n      variables: '変数',\n      platform: 'プラットフォーム',\n      lastActive: '最終アクティブ',\n      refresh: '更新',\n      active: 'アクティブ',\n      inactive: '非アクティブ',\n    },\n  },\n  plugins: {\n    title: '拡張機能',\n    description:\n      'LangBotの機能を拡張するプラグインをインストール・設定。流水線設定で使用します',\n    createPlugin: 'プラグインを作成',\n    editPlugin: 'プラグインを編集',\n    installed: 'インストール済み',\n    marketplace: 'プラグインマーケット',\n    arrange: '並び替え',\n    install: 'インストール',\n    installPlugin: 'プラグインをインストール',\n    onlySupportGithub: '現在はGitHubからのインストールのみサポートしています',\n    enterGithubLink: 'プラグインのGitHubリンクを入力してください',\n    installing: 'プラグインをインストール中...',\n    installSuccess: 'プラグインのインストールに成功しました',\n    installFailed: 'プラグインのインストールに失敗しました：',\n    searchPlugin: 'プラグインを検索',\n    sortBy: '並び順',\n    mostStars: 'スター数順',\n    recentlyAdded: '最近追加',\n    recentlyUpdated: '最近更新',\n    noMatchingPlugins: '一致するプラグインが見つかりません',\n    loading: '読み込み中...',\n    getPluginListError: 'プラグインリストの取得に失敗しました：',\n    noPluginInstalled: 'プラグインがインストールされていません',\n    pluginConfig: 'プラグイン設定',\n    pluginSort: 'プラグインの並び替え',\n    pluginSortDescription:\n      'プラグインの順序は、同一イベント内での処理順序に影響します。カードをドラッグして並び替えが可能です',\n    pluginSortSuccess: 'プラグインの並び替えに成功しました',\n    pluginSortError: 'プラグインの並び替えに失敗しました：',\n    pluginNoConfig: 'プラグインに設定項目がありません。',\n    systemDisabled: 'プラグインシステムが無効になっています',\n    systemDisabledDesc:\n      'プラグインシステムが無効になっています。プラグインシステムを有効にするか、ドキュメントに従って設定を変更してください',\n    connectionError: 'プラグインシステム接続エラー',\n    connectionErrorDesc:\n      'プラグインシステム設定を確認するか、管理者に連絡してください',\n    errorDetails: 'エラー詳細',\n    loadingStatus: 'プラグインシステム状態を確認中...',\n    failedToGetStatus: 'プラグインシステム状態の取得に失敗しました',\n    pluginSystemNotReady:\n      'プラグインシステムが準備されていません。この操作を実行できません',\n    debugInfo: 'デバッグ情報',\n    debugInfoTitle: 'プラグインデバッグ情報',\n    debugUrl: 'デバッグURL',\n    debugKey: 'デバッグキー',\n    noDebugKey: '(未設定)',\n    debugKeyDisabled:\n      'デバッグキーが設定されていません。プラグインデバッグには認証が不要です',\n    failedToGetDebugInfo: 'デバッグ情報の取得に失敗しました',\n    copiedToClipboard: 'クリップボードにコピーしました',\n    deleting: '削除中...',\n    deletePlugin: 'プラグインを削除',\n    cancel: 'キャンセル',\n    saveConfig: '設定を保存',\n    saving: '保存中...',\n    confirmDeletePlugin:\n      'プラグイン「{{author}}/{{name}}」を削除してもよろしいですか？',\n    deleteDataCheckbox: 'プラグイン設定と永続化ストレージも削除する',\n    confirmDelete: '削除を確認',\n    deleteError: '削除に失敗しました：',\n    close: '閉じる',\n    deleteConfirm: '削除の確認',\n    deleteSuccess: '削除に成功しました',\n    modifyFailed: '変更に失敗しました：',\n    componentName: {\n      Tool: 'ツール',\n      EventListener: 'イベント監視器',\n      Command: 'コマンド',\n      KnowledgeEngine: '知識エンジン',\n      Parser: 'パーサー',\n    },\n    uploadLocal: 'ローカルアップロード',\n    debugging: 'デバッグ中',\n    uploadLocalPlugin: 'ローカルプラグインのアップロード',\n    dragToUpload: 'ファイルをここにドラッグしてアップロード',\n    unsupportedFileType:\n      'サポートされていないファイルタイプです。.lbpkg と .zip ファイルのみサポートされています',\n    uploadingPlugin: 'プラグインをアップロード中...',\n    uploadSuccess: 'アップロード成功',\n    uploadFailed: 'アップロード失敗',\n    selectFileToUpload: 'アップロードするプラグインファイルを選択',\n    askConfirm: 'プラグイン \"{{name}}\" ({{version}}) をインストールしますか？',\n    fromGithub: 'GitHubから',\n    fromLocal: 'ローカルから',\n    fromMarketplace: 'プラグインマーケットから',\n    componentsList: '部品：',\n    noComponents: '部品がありません',\n    delete: 'プラグインを削除',\n    update: 'プラグインを更新',\n    new: 'New',\n    updateConfirm: '更新の確認',\n    confirmUpdatePlugin:\n      'プラグイン「{{author}}/{{name}}」を更新してもよろしいですか？',\n    confirmUpdate: '更新を確認',\n    updating: '更新中...',\n    updateSuccess: 'プラグインの更新に成功しました',\n    updateError: '更新に失敗しました：',\n    saveConfigSuccessNormal: '設定を保存しました',\n    saveConfigError: '設定の保存に失敗しました：',\n    config: '設定',\n    readme: 'ドキュメント',\n    viewSource: 'ソースを表示',\n    loadingReadme: 'ドキュメントを読み込み中...',\n    noReadme: 'このプラグインはREADMEドキュメントを提供していません',\n    fileUpload: {\n      tooLarge: 'ファイルサイズが 10MB の制限を超えています',\n      success: 'ファイルのアップロードに成功しました',\n      failed: 'ファイルのアップロードに失敗しました',\n      uploading: 'アップロード中...',\n      chooseFile: 'ファイルを選択',\n      addFile: 'ファイルを追加',\n    },\n    installFromGithub: 'GitHubから',\n    enterRepoUrl: 'GitHubリポジトリのURLを入力してください',\n    repoUrlPlaceholder: '例: https://github.com/owner/repo',\n    fetchingReleases: 'リリース一覧を取得中...',\n    selectRelease: 'リリースを選択',\n    noReleasesFound: 'リリースが見つかりません',\n    fetchReleasesError: 'リリース一覧の取得に失敗しました：',\n    selectAsset: 'インストールするファイルを選択',\n    noAssetsFound: 'このリリースには利用可能な .lbpkg ファイルがありません',\n    fetchAssetsError: 'ファイル一覧の取得に失敗しました：',\n    backToReleases: 'リリース一覧に戻る',\n    backToRepoUrl: 'リポジトリURLに戻る',\n    backToAssets: 'ファイル選択に戻る',\n    releaseTag: 'タグ: {{tag}}',\n    releaseName: '名前: {{name}}',\n    publishedAt: '公開日: {{date}}',\n    prerelease: 'プレリリース',\n    assetSize: 'サイズ: {{size}}',\n    confirmInstall: 'インストールを確認',\n    installFromGithubDesc: 'GitHubリリースからプラグインをインストール',\n  },\n  market: {\n    searchPlaceholder: 'プラグインを検索...',\n    searchResults: '{{count}} 個のプラグインが見つかりました',\n    totalPlugins: '合計 {{count}} 個のプラグイン',\n    noPlugins: '利用可能なプラグインがありません',\n    noResults: '関連するプラグインが見つかりません',\n    loadingMore: 'さらに読み込み中...',\n    loading: '読み込み中...',\n    allLoaded: 'すべてのプラグインが表示されました',\n    install: 'インストール',\n    installConfirm:\n      'プラグイン \"{{name}}\" ({{version}}) をインストールしますか？',\n    downloadComplete: 'プラグイン \"{{name}}\" のダウンロードが完了しました',\n    installFailed: 'インストールに失敗しました。後でもう一度お試しください',\n    loadFailed:\n      'プラグインリストの取得に失敗しました。後でもう一度お試しください',\n    noDescription: '説明がありません',\n    notFound: 'プラグイン情報が見つかりません',\n    sortBy: '並び順',\n    sort: {\n      recentlyAdded: '最近追加',\n      recentlyUpdated: '最近更新',\n      mostDownloads: 'ダウンロード数多',\n      leastDownloads: 'ダウンロード数少',\n    },\n    downloads: '回ダウンロード',\n    download: 'ダウンロード',\n    repository: 'リポジトリ',\n    downloadFailed: 'ダウンロード失敗',\n    noReadme: 'このプラグインはREADMEドキュメントを提供していません',\n    description: '説明',\n    tagLabel: 'タグ',\n    submissionTitle: 'プラグインの提出が審査中です： {{name}}',\n    submissionPending: 'プラグインの提出が審査中です： {{name}}',\n    submissionApproved: 'プラグインの提出が承認されました： {{name}}',\n    submissionRejected: 'プラグインの提出が拒否されました： {{name}}',\n    clickToRevoke: '取り消し',\n    revokeSuccess: '取り消し成功',\n    revokeFailed: '取り消し失敗',\n    submissionDetails: 'プラグイン提出詳細',\n    markAsRead: '既読',\n    markAsReadSuccess: '既読に設定しました',\n    markAsReadFailed: '既読に設定に失敗しました',\n    filterByComponent: 'コンポーネント',\n    allComponents: '全部コンポーネント',\n    requestPlugin: 'プラグインをリクエスト',\n    tags: {\n      filterByTags: 'タグで絞り込み',\n      selected: '選択済み',\n      selectTags: 'タグを選択',\n      clearAll: 'クリア',\n      noTags: 'タグがありません',\n    },\n    viewDetails: '詳細を表示',\n    deprecated: '非推奨',\n    deprecatedTooltip:\n      '対応する「ナレッジエンジン」プラグインをインストールしてください。',\n  },\n  mcp: {\n    title: 'MCP',\n    createServer: 'MCPサーバーを追加',\n    editServer: 'MCPサーバーを編集',\n    deleteServer: 'MCPサーバーを削除',\n    confirmDeleteServer: 'このMCPサーバーを削除してもよろしいですか？',\n    confirmDeleteTitle: 'MCPサーバーを削除',\n    getServerListError: 'MCPサーバーリストの取得に失敗しました：',\n    serverName: 'サーバー名',\n    serverMode: '接続モード',\n    stdio: 'Stdioモード',\n    sse: 'SSEモード',\n    http: 'HTTPモード',\n    selectMode: '接続モードを選択',\n    noServerInstalled: 'MCPサーバーが設定されていません',\n    serverNameRequired: 'サーバー名は必須です',\n    commandRequired: 'コマンドは必須です',\n    urlRequired: 'URLは必須です',\n    timeoutMustBePositive: 'タイムアウトは正の数でなければなりません',\n    command: 'コマンド',\n    args: '引数',\n    env: '環境変数',\n    url: 'URL',\n    headers: 'ヘッダー',\n    timeout: 'タイムアウト',\n    addArgument: '引数を追加',\n    addEnvVar: '環境変数を追加',\n    addHeader: 'ヘッダーを追加',\n    keyName: 'キー名',\n    value: '値',\n    testing: 'テスト中...',\n    connecting: '接続中...',\n    testSuccess: '刷新に成功しました',\n    testFailed: '刷新に失敗しました：',\n    testError: '刷新エラー',\n    refreshSuccess: '刷新に成功しました',\n    refreshFailed: '刷新に失敗しました：',\n    connectionSuccess: '接続に成功しました',\n    connectionFailed: '接続に失敗しました，URLを確認してください',\n    connectionFailedStatus: '接続失敗',\n    toolsFound: '個のツール',\n    unknownError: '不明なエラー',\n    noToolsFound: 'ツールが見つかりません',\n    parseResultFailed: 'テスト結果の解析に失敗しました',\n    noResultReturned: 'テスト結果が返されませんでした',\n    getTaskFailed: 'タスクステータスの取得に失敗しました',\n    noTaskId: 'タスクIDを取得できませんでした',\n    deleteSuccess: '削除に成功しました',\n    deleteFailed: '削除に失敗しました：',\n    deleteError: '削除に失敗しました：',\n    saveSuccess: '保存に成功しました',\n    saveError: '保存に失敗しました：',\n    createSuccess: '作成に成功しました',\n    createFailed: '作成に失敗しました：',\n    createError: '作成に失敗しました：',\n    loadFailed: '読み込みに失敗しました',\n    modifyFailed: '変更に失敗しました：',\n    toolCount: 'ツール：{{count}}',\n    statusConnected: '接続済み',\n    statusDisconnected: '未接続',\n    statusError: '接続エラー',\n    statusDisabled: '無効',\n    loading: '読み込み中...',\n    starCount: 'スター：{{count}}',\n    install: 'インストール',\n    installFromGithub: 'GitHubからMCPサーバーをインストール',\n    add: '追加',\n    name: '名前',\n    nameRequired: '名前は必須です',\n    sseTimeout: 'SSEタイムアウト',\n    sseTimeoutDescription: 'SSE接続を確立するためのタイムアウト',\n    extraParametersDescription:\n      'MCPサーバーの特定の動作を設定するための追加パラメータ',\n    timeoutMustBeNumber: 'タイムアウトは数値である必要があります',\n    timeoutNonNegative: 'タイムアウトは負の数にできません',\n    sseTimeoutMustBeNumber: 'SSEタイムアウトは数値である必要があります',\n    sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません',\n    updateSuccess: '更新に成功しました',\n    updateFailed: '更新に失敗しました：',\n  },\n  pipelines: {\n    title: 'パイプライン',\n    description:\n      'メッセージイベントの処理フローを定義し、ボットに紐付けて使用するパイプラインです',\n    createPipeline: 'パイプラインを作成',\n    editPipeline: 'パイプラインを編集',\n    chat: 'チャット',\n    configuration: '設定',\n    debugChat: 'チャットデバッグ',\n    getPipelineListError: 'パイプラインリストの取得に失敗しました：',\n    daysAgo: '日前',\n    today: '今日',\n    updateTime: '更新日時',\n    defaultBadge: 'デフォルト',\n    sortBy: '並び順',\n    newestCreated: '最新作成',\n    earliestCreated: '最古作成',\n    recentlyEdited: '最近編集',\n    earliestEdited: '最古編集',\n    basicInfo: '基本情報',\n    aiCapabilities: 'AI機能',\n    triggerConditions: 'トリガー条件',\n    safetyControls: '安全制御',\n    outputProcessing: '出力処理',\n    nameRequired: '名前は必須です',\n    descriptionRequired: '説明は必須です',\n    createSuccess:\n      '作成が完了しました。パイプラインの詳細パラメータを設定してください',\n    createError: '作成に失敗しました：',\n    saveSuccess: '保存に成功しました',\n    saveError: '保存に失敗しました：',\n    copySuffix: ' Copy',\n    deleteConfirmation:\n      '本当にこのパイプラインを削除しますか？このパイプラインに紐付けられたボットは動作しなくなります。',\n    defaultPipelineCannotDelete: 'デフォルトパイプラインは削除できません',\n    deleteSuccess: '削除に成功しました',\n    deleteError: '削除に失敗しました：',\n    copyConfirmTitle: 'コピーの確認',\n    copyConfirmation:\n      'このパイプラインをコピーしますか？すべての設定を含む新しいパイプラインが作成されます。',\n    unsavedChanges: '未保存の変更があります',\n    extensions: {\n      title: 'プラグイン統合',\n      loadError: 'プラグインリストの読み込みに失敗しました',\n      saveSuccess: '保存に成功しました',\n      saveError: '保存に失敗しました',\n      noPluginsAvailable: '利用可能なプラグインがありません',\n      disabled: '無効',\n      noPluginsSelected: 'プラグインが選択されていません',\n      addPlugin: 'プラグインを追加',\n      selectPlugins: 'プラグインを選択',\n      pluginsTitle: 'プラグイン',\n      mcpServersTitle: 'MCPサーバー',\n      noMCPServersSelected: 'MCPサーバーが選択されていません',\n      addMCPServer: 'MCPサーバーを追加',\n      selectMCPServers: 'MCPサーバーを選択',\n      toolCount: '{{count}}個のツール',\n      noPluginsInstalled: 'インストールされているプラグインがありません',\n      noMCPServersConfigured: '設定されているMCPサーバーがありません',\n      selectAll: 'すべて選択',\n      enableAllPlugins: 'すべてのプラグインを有効にする',\n      enableAllMCPServers: 'すべてのMCPサーバーを有効にする',\n      allPluginsEnabled: 'すべてのプラグインが有効になっています',\n      allMCPServersEnabled: 'すべてのMCPサーバーが有効になっています',\n    },\n    debugDialog: {\n      title: 'パイプラインのチャット',\n      selectPipeline: 'パイプラインを選択',\n      sessionType: 'セッションタイプ',\n      privateChat: 'プライベートチャット',\n      groupChat: 'グループチャット',\n      send: '送信',\n      reset: '会話をリセット',\n      inputPlaceholder: '{{type}}メッセージを送信...',\n      noMessages: 'メッセージがありません',\n      userMessage: 'ユーザー',\n      botMessage: 'ボット',\n      sendFailed: '送信に失敗しました',\n      resetSuccess: '会話がリセットされました',\n      resetFailed: 'リセットに失敗しました',\n      loadMessagesFailed: 'メッセージの読み込みに失敗しました',\n      loadPipelinesFailed: 'パイプラインの読み込みに失敗しました',\n      atTips: 'ボットをメンション',\n      streaming: 'ストリーミング',\n      streamOutput: 'ストリーム',\n      connected: 'WebSocket接続済み',\n      disconnected: 'WebSocket未接続',\n      connectionError: 'WebSocket接続エラー',\n      connectionFailed: 'WebSocket接続に失敗しました',\n      notConnected:\n        'WebSocketに接続されていません。しばらくしてからやり直してください',\n      imageUploadFailed: '画像のアップロードに失敗しました',\n      reply: '返信',\n      replyTo: '返信先',\n      showMarkdown: 'Markdownで表示',\n      showRaw: '原文で表示',\n    },\n    monitoring: {\n      title: 'モニタリング',\n      description: 'このパイプラインの実行ログとエラー情報を表示（過去24時間）',\n      detailedLogs: '詳細ログ',\n    },\n  },\n  knowledge: {\n    title: '知識ベース',\n    createKnowledgeBase: '知識ベースを作成',\n    editKnowledgeBase: '知識ベースを編集',\n    selectKnowledgeBase: '知識ベースを選択',\n    selectKnowledgeBases: '知識ベースを選択',\n    addKnowledgeBase: '知識ベースを追加',\n    noKnowledgeBaseSelected: '知識ベースが選択されていません',\n    empty: 'なし',\n    editDocument: 'ドキュメント',\n    description: 'LLMの回答品質向上のための知識ベースを設定します',\n    metadata: 'メタデータ',\n    documents: 'ドキュメント',\n    kbNameRequired: '知識ベース名は必須です',\n    kbDescriptionRequired: '知識ベースの説明は必須です',\n    embeddingModelUUIDRequired: '埋め込みモデルは必須です',\n    daysAgo: '日前',\n    today: '今日',\n    kbName: '知識ベース名',\n    kbDescription: '知識ベースの説明',\n    topK: 'Top K',\n    topKRequired: 'Top Kは必須です',\n    topKMax: 'Top Kの最大値は30です',\n    topKdescription:\n      '取得する関連性の高い上位K件の文書の数。1～30の範囲で設定できます',\n    defaultDescription: '知識ベース',\n    embeddingModelUUID: '埋め込みモデル',\n    selectEmbeddingModel: '埋め込みモデルを選択',\n    embeddingModelDescription:\n      'テキストのベクトル化に使用する埋め込みモデルを管理します',\n    updateTime: '更新日時',\n    cannotChangeEmbeddingModel:\n      '知識ベース作成後は埋め込みモデルを変更できません',\n    updateKnowledgeBaseSuccess: '知識ベースの更新に成功しました',\n    updateKnowledgeBaseFailed: '知識ベースの更新に失敗しました：',\n    documentsTab: {\n      name: '名前',\n      status: 'ステータス',\n      noResults: 'ドキュメントがありません',\n      dragAndDrop:\n        'ファイルをここにドラッグ&ドロップするか、クリックしてアップロードしてください',\n      uploading: 'アップロード中...',\n      supportedFormats:\n        'PDF、Word、TXT、Markdownなどのドキュメントファイルをサポートしています',\n      uploadSuccess: 'ファイルのアップロードに成功しました！',\n      uploadError: 'ファイルのアップロードに失敗しました：',\n      uploadingFile: 'ファイルをアップロード中...',\n      fileSizeExceeded:\n        'ファイルサイズが10MBの制限を超えています。より小さいファイルに分割してください。',\n      actions: 'アクション',\n      delete: 'ドキュメントを削除',\n      fileDeleteSuccess: 'ドキュメントの削除に成功しました',\n      fileDeleteFailed: 'ドキュメントの削除に失敗しました：',\n      processing: '処理中',\n      completed: '完了',\n      failed: '失敗',\n      selectParser: 'パーサーを選択',\n      builtInParser: '知識エンジンが提供',\n      noParserAvailable:\n        'このファイル形式に対応するパーサーがありません。対応するパーサープラグインをインストールしてください。',\n      confirmUpload: 'アップロード',\n      cancelUpload: 'キャンセル',\n    },\n    deleteKnowledgeBaseConfirmation:\n      '本当にこの知識ベースを削除しますか？この知識ベースに紐付けられたドキュメントは削除されます。',\n    retrieve: '検索テスト',\n    retrieveTest: '検索テスト',\n    query: '検索',\n    queryPlaceholder: '検索内容を入力...',\n    distance: '距離',\n    content: '内容',\n    fileName: 'ファイル名',\n    noResults: '検索結果がありません',\n    retrieveError: '検索に失敗しました：',\n    noEnginesAvailable: '利用可能なナレッジエンジンがありません',\n    installEngineHint:\n      '先に「ナレッジエンジン」プラグインをインストールしてください',\n    unknownEngine: '不明なエンジン',\n    loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました：',\n    deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました：',\n    getKnowledgeBaseListError: 'ナレッジベース一覧の取得に失敗しました：',\n    addExternal: '外部ナレッジベースを追加',\n    createExternalSuccess: '外部ナレッジベースが正常に作成されました',\n    updateExternalSuccess: '外部ナレッジベースが正常に更新されました',\n    deleteExternalSuccess: '外部ナレッジベースが正常に削除されました',\n    retriever: '検索器',\n    selectRetriever: '検索器を選択...',\n    retrieverConfiguration: '検索器設定',\n    retrieverInstallInfo: 'ナレッジ検索器プラグインは',\n    retrieverMarketLink: 'こちらからインストールできます',\n    migration: {\n      title: 'ナレッジベースの移行',\n      description:\n        '新バージョンではナレッジベースをプラグインベースのアーキテクチャに再構築し、内蔵ナレッジベースと外部ナレッジベースを「ナレッジエンジン」プラグインとして統合しました。旧ナレッジベースデータの移行が必要です。旧データはデータベースに自動的にバックアップされています。',\n      detected:\n        '移行が必要なナレッジベースが{{total}}件見つかりました（内部{{internal}}件、外部{{external}}件）。',\n      startWithInstall: 'プラグインを自動インストールして移行',\n      startDataOnly: 'データのみ移行',\n      dataOnlyHint:\n        '「データのみ移行」はオフライン環境向けです。移行完了後に対応するプラグインを手動でインストールしてください。',\n      dismiss: '元データを破棄',\n      running: 'ナレッジベースを移行中です。しばらくお待ちください...',\n      success: 'ナレッジベースの移行が完了しました',\n      error: 'ナレッジベースの移行に失敗しました：',\n      dismissError: '操作に失敗しました',\n      retry: 'リトライ',\n    },\n  },\n  register: {\n    title: 'LangBot を初期化 👋',\n    description: 'これはLangBotの初回起動です',\n    adminAccountNote:\n      'ここで初期化されたアカウントは管理者アカウントとして使用されます',\n    register: '登録',\n    initWithSpace: 'Space で初期化',\n    spaceRecommended:\n      'おすすめ：公式の安定したモデル API とクラウドサービスを利用',\n    spaceInfoTip1:\n      'Space は統一されたアカウント認証サービスを提供し、機密情報をアップロードすることはありません。',\n    spaceInfoTip2:\n      'Space アカウントでログインすると、LangBot Models などのクラウドサービスを利用でき、無料のモデル呼び出しクレジットで迅速に開始できます。',\n    spaceInfoTip3:\n      'ログイン方法は他の機能に影響しません。いつでも他のソースからモデルを設定して使用できます。',\n    registerLocal: 'ローカルアカウントを登録',\n    registerWithPassword: 'メールアドレスとパスワードで登録',\n    initSuccess: '初期化に成功しました。ログインしてください',\n    initFailed: '初期化に失敗しました：',\n  },\n  resetPassword: {\n    title: 'パスワードをリセット 🔐',\n    description:\n      '復旧キーと新しいパスワードを入力して、アカウントのパスワードをリセットします',\n    recoveryKey: '復旧キー',\n    recoveryKeyDescription:\n      '設定ファイル `data/config.yaml` の `system.recovery_key` に保存されています',\n    newPassword: '新しいパスワード',\n    enterRecoveryKey: '復旧キーを入力',\n    enterNewPassword: '新しいパスワードを入力',\n    recoveryKeyRequired: '復旧キーは必須です',\n    newPasswordRequired: '新しいパスワードは必須です',\n    resetPassword: 'パスワードをリセット',\n    resetting: 'リセット中...',\n    resetSuccess: 'パスワードのリセットに成功しました。ログインしてください',\n    resetFailed:\n      'パスワードのリセットに失敗しました。メールアドレスと復旧キーを確認してください',\n    backToLogin: 'ログインに戻る',\n  },\n  embedding: {\n    description: 'テキストのベクトル化に使用する埋め込みモデルを管理します',\n    createModel: '埋め込みモデルを作成',\n    editModel: '埋め込みモデルを編集',\n    getModelListError: '埋め込みモデルリストの取得に失敗しました：',\n    embeddingModels: '埋め込みモデル',\n    extraParametersDescription:\n      'リクエストボディに追加されるパラメータ（encoding_format、dimensions など）',\n  },\n  llm: {\n    description: 'チャットメッセージの生成に使用するLLMモデルを管理します',\n    llmModels: 'LLMモデル',\n    extraParametersDescription:\n      'リクエストボディに追加されるパラメータ（max_tokens、temperature、top_p など）',\n  },\n  version: {\n    newVersionAvailable: '新しいバージョンが利用可能',\n    viewUpdateGuide: 'アップデート方法を見る',\n    noReleaseNotes: 'リリースノートはありません',\n  },\n  account: {\n    settings: 'アカウント設定',\n    setPassword: 'パスワードを設定',\n    passwordSetSuccess: 'パスワードの設定に成功しました',\n    passwordStatus: 'ローカルパスワード',\n    passwordSet: '設定済み',\n    passwordNotSet: '未設定',\n    passwordSetDescription:\n      'パスワードが設定されています。メールとパスワードでログインできます',\n    spaceStatus: 'Space アカウント',\n    spaceBound: '連携済み',\n    spaceNotBound: '未連携',\n    spaceBoundDescription:\n      'Space アカウントと連携済み、公式モデル API とクラウドサービスが利用可能',\n    bindSpace: 'Space アカウントを連携',\n    bindSpaceDescription: '連携して公式モデル API とクラウドサービスを利用',\n    bindSpaceButton: '連携',\n    bindSpaceConfirmTitle: '連携を確認',\n    bindSpaceConfirmDescription:\n      'ローカルインスタンスを Space アカウントに連携しようとしています',\n    bindSpaceWarning:\n      '連携後、ログインメールアドレスは {{localEmail}} から Space アカウントのメールアドレスに変更されます。',\n    bindSpaceSuccess: 'Space アカウントの連携に成功しました',\n    bindSpaceFailed: 'Space アカウントの連携に失敗しました',\n    bindSpaceInvalidState:\n      '無効な連携リクエストです。アカウント設定から再度お試しください。',\n    setPasswordHint:\n      'パスワードを設定するとメールとパスワードでログインできます',\n    spaceEmailMismatch:\n      'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',\n  },\n  monitoring: {\n    title: 'モニタリング',\n    description:\n      'ボットアクティビティ、LLM呼び出し、システムパフォーマンスを監視',\n    overview: '概要',\n    totalMessages: '総メッセージ数',\n    llmCallsCount: 'LLM呼び出し',\n    modelCallsCount: 'モデル呼び出し',\n    successRate: '成功率',\n    activeSessions: 'アクティブセッション',\n    last24Hours: '過去24時間',\n    filters: {\n      title: 'フィルター',\n      bot: 'ボット',\n      pipeline: 'パイプライン',\n      allBots: 'すべてのボット',\n      selectBot: 'ボットを選択',\n      allPipelines: 'すべてのパイプライン',\n      selectPipeline: 'パイプラインを選択',\n      loading: '読み込み中...',\n      timeRange: '時間範囲',\n      customRange: 'カスタム範囲',\n      from: '開始',\n      to: '終了',\n      apply: '適用',\n      reset: 'フィルターをリセット',\n      lastHour: '過去1時間',\n      last6Hours: '過去6時間',\n      last24Hours: '過去24時間',\n      last7Days: '過去7日間',\n      last30Days: '過去30日間',\n    },\n    tabs: {\n      messages: 'メッセージ記録',\n      llmCalls: 'LLM呼び出し',\n      embeddingCalls: 'Embedding呼び出し',\n      modelCalls: 'モデル呼び出し',\n      sessions: 'セッション分析',\n      errors: 'エラーログ',\n    },\n    messageList: {\n      timestamp: 'タイムスタンプ',\n      bot: 'ボット',\n      pipeline: 'パイプライン',\n      message: 'メッセージ',\n      sessionId: 'セッションID',\n      status: 'ステータス',\n      actions: 'アクション',\n      viewDetails: '詳細を表示',\n      copyId: 'IDをコピー',\n      noMessages: 'メッセージが見つかりません',\n      noMessagesDescription: 'フィルターを調整するか、後で確認してください',\n      loading: 'メッセージを読み込んでいます...',\n      loadMore: 'もっと読み込む',\n      autoRefresh: '自動更新',\n      platform: 'プラットフォーム',\n      user: 'ユーザー',\n      level: 'レベル',\n      runner: 'ランナー',\n      viewConversation: '会話詳細を表示',\n    },\n    llmCalls: {\n      model: 'モデル',\n      tokens: 'トークン数',\n      duration: '期間',\n      cost: 'コスト',\n      noData: 'LLM呼び出し記録が見つかりません',\n      inputTokens: '入力トークン',\n      outputTokens: '出力トークン',\n      totalTokens: '合計トークン数',\n    },\n    embeddingCalls: {\n      title: 'Embedding呼び出し',\n      model: 'モデル',\n      tokens: 'トークン数',\n      duration: '期間',\n      noData: 'Embedding呼び出し記録が見つかりません',\n      promptTokens: '入力トークン',\n      totalTokens: '合計トークン数',\n      inputCount: '入力数',\n      knowledgeBase: 'ナレッジベース',\n      queryText: 'クエリ',\n    },\n    modelCalls: {\n      title: 'モデル呼び出し',\n      llmModel: '対話モデル',\n      embeddingModel: '埋め込みモデル',\n      embeddingCall: '埋め込み呼び出し',\n      retrieveCall: '検索呼び出し',\n      noData: 'モデル呼び出し記録が見つかりません',\n    },\n    sessions: {\n      sessionId: 'セッションID',\n      messageCount: 'メッセージ数',\n      duration: '期間',\n      lastActivity: '最終アクティビティ',\n      noSessions: 'セッションが見つかりません',\n      startTime: '開始時刻',\n    },\n    errors: {\n      errorType: 'エラータイプ',\n      errorMessage: 'エラーメッセージ',\n      occurredAt: '発生時刻',\n      noErrors: 'エラーが見つかりません',\n      stackTrace: 'スタックトレース',\n      title: 'エラー',\n    },\n    messageDetails: {\n      noData: 'このクエリにはLLM呼び出しやエラーがありません',\n    },\n    queryVariables: {\n      title: 'クエリ変数',\n    },\n    viewMonitoring: 'モニタリングを表示',\n    refreshData: 'データを更新',\n    exportData: 'データをエクスポート',\n    export: {\n      title: 'データをエクスポート',\n      exporting: 'エクスポート中...',\n      messages: 'メッセージ',\n      llmCalls: 'LLM コール',\n      embeddingCalls: 'Embedding コール',\n      errors: 'エラーログ',\n      sessions: 'セッション',\n    },\n  },\n  limitation: {\n    maxBotsReached:\n      'ボット数が上限（{{max}}個）に達しました。新しいボットを作成するには、既存のボットを削除してください。',\n    maxPipelinesReached:\n      'パイプライン数が上限（{{max}}個）に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',\n    maxExtensionsReached:\n      '拡張機能数が上限（{{max}}個）に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',\n  },\n};\n\nexport default jaJP;\n"
  },
  {
    "path": "web/src/i18n/locales/zh-Hans.ts",
    "content": "const zhHans = {\n  common: {\n    login: '登录',\n    logout: '退出登录',\n    accountOptions: '系统设置',\n    account: '账户',\n    integration: '连接',\n    email: '邮箱',\n    password: '密码',\n    welcome: '欢迎回到 LangBot 👋',\n    continueToLogin: '登录以继续',\n    loginSuccess: '登录成功',\n    loginFailed: '登录失败，请检查邮箱和密码是否正确',\n    loginLoadError: '无法连接到服务器',\n    loginLoadErrorDesc: '无法连接到 LangBot 后端服务，请确认服务已启动后重试。',\n    retry: '重试',\n    enterEmail: '输入邮箱地址',\n    enterPassword: '输入密码',\n    invalidEmail: '请输入有效的邮箱地址',\n    emptyPassword: '请输入密码',\n    language: '语言',\n    helpDocs: '帮助文档',\n    featureRequest: '需求建议',\n    create: '创建',\n    edit: '编辑',\n    delete: '删除',\n    add: '添加',\n    select: '请选择',\n    cancel: '取消',\n    submit: '提交',\n    error: '错误',\n    success: '成功',\n    save: '保存',\n    saving: '保存中...',\n    confirm: '确认',\n    confirmDelete: '确认删除',\n    deleteConfirmation: '你确定要删除这个吗？',\n    selectOption: '选择一个选项',\n    required: '必填',\n    enable: '是否启用',\n    name: '名称',\n    description: '描述',\n    icon: '图标',\n    close: '关闭',\n    deleteSuccess: '删除成功',\n    deleteError: '删除失败：',\n    addRound: '添加回合',\n    copy: '复制',\n    copySuccess: '复制成功',\n    copyFailed: '复制失败',\n    test: '测试',\n    forgotPassword: '忘记密码？',\n    agreementNotice: '继续即表示您同意我们的',\n    privacyPolicy: '隐私政策',\n    and: '和',\n    dataCollectionPolicy: '数据收集政策',\n    dataCollectionPolicyUrl:\n      'https://docs.langbot.app/zh/insight/data-collection-policy',\n    loading: '加载中...',\n    fieldRequired: '此字段为必填项',\n    or: '或',\n    loginWithSpace: '通过 Space 登录',\n    spaceLoginRecommended: '推荐：使用官方提供的稳定模型 API 和云服务',\n    loginLocal: '使用本地账号登录',\n    loginWithPassword: '通过密码登录',\n    spaceLoginTitle: '通过 Space 登录',\n    spaceLoginDescription: '扫描二维码或访问下方链接进行授权',\n    spaceLoginUserCode: '您的验证码',\n    spaceLoginExpires: '验证码将在 {{seconds}} 秒后过期',\n    spaceLoginWaiting: '等待授权中...',\n    spaceLoginSuccess: '授权成功',\n    spaceLoginFailed: 'Space 登录失败',\n    spaceLoginExpired: '验证码已过期，请重试',\n    spaceLoginCancel: '取消',\n    spaceLoginVisitLink: '访问链接',\n    spaceLoginProcessing: '正在通过 Space 登录',\n    spaceLoginProcessingDescription: '请稍候，正在完成登录...',\n    spaceLoginSuccessDescription: '正在跳转到 LangBot...',\n    spaceLoginError: '登录失败',\n    spaceLoginNoCode: '缺少授权码',\n    backToLogin: '返回登录',\n    backToHome: '返回首页',\n    spaceAccountCannotChangePassword: 'Space 账户无法在此修改密码',\n    theme: '主题',\n    changePassword: '修改密码',\n    currentPassword: '当前密码',\n    newPassword: '新密码',\n    confirmNewPassword: '确认新密码',\n    enterCurrentPassword: '输入当前密码',\n    enterNewPassword: '输入新密码',\n    enterConfirmPassword: '确认新密码',\n    currentPasswordRequired: '当前密码不能为空',\n    newPasswordRequired: '新密码不能为空',\n    confirmPasswordRequired: '确认密码不能为空',\n    passwordsDoNotMatch: '两次输入的密码不一致',\n    changePasswordSuccess: '密码修改成功',\n    changePasswordFailed: '密码修改失败，请检查当前密码是否正确',\n    apiIntegration: 'API 集成',\n    apiKeys: 'API 密钥',\n    manageApiIntegration: '管理 API 集成',\n    manageApiKeys: '管理 API 密钥',\n    createApiKey: '创建 API 密钥',\n    apiKeyName: 'API 密钥名称',\n    apiKeyDescription: 'API 密钥描述',\n    apiKeyValue: 'API 密钥值',\n    apiKeyCreated: 'API 密钥创建成功',\n    apiKeyDeleted: 'API 密钥删除成功',\n    apiKeyDeleteConfirm: '确定要删除此 API 密钥吗？',\n    apiKeyNameRequired: 'API 密钥名称不能为空',\n    copyApiKey: '复制 API 密钥',\n    apiKeyCopied: 'API 密钥已复制到剪贴板',\n    noApiKeys: '暂无 API 密钥',\n    apiKeyHint: 'API 密钥允许外部系统访问 LangBot 的 Service API',\n    webhooks: 'Webhooks',\n    createWebhook: '创建 Webhook',\n    webhookName: 'Webhook 名称',\n    webhookUrl: 'Webhook URL',\n    webhookDescription: 'Webhook 描述',\n    webhookEnabled: '是否启用',\n    webhookCreated: 'Webhook 创建成功',\n    webhookDeleted: 'Webhook 删除成功',\n    webhookDeleteConfirm: '确定要删除此 Webhook 吗？',\n    webhookNameRequired: 'Webhook 名称不能为空',\n    webhookUrlRequired: 'Webhook URL 不能为空',\n    noWebhooks: '暂无 Webhook',\n    webhookHint: 'Webhook 允许 LangBot 将个人消息和群消息事件推送到外部系统',\n    actions: '操作',\n    apiKeyCreatedMessage: '请复制此 API 密钥，若按钮无效，请手动复制。',\n    none: '无',\n  },\n  notFound: {\n    title: '页面不存在',\n    description:\n      '您要查找的页面似乎不存在。请检查您输入的 URL 是否正确，或者返回首页。',\n    back: '上一级',\n    home: '返回主页',\n    help: '查看帮助文档',\n  },\n  models: {\n    title: '模型配置',\n    description: '配置和管理可在流水线中使用的模型',\n    createModel: '创建对话模型',\n    editModel: '编辑模型',\n    getModelListError: '获取模型列表失败：',\n    modelName: '模型名称',\n    modelProvider: '模型提供商',\n    modelBaseURL: '基础 URL',\n    modelAbilities: '模型能力',\n    saveSuccess: '保存成功',\n    saveError: '保存失败：',\n    createSuccess: '创建成功',\n    createError: '创建失败：',\n    deleteSuccess: '删除成功',\n    deleteError: '删除失败：',\n    deleteConfirmation: '你确定要删除这个模型吗？',\n    modelNameRequired: '模型名称不能为空',\n    modelProviderRequired: '模型供应商不能为空',\n    requestURLRequired: '请求URL不能为空',\n    apiKeyRequired: 'API Key不能为空',\n    keyNameRequired: '键名不能为空',\n    mustBeValidNumber: '必须是有效的数字',\n    mustBeTrueOrFalse: '必须是 true 或 false',\n    requestURL: '请求URL',\n    apiKey: 'API Key',\n    abilities: '能力',\n    selectModelAbilities: '选择模型能力',\n    visionAbility: '视觉能力',\n    functionCallAbility: '函数调用',\n    extraParameters: '额外参数',\n    addParameter: '添加参数',\n    keyName: '键名',\n    type: '类型',\n    value: '值',\n    string: '字符串',\n    number: '数字',\n    boolean: '布尔值',\n    selectModelProvider: '选择模型供应商',\n    modelProviderDescription: '请填写供应商向您提供的模型名称',\n    modelManufacturer: '模型厂商',\n    aggregationPlatform: '中转平台',\n    selfDeployed: '自部署',\n    builtin: '内置',\n    selectModel: '请选择模型',\n    testSuccess: '测试成功',\n    testError: '测试失败，请检查模型配置',\n    llmModels: '对话模型',\n    localProvider: '本地',\n    localProviderDescription: '在本地配置和管理的模型',\n    spaceProviderDescription: '从您的 Space 账户同步的模型',\n    spaceDisabledForLocalAccount: '使用 Space 登录以使用云端模型',\n    syncModels: '同步',\n    syncSuccess: '同步完成：创建 {{created}} 个，更新 {{updated}} 个',\n    syncError: '同步失败：',\n    spaceModelReadOnly: 'Space 模型为只读',\n    noSpaceModels: '暂无 Space 模型。点击同步按钮从 Space 获取模型。',\n    noLocalModels: '暂无本地模型。点击创建按钮添加模型。',\n    providerCount: '共 {{count}} 个自定义供应商',\n    // 供应商结构新增键\n    addModel: '添加模型',\n    addLLMModel: '添加对话模型',\n    addEmbeddingModel: '添加嵌入模型',\n    provider: '供应商',\n    existingProvider: '已有供应商',\n    newProvider: '新建供应商',\n    selectProvider: '选择供应商',\n    requester: '供应商类型',\n    selectRequester: '选择供应商类型',\n    langbotModelsDescription: 'LangBot Space 提供的云端模型',\n    credits: '积分',\n    loginWithSpace: '通过 Space 登录',\n    loginToUseModels: '通过 Space 登录以使用云端模型',\n    noModels: '暂无模型',\n    editProvider: '编辑供应商',\n    addProvider: '添加供应商',\n    addProviderHint: '添加自定义供应商以使用其他来源的模型',\n    addProviderHintSimple: '添加自定义供应商以使用模型',\n    noProviders: '暂无自定义供应商',\n    providerName: '供应商名称',\n    providerNameRequired: '供应商名称不能为空',\n    requesterRequired: '供应商类型不能为空',\n    providerSaved: '供应商已保存',\n    providerCreated: '供应商已创建',\n    providerSaveError: '保存供应商失败：',\n    providerDeleted: '供应商已删除',\n    providerDeleteError: '删除供应商失败：',\n    deleteProviderConfirmation: '你确定要删除这个供应商吗？',\n    loadError: '加载数据失败',\n    chat: '对话',\n    embedding: '嵌入',\n    modelsCount: '{{count}} 个模型',\n    expandModels: '展开',\n    collapseModels: '收起',\n    fallback: {\n      primary: '主模型',\n      fallbackList: '备用模型',\n      addFallback: '添加备用模型',\n    },\n  },\n  bots: {\n    title: '机器人',\n    description: '创建和管理机器人，这是 LangBot 与各个平台连接的入口',\n    createBot: '创建机器人',\n    editBot: '编辑机器人',\n    getBotListError: '获取机器人列表失败：',\n    botName: '机器人名称',\n    botDescription: '机器人描述',\n    botNameRequired: '机器人名称不能为空',\n    botDescriptionRequired: '机器人描述不能为空',\n    adapterRequired: '适配器不能为空',\n    defaultDescription: '一个机器人',\n    getBotConfigError: '获取机器人配置失败：',\n    saveSuccess: '保存成功',\n    saveError: '保存失败：',\n    createSuccess: '创建成功 请启用或修改绑定流水线',\n    createError: '创建失败：',\n    deleteSuccess: '删除成功',\n    deleteError: '删除失败：',\n    deleteConfirmation: '你确定要删除这个机器人吗？',\n    platformAdapter: '平台/适配器选择',\n    selectAdapter: '选择适配器',\n    adapterConfig: '适配器配置',\n    bindPipeline: '绑定流水线',\n    selectPipeline: '选择流水线',\n    selectBot: '请选择机器人',\n    botLogTitle: '机器人日志',\n    enableAutoRefresh: '开启自动刷新',\n    session: '会话',\n    yesterday: '昨天',\n    earlier: '更久之前',\n    dateFormat: '{{month}}月{{day}}日',\n    setBotEnableError: '设置机器人启用状态失败',\n    log: '日志',\n    configuration: '配置',\n    logs: '日志',\n    webhookUrl: 'Webhook 回调地址',\n    webhookUrlCopied: 'Webhook 地址已复制',\n    webhookUrlHint:\n      '点击输入框自动全选，然后按 Ctrl+C (Mac: Cmd+C) 复制，或点击右侧按钮',\n    webhookUrlHintEither: '以上两个地址任选其一填入平台配置即可',\n    logLevel: '日志级别',\n    allLevels: '全部级别',\n    selectLevel: '选择级别',\n    levelsSelected: '个级别已选',\n    viewDetailedLogs: '查看详细日志',\n    viewDetails: '详情',\n    collapse: '收起',\n    imagesAttached: '张图片',\n    sessionMonitor: {\n      title: '会话监控',\n      sessions: '会话列表',\n      noSessions: '暂无会话',\n      selectSession: '选择一个会话查看消息',\n      noMessages: '该会话暂无消息',\n      messages: '条消息',\n      messageCount: '{{count}} 条消息',\n      loading: '加载中...',\n      loadingSessions: '加载会话中...',\n      loadingMessages: '加载消息中...',\n      user: '用户',\n      variables: '变量',\n      platform: '平台',\n      lastActive: '最近活跃',\n      refresh: '刷新',\n      active: '活跃',\n      inactive: '不活跃',\n    },\n  },\n  plugins: {\n    title: '插件扩展',\n    description: '安装和配置用于扩展功能的插件，请在流水线配置中选用',\n    createPlugin: '创建插件',\n    editPlugin: '编辑插件',\n    installed: '已安装',\n    marketplace: '插件市场',\n    arrange: '编排',\n    install: '安装',\n    installPlugin: '安装插件',\n    onlySupportGithub: '目前仅支持从 GitHub 安装',\n    enterGithubLink: '请输入插件的Github链接',\n    installing: '正在安装插件...',\n    installSuccess: '插件安装成功',\n    installFailed: '插件安装失败：',\n    searchPlugin: '搜索插件',\n    sortBy: '排序方式',\n    mostStars: '最多星标',\n    recentlyAdded: '最近新增',\n    recentlyUpdated: '最近更新',\n    noMatchingPlugins: '没有找到匹配的插件',\n    loading: '加载中...',\n    getPluginListError: '获取插件列表失败:',\n    pluginConfig: '插件配置',\n    noPluginInstalled: '暂未安装任何插件',\n    pluginSort: '插件排序',\n    pluginSortDescription:\n      '插件顺序会影响同一事件内的处理顺序，请拖动插件卡片排序',\n    pluginSortSuccess: '插件排序成功',\n    pluginSortError: '插件排序失败：',\n    pluginNoConfig: '插件没有配置项。',\n    systemDisabled: '插件系统未启用',\n    systemDisabledDesc: '尚未启用插件系统，请根据文档修改配置',\n    connectionError: '插件系统连接异常',\n    connectionErrorDesc: '请检查插件系统配置或联系管理员',\n    errorDetails: '错误详情',\n    loadingStatus: '正在检查插件系统状态...',\n    failedToGetStatus: '获取插件系统状态失败',\n    pluginSystemNotReady: '插件系统未就绪，无法执行此操作',\n    debugInfo: '调试信息',\n    debugInfoTitle: '插件调试信息',\n    debugUrl: '调试地址',\n    debugKey: '调试密钥',\n    noDebugKey: '(未设置)',\n    debugKeyDisabled: '未设置调试密钥，插件调试无需认证',\n    failedToGetDebugInfo: '获取调试信息失败',\n    copiedToClipboard: '已复制到剪贴板',\n    deleting: '删除中...',\n    deletePlugin: '删除插件',\n    cancel: '取消',\n    saveConfig: '保存配置',\n    saving: '保存中...',\n    confirmDeletePlugin: '你确定要删除插件（{{author}}/{{name}}）吗？',\n    deleteDataCheckbox: '同时删除插件配置和持久化存储',\n    confirmDelete: '确认删除',\n    deleteError: '删除失败：',\n    close: '关闭',\n    deleteConfirm: '删除确认',\n    deleteSuccess: '删除成功',\n    modifyFailed: '修改失败：',\n    componentName: {\n      Tool: '工具',\n      EventListener: '事件监听器',\n      Command: '命令',\n      KnowledgeEngine: '知识引擎',\n      Parser: '解析器',\n    },\n    uploadLocal: '本地上传',\n    debugging: '调试中',\n    uploadLocalPlugin: '上传本地插件',\n    dragToUpload: '拖拽文件到此处上传',\n    unsupportedFileType: '不支持的文件类型，仅支持 .lbpkg 和 .zip 文件',\n    uploadingPlugin: '正在上传插件...',\n    uploadSuccess: '上传成功',\n    uploadFailed: '上传失败',\n    selectFileToUpload: '选择要上传的插件文件',\n    askConfirm: '确定要安装插件 \"{{name}}\" ({{version}}) 吗？',\n    fromGithub: '来自 GitHub',\n    fromLocal: '本地安装',\n    fromMarketplace: '来自市场',\n    componentsList: '组件: ',\n    noComponents: '无组件',\n    delete: '删除插件',\n    update: '更新插件',\n    new: '新',\n    updateConfirm: '更新确认',\n    confirmUpdatePlugin: '你确定要更新插件（{{author}}/{{name}}）吗？',\n    confirmUpdate: '确认更新',\n    updating: '更新中...',\n    updateSuccess: '插件更新成功',\n    updateError: '更新失败：',\n    saveConfigSuccessNormal: '保存配置成功',\n    saveConfigError: '保存配置失败：',\n    config: '配置',\n    readme: '文档',\n    viewSource: '查看来源',\n    loadingReadme: '正在加载文档...',\n    noReadme: '该插件没有提供 README 文档',\n    fileUpload: {\n      tooLarge: '文件大小超过 10MB 限制',\n      success: '文件上传成功',\n      failed: '文件上传失败',\n      uploading: '上传中...',\n      chooseFile: '选择文件',\n      addFile: '添加文件',\n    },\n    installFromGithub: '来自 GitHub',\n    enterRepoUrl: '请输入 GitHub 仓库地址',\n    repoUrlPlaceholder: '例如: https://github.com/owner/repo',\n    fetchingReleases: '正在获取 Release 列表...',\n    selectRelease: '选择 Release',\n    noReleasesFound: '未找到 Release',\n    fetchReleasesError: '获取 Release 列表失败：',\n    selectAsset: '选择要安装的文件',\n    noAssetsFound: '该 Release 没有可用的 .lbpkg 文件',\n    fetchAssetsError: '获取文件列表失败：',\n    backToReleases: '返回 Release 列表',\n    backToRepoUrl: '返回仓库地址',\n    backToAssets: '返回文件选择',\n    releaseTag: 'Tag: {{tag}}',\n    releaseName: '名称: {{name}}',\n    publishedAt: '发布于: {{date}}',\n    prerelease: '预发布',\n    assetSize: '大小: {{size}}',\n    confirmInstall: '确认安装',\n    installFromGithubDesc: '从 GitHub Release 安装插件',\n  },\n  market: {\n    searchPlaceholder: '搜索插件...',\n    searchResults: '搜索到 {{count}} 个插件',\n    totalPlugins: '共 {{count}} 个插件',\n    noPlugins: '暂无插件',\n    noResults: '未找到相关插件',\n    loadingMore: '加载更多...',\n    loading: '加载中...',\n    allLoaded: '已显示全部插件',\n    install: '安装',\n    installConfirm: '确定要安装插件 \"{{name}}\" ({{version}}) 吗？',\n    downloadComplete: '插件 \"{{name}}\" 下载完成',\n    installFailed: '安装失败，请稍后重试',\n    loadFailed: '获取插件列表失败，请稍后重试',\n    noDescription: '暂无描述',\n    notFound: '插件信息未找到',\n    sortBy: '排序方式',\n    sort: {\n      recentlyAdded: '最近新增',\n      recentlyUpdated: '最近更新',\n      mostDownloads: '最多下载',\n      leastDownloads: '最少下载',\n    },\n    downloads: '次下载',\n    download: '下载',\n    repository: '代码仓库',\n    downloadFailed: '下载失败',\n    noReadme: '该插件没有提供 README 文档',\n    description: '描述',\n    tagLabel: '标签',\n    submissionTitle: '您有插件提交正在审核中： {{name}}',\n    submissionApproved: '您的插件提交已通过审核： {{name}}',\n    submissionRejected: '您的插件提交已被拒绝： {{name}}',\n    clickToRevoke: '撤回',\n    revokeSuccess: '撤回成功',\n    revokeFailed: '撤回失败',\n    submissionDetails: '插件提交详情',\n    markAsRead: '已读',\n    markAsReadSuccess: '已标记为已读',\n    markAsReadFailed: '标记为已读失败',\n    filterByComponent: '组件',\n    allComponents: '全部组件',\n    requestPlugin: '请求插件',\n    tags: {\n      filterByTags: '按标签筛选',\n      selected: '已选',\n      selectTags: '选择标签',\n      clearAll: '清空',\n      noTags: '暂无标签',\n    },\n    viewDetails: '查看详情',\n    deprecated: '已弃用',\n    deprecatedTooltip: '请安装对应「知识引擎」插件',\n  },\n  mcp: {\n    title: 'MCP',\n    createServer: '添加 MCP 服务器',\n    editServer: '修改 MCP 服务器',\n    deleteServer: '删除 MCP 服务器',\n    confirmDeleteServer: '你确定要删除此 MCP 服务器吗？',\n    confirmDeleteTitle: '删除 MCP 服务器',\n    getServerListError: '获取 MCP 服务器列表失败：',\n    serverName: '服务器名称',\n    serverMode: '连接模式',\n    selectMode: '选择模式',\n    stdio: 'Stdio模式',\n    sse: 'SSE模式',\n    http: 'HTTP模式',\n    noServerInstalled: '暂未配置任何 MCP 服务器',\n    serverNameRequired: '服务器名称不能为空',\n    commandRequired: '命令不能为空',\n    urlRequired: 'URL 不能为空',\n    timeoutMustBePositive: '超时时间必须是正数',\n    command: '命令',\n    args: '参数',\n    env: '环境变量',\n    url: 'URL地址',\n    headers: '请求头',\n    timeout: '超时时间',\n    addArgument: '添加参数',\n    addEnvVar: '添加环境变量',\n    addHeader: '添加请求头',\n    keyName: '键名',\n    value: '值',\n    testing: '测试中...',\n    connecting: '连接中...',\n    testSuccess: '测试成功',\n    testFailed: '测试失败：',\n    testError: '刷新出错',\n    refreshSuccess: '刷新成功',\n    refreshFailed: '刷新失败：',\n    connectionSuccess: '连接成功',\n    connectionFailed: '连接失败，请检查URL',\n    connectionFailedStatus: '连接失败',\n    toolsFound: '个工具',\n    unknownError: '未知错误',\n    noToolsFound: '未找到任何工具',\n    parseResultFailed: '解析测试结果失败',\n    noResultReturned: '测试未返回结果',\n    getTaskFailed: '获取任务状态失败',\n    noTaskId: '未获取到任务ID',\n    deleteSuccess: '删除成功',\n    deleteFailed: '删除失败：',\n    deleteError: '删除失败：',\n    saveSuccess: '保存成功',\n    saveError: '保存失败：',\n    createSuccess: '创建成功',\n    createFailed: '创建失败：',\n    createError: '创建失败：',\n    loadFailed: '加载失败',\n    modifyFailed: '修改失败：',\n    toolCount: '工具：{{count}}',\n    statusConnected: '已打开',\n    statusDisconnected: '未打开',\n    statusError: '连接错误',\n    statusDisabled: '已禁用',\n    loading: '加载中...',\n    starCount: '星标：{{count}}',\n    install: '安装',\n    installFromGithub: '从Github安装MCP服务器',\n    add: '添加',\n    name: '名称',\n    nameRequired: '名称不能为空',\n    sseTimeout: 'SSE超时时间',\n    sseTimeoutDescription: '用于建立SSE连接的超时时间',\n    extraParametersDescription: '额外参数，用于配置MCP服务器的特定行为',\n    timeoutMustBeNumber: '超时时间必须是数字',\n    timeoutNonNegative: '超时时间不能为负数',\n    sseTimeoutMustBeNumber: 'SSE超时时间必须是数字',\n    sseTimeoutNonNegative: 'SSE超时时间不能为负数',\n    updateSuccess: '更新成功',\n    updateFailed: '更新失败：',\n  },\n  pipelines: {\n    title: '流水线',\n    description: '流水线定义了对消息事件的处理流程，用于绑定到机器人',\n    createPipeline: '创建流水线',\n    editPipeline: '编辑流水线',\n    chat: '对话',\n    configuration: '配置',\n    debugChat: '对话调试',\n    getPipelineListError: '获取流水线列表失败：',\n    daysAgo: '天前',\n    today: '今天',\n    updateTime: '更新于',\n    defaultBadge: '默认',\n    sortBy: '排序方式',\n    newestCreated: '最新创建',\n    earliestCreated: '最早创建',\n    recentlyEdited: '最近编辑',\n    earliestEdited: '最早编辑',\n    basicInfo: '基础信息',\n    aiCapabilities: 'AI 能力',\n    triggerConditions: '触发条件',\n    safetyControls: '安全控制',\n    outputProcessing: '输出处理',\n    nameRequired: '名称不能为空',\n    descriptionRequired: '描述不能为空',\n    createSuccess: '创建成功 请编辑流水线详细参数',\n    createError: '创建失败：',\n    saveSuccess: '保存成功',\n    saveError: '保存失败：',\n    copySuffix: ' Copy',\n    deleteConfirmation:\n      '你确定要删除这个流水线吗？已绑定此流水线的机器人将无法使用。',\n    defaultPipelineCannotDelete: '默认流水线不可删除',\n    deleteSuccess: '删除成功',\n    deleteError: '删除失败：',\n    copyConfirmTitle: '确认复制',\n    copyConfirmation:\n      '确定要复制这个流水线吗？复制将创建一个包含完整配置的新流水线。',\n    unsavedChanges: '有未保存的更改',\n    extensions: {\n      title: '扩展集成',\n      loadError: '加载插件列表失败',\n      saveSuccess: '保存成功',\n      saveError: '保存失败',\n      noPluginsAvailable: '暂无可用插件',\n      disabled: '已禁用',\n      noPluginsSelected: '未选择任何插件',\n      addPlugin: '添加插件',\n      selectPlugins: '选择插件',\n      pluginsTitle: '插件',\n      mcpServersTitle: 'MCP 服务器',\n      noMCPServersSelected: '未选择任何 MCP 服务器',\n      addMCPServer: '添加 MCP 服务器',\n      selectMCPServers: '选择 MCP 服务器',\n      toolCount: '{{count}} 个工具',\n      noPluginsInstalled: '无已安装的插件',\n      noMCPServersConfigured: '无已配置的 MCP 服务器',\n      selectAll: '全选',\n      enableAllPlugins: '启用所有插件',\n      enableAllMCPServers: '启用所有 MCP 服务器',\n      allPluginsEnabled: '已启用所有插件',\n      allMCPServersEnabled: '已启用所有 MCP 服务器',\n    },\n    debugDialog: {\n      title: '流水线对话',\n      selectPipeline: '选择流水线',\n      sessionType: '会话类型',\n      privateChat: '私聊',\n      groupChat: '群聊',\n      send: '发送',\n      reset: '重置对话',\n      inputPlaceholder: '发送 {{type}} 消息...',\n      noMessages: '暂无消息',\n      userMessage: '用户',\n      botMessage: '机器人',\n      sendFailed: '发送失败',\n      resetSuccess: '对话已重置',\n      resetFailed: '重置失败',\n      loadMessagesFailed: '加载消息失败',\n      loadPipelinesFailed: '加载流水线失败',\n      atTips: '提及机器人',\n      streaming: '流式传输',\n      streamOutput: '流式',\n      connected: 'WebSocket已连接',\n      disconnected: 'WebSocket未连接',\n      connectionError: 'WebSocket连接错误',\n      connectionFailed: 'WebSocket连接失败',\n      notConnected: 'WebSocket未连接，请稍后重试',\n      imageUploadFailed: '图片上传失败',\n      reply: '回复',\n      replyTo: '回复给',\n      showMarkdown: '渲染',\n      showRaw: '原文',\n    },\n    monitoring: {\n      title: '监控日志',\n      description: '查看此流水线的运行记录和错误信息（最近24小时）',\n      detailedLogs: '详细日志',\n    },\n  },\n  knowledge: {\n    title: '知识库',\n    createKnowledgeBase: '创建知识库',\n    editKnowledgeBase: '编辑知识库',\n    selectKnowledgeBase: '选择知识库',\n    selectKnowledgeBases: '选择知识库',\n    addKnowledgeBase: '添加知识库',\n    noKnowledgeBaseSelected: '未选择知识库',\n    empty: '无',\n    editDocument: '文档',\n    description: '配置可用于提升模型回复质量的知识库',\n    metadata: '元数据',\n    documents: '文档',\n    kbNameRequired: '知识库名称不能为空',\n    kbDescriptionRequired: '知识库描述不能为空',\n    embeddingModelUUIDRequired: '嵌入模型不能为空',\n    daysAgo: '天前',\n    today: '今天',\n    kbName: '知识库名称',\n    kbDescription: '知识库描述',\n    topK: '召回数量',\n    topKRequired: '召回数量不能为空',\n    topKMax: '召回数量最大值为 30',\n    topKdescription: '召回相关文档块的数量，取值范围为 1-30',\n    defaultDescription: '一个知识库',\n    embeddingModelUUID: '嵌入模型',\n    selectEmbeddingModel: '选择嵌入模型',\n    embeddingModelDescription: '用于向量化文本，可在模型配置页面配置',\n    updateTime: '更新于',\n    cannotChangeEmbeddingModel: '知识库创建后不可修改嵌入模型',\n    updateKnowledgeBaseSuccess: '知识库更新成功',\n    updateKnowledgeBaseFailed: '知识库更新失败：',\n    documentsTab: {\n      name: '名称',\n      status: '状态',\n      noResults: '暂无文档',\n      dragAndDrop: '拖拽文件到此处或点击上传',\n      uploading: '上传中...',\n      supportedFormats: '支持 PDF、Word、TXT、Markdown、HTML、ZIP 等文档格式',\n      uploadSuccess: '文件上传成功！',\n      uploadError: '文件上传失败：',\n      uploadingFile: '上传文件中...',\n      fileSizeExceeded: '文件大小超过 10MB 限制，请分割成较小的文件后上传',\n      actions: '操作',\n      delete: '删除文件',\n      fileDeleteSuccess: '文件删除成功',\n      fileDeleteFailed: '文件删除失败：',\n      processing: '处理中',\n      completed: '完成',\n      failed: '失败',\n      selectParser: '选择解析器',\n      builtInParser: '由知识引擎提供',\n      noParserAvailable:\n        '没有解析器支持此文件类型，请安装支持该格式的解析器插件。',\n      confirmUpload: '上传',\n      cancelUpload: '取消',\n    },\n    deleteKnowledgeBaseConfirmation:\n      '你确定要删除这个知识库吗？此知识库下的所有文档将被删除。',\n    retrieve: '检索测试',\n    retrieveTest: '检索测试',\n    query: '查询',\n    queryPlaceholder: '输入查询内容...',\n    distance: '距离',\n    content: '内容',\n    fileName: '文件名',\n    noResults: '暂无结果',\n    retrieveError: '检索失败：',\n    unknownEngine: '未知引擎',\n    knowledgeEngine: '知识引擎',\n    knowledgeEngineRequired: '知识引擎不能为空',\n    selectKnowledgeEngine: '选择知识引擎',\n    builtInEngine: '内置引擎',\n    cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎',\n    engineSettings: '引擎设置',\n    engineSettingsReadonly: '编辑模式下不可修改',\n    retrievalSettings: '检索设置',\n    noEnginesAvailable: '没有可用的知识库引擎',\n    installEngineHint: '请先安装「知识引擎」插件',\n    createKnowledgeBaseFailed: '知识库创建失败：',\n    loadKnowledgeBaseFailed: '知识库加载失败：',\n    deleteKnowledgeBaseFailed: '知识库删除失败：',\n    getKnowledgeBaseListError: '获取知识库列表失败：',\n    embeddingModel: '嵌入模型',\n    embeddingModelRequired: '此引擎需要选择嵌入模型',\n    addExternal: '添加外部知识库',\n    createExternalSuccess: '外部知识库创建成功',\n    updateExternalSuccess: '外部知识库更新成功',\n    deleteExternalSuccess: '外部知识库删除成功',\n    retriever: '检索器',\n    selectRetriever: '选择一个检索器...',\n    retrieverConfiguration: '检索器配置',\n    retrieverInstallInfo: '您可以从',\n    retrieverMarketLink: '此处安装知识检索器插件',\n    migration: {\n      title: '知识库迁移',\n      description:\n        '新版本已将知识库重构为插件化架构，并统一内置知识库和外部知识库为「知识引擎」插件，需要对旧知识库数据进行迁移。您的旧数据已自动备份在数据库中。',\n      detected:\n        '共检测到 {{total}} 个知识库需要迁移（{{internal}} 个内置知识库，{{external}} 个外部知识库）。',\n      startWithInstall: '自动安装插件并迁移',\n      startDataOnly: '仅迁移数据',\n      dataOnlyHint:\n        '「仅迁移数据」适合内网环境使用，请在迁移完成后自行安装对应插件',\n      dismiss: '丢弃原数据',\n      running: '正在迁移知识库，请稍候...',\n      success: '知识库迁移完成',\n      error: '知识库迁移失败：',\n      dismissError: '操作失败',\n      retry: '重试',\n    },\n  },\n  register: {\n    title: '初始化 LangBot 👋',\n    description: '这是您首次启动 LangBot',\n    adminAccountNote: '您在此处初始化使用的账号将作为管理员账号',\n    register: '注册',\n    initWithSpace: '通过 Space 初始化',\n    spaceRecommended: '推荐：使用官方提供的稳定模型 API 和云服务',\n    spaceInfoTip1: 'Space 提供统一的账户鉴权服务，不会上传您的任何敏感信息。',\n    spaceInfoTip2:\n      '使用 Space 账户登录可使用 LangBot Models 等云服务，您将会获得一定的免费模型调用额度帮助您快速起步。',\n    spaceInfoTip3:\n      '登录方式不会影响其他功能，您在任何情况下都可以配置使用其他来源的模型。',\n    registerLocal: '注册本地账号',\n    registerWithPassword: '通过邮箱密码组合注册',\n    initSuccess: '初始化成功 请登录',\n    initFailed: '初始化失败：',\n  },\n  resetPassword: {\n    title: '重置密码 🔐',\n    description: '输入恢复密钥和新的密码来重置您的账户密码',\n    recoveryKey: '恢复密钥',\n    recoveryKeyDescription:\n      '存储在配置文件`data/config.yaml`的`system.recovery_key`中',\n    newPassword: '新密码',\n    enterRecoveryKey: '输入恢复密钥',\n    enterNewPassword: '输入新密码',\n    recoveryKeyRequired: '恢复密钥不能为空',\n    newPasswordRequired: '新密码不能为空',\n    resetPassword: '重置密码',\n    resetting: '重置中...',\n    resetSuccess: '密码重置成功，请登录',\n    resetFailed: '密码重置失败，请检查邮箱和恢复密钥是否正确',\n    backToLogin: '返回登录',\n  },\n  embedding: {\n    description: '管理嵌入模型，用于向量化文本',\n    createModel: '创建嵌入模型',\n    editModel: '编辑嵌入模型',\n    getModelListError: '获取嵌入模型列表失败：',\n    embeddingModels: '嵌入模型',\n    extraParametersDescription:\n      '将在请求时附加到请求体中，如 encoding_format, dimensions 等',\n  },\n  llm: {\n    llmModels: '对话模型',\n    description: '管理 LLM 模型,用于对话消息生成',\n    extraParametersDescription:\n      '将在请求时附加到请求体中，如 max_tokens, temperature, top_p 等',\n  },\n  version: {\n    newVersionAvailable: '有新版本可用',\n    viewUpdateGuide: '查看更新方式',\n    noReleaseNotes: '暂无更新日志',\n  },\n  account: {\n    settings: '账户设置',\n    setPassword: '设置密码',\n    passwordSetSuccess: '密码设置成功',\n    passwordStatus: '本地密码',\n    passwordSet: '已设置',\n    passwordNotSet: '未设置',\n    passwordSetDescription: '您已设置本地密码，可使用邮箱密码登录',\n    spaceStatus: 'Space 账户',\n    spaceBound: '已绑定',\n    spaceNotBound: '未绑定',\n    spaceBoundDescription: '已绑定 Space 账户，可使用官方模型 API 和云服务',\n    bindSpace: '绑定 Space 账户',\n    bindSpaceDescription: '绑定后可使用官方模型 API 和云服务',\n    bindSpaceButton: '绑定',\n    bindSpaceConfirmTitle: '确认绑定',\n    bindSpaceConfirmDescription: '您即将把本地实例绑定到 Space 账户',\n    bindSpaceWarning:\n      '绑定后，您的登录邮箱将从 {{localEmail}} 更改为 Space 账户的邮箱。',\n    bindSpaceSuccess: 'Space 账户绑定成功',\n    bindSpaceFailed: '绑定 Space 账户失败',\n    bindSpaceInvalidState: '无效的绑定请求，请从账户设置重新发起',\n    setPasswordHint: '设置密码后可使用邮箱密码登录',\n    spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',\n  },\n  monitoring: {\n    title: '日志监控',\n    description: '查看机器人活动、LLM调用和系统性能',\n    overview: '概览',\n    totalMessages: '总消息数',\n    llmCallsCount: 'LLM调用',\n    modelCallsCount: '模型调用',\n    successRate: '成功率',\n    activeSessions: '活跃会话',\n    last24Hours: '最近24小时',\n    filters: {\n      title: '筛选',\n      bot: '机器人',\n      pipeline: '流水线',\n      allBots: '全部机器人',\n      selectBot: '选择机器人',\n      allPipelines: '全部流水线',\n      selectPipeline: '选择流水线',\n      loading: '加载中...',\n      timeRange: '时间范围',\n      customRange: '自定义范围',\n      from: '从',\n      to: '到',\n      apply: '应用',\n      reset: '重置筛选',\n      lastHour: '最近1小时',\n      last6Hours: '最近6小时',\n      last24Hours: '最近24小时',\n      last7Days: '最近7天',\n      last30Days: '最近30天',\n    },\n    tabs: {\n      messages: '消息记录',\n      llmCalls: 'LLM调用',\n      embeddingCalls: 'Embedding调用',\n      modelCalls: '模型调用',\n      sessions: '会话分析',\n      errors: '错误日志',\n    },\n    messageList: {\n      timestamp: '时间戳',\n      bot: '机器人',\n      pipeline: '流水线',\n      message: '消息',\n      sessionId: '会话ID',\n      status: '状态',\n      actions: '操作',\n      viewDetails: '查看详情',\n      copyId: '复制ID',\n      noMessages: '未找到消息',\n      noMessagesDescription: '尝试调整筛选条件或稍后查看',\n      loading: '加载消息中...',\n      loadMore: '加载更多',\n      autoRefresh: '自动刷新',\n      platform: '角色',\n      user: '用户',\n      level: '级别',\n      runner: '执行器',\n      viewConversation: '显示对话详情',\n    },\n    llmCalls: {\n      title: 'LLM调用',\n      model: '模型',\n      tokens: 'Token数',\n      duration: '持续时间',\n      cost: '成本',\n      noData: '未找到LLM调用记录',\n      inputTokens: '输入Token',\n      outputTokens: '输出Token',\n      totalTokens: '总Token数',\n      avgDuration: '平均耗时',\n      calls: '调用次数',\n    },\n    embeddingCalls: {\n      title: 'Embedding调用',\n      model: '模型',\n      tokens: 'Token数',\n      duration: '持续时间',\n      noData: '未找到Embedding调用记录',\n      promptTokens: '输入Token',\n      totalTokens: '总Token数',\n      inputCount: '输入数量',\n      knowledgeBase: '知识库',\n      queryText: '查询文本',\n    },\n    modelCalls: {\n      title: '模型调用',\n      llmModel: '对话模型',\n      embeddingModel: '嵌入模型',\n      embeddingCall: '嵌入调用',\n      retrieveCall: '检索调用',\n      noData: '未找到模型调用记录',\n    },\n    sessions: {\n      sessionId: '会话ID',\n      messageCount: '消息数',\n      duration: '持续时间',\n      lastActivity: '最后活动',\n      noSessions: '未找到会话',\n      startTime: '开始时间',\n      messageStats: '消息统计',\n      totalMessages: '总消息数',\n      successMessages: '成功',\n      errorMessages: '失败',\n      llmStats: 'LLM统计',\n      noData: '会话未找到',\n    },\n    errors: {\n      title: '错误',\n      errorType: '错误类型',\n      errorMessage: '错误消息',\n      occurredAt: '发生时间',\n      noErrors: '未找到错误',\n      stackTrace: '堆栈追踪',\n    },\n    queries: {\n      title: '查询记录',\n    },\n    messageDetails: {\n      noData: '此查询没有LLM调用或错误记录',\n    },\n    queryVariables: {\n      title: '查询变量',\n    },\n    trafficChart: {\n      title: '流量概览',\n      messages: '消息数',\n      llmCalls: 'LLM调用',\n      noData: '暂无流量数据',\n    },\n    viewMonitoring: '查看日志监控',\n    refreshData: '刷新数据',\n    exportData: '导出数据',\n    export: {\n      title: '导出数据',\n      exporting: '导出中...',\n      messages: '消息记录',\n      llmCalls: 'LLM 调用',\n      embeddingCalls: 'Embedding 调用',\n      errors: '错误日志',\n      sessions: '会话记录',\n    },\n  },\n  limitation: {\n    maxBotsReached:\n      '已达到机器人数量上限（{{max}}个）。请先删除已有机器人后再创建新的。',\n    maxPipelinesReached:\n      '已达到流水线数量上限（{{max}}个）。请先删除已有流水线后再创建新的。',\n    maxExtensionsReached:\n      '已达到扩展数量上限（{{max}}个）。请先删除已有的 MCP 服务器或插件后再添加新的。',\n  },\n};\n\nexport default zhHans;\n"
  },
  {
    "path": "web/src/i18n/locales/zh-Hant.ts",
    "content": "const zhHant = {\n  common: {\n    login: '登入',\n    logout: '登出',\n    accountOptions: '系統設定',\n    account: '帳戶',\n    integration: '連接',\n    email: '電子郵件',\n    password: '密碼',\n    welcome: '歡迎回到 LangBot 👋',\n    continueToLogin: '登入以繼續',\n    loginSuccess: '登入成功',\n    loginFailed: '登入失敗，請檢查電子郵件和密碼是否正確',\n    loginLoadError: '無法連線到伺服器',\n    loginLoadErrorDesc: '無法連線到 LangBot 後端服務，請確認服務已啟動後重試。',\n    retry: '重試',\n    enterEmail: '輸入電子郵件地址',\n    enterPassword: '輸入密碼',\n    invalidEmail: '請輸入有效的電子郵件地址',\n    emptyPassword: '請輸入密碼',\n    language: '語言',\n    helpDocs: '輔助說明',\n    featureRequest: '需求建議',\n    create: '建立',\n    edit: '編輯',\n    delete: '刪除',\n    add: '新增',\n    select: '請選擇',\n    cancel: '取消',\n    submit: '提交',\n    error: '錯誤',\n    success: '成功',\n    save: '儲存',\n    saving: '儲存中...',\n    confirm: '確認',\n    confirmDelete: '確認刪除',\n    deleteConfirmation: '您確定要刪除這個嗎？',\n    selectOption: '選擇一個選項',\n    required: '必填',\n    enable: '是否啟用',\n    name: '名稱',\n    description: '描述',\n    icon: '圖標',\n    close: '關閉',\n    deleteSuccess: '刪除成功',\n    deleteError: '刪除失敗：',\n    addRound: '新增回合',\n    copy: '複製',\n    copySuccess: '複製成功',\n    copyFailed: '複製失敗',\n    test: '測試',\n    forgotPassword: '忘記密碼？',\n    agreementNotice: '繼續即表示您同意我們的',\n    privacyPolicy: '隱私政策',\n    and: '和',\n    dataCollectionPolicy: '數據收集政策',\n    dataCollectionPolicyUrl:\n      'https://docs.langbot.app/zh/insight/data-collection-policy',\n    loading: '載入中...',\n    fieldRequired: '此欄位為必填',\n    or: '或',\n    loginWithSpace: '透過 Space 登入',\n    spaceLoginRecommended: '推薦：使用官方提供的穩定模型 API 和雲服務',\n    loginLocal: '使用本地帳號登入',\n    loginWithPassword: '透過密碼登入',\n    spaceLoginTitle: '透過 Space 登入',\n    spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',\n    spaceLoginUserCode: '您的驗證碼',\n    spaceLoginExpires: '驗證碼將在 {{seconds}} 秒後過期',\n    spaceLoginWaiting: '等待授權中...',\n    spaceLoginSuccess: '授權成功',\n    spaceLoginFailed: 'Space 登入失敗',\n    spaceLoginExpired: '驗證碼已過期，請重試',\n    spaceLoginCancel: '取消',\n    spaceLoginVisitLink: '訪問連結',\n    spaceLoginProcessing: '正在透過 Space 登入',\n    spaceLoginProcessingDescription: '請稍候，正在完成登入...',\n    spaceLoginSuccessDescription: '正在跳轉到 LangBot...',\n    spaceLoginError: '登入失敗',\n    spaceLoginNoCode: '缺少授權碼',\n    backToLogin: '返回登入',\n    backToHome: '返回首頁',\n    spaceAccountCannotChangePassword: 'Space 帳戶無法在此修改密碼',\n    theme: '主題',\n    changePassword: '修改密碼',\n    currentPassword: '當前密碼',\n    newPassword: '新密碼',\n    confirmNewPassword: '確認新密碼',\n    enterCurrentPassword: '輸入當前密碼',\n    enterNewPassword: '輸入新密碼',\n    enterConfirmPassword: '確認新密碼',\n    currentPasswordRequired: '當前密碼不能為空',\n    newPasswordRequired: '新密碼不能為空',\n    confirmPasswordRequired: '確認密碼不能為空',\n    passwordsDoNotMatch: '兩次輸入的密碼不一致',\n    changePasswordSuccess: '密碼修改成功',\n    changePasswordFailed: '密碼修改失敗，請檢查當前密碼是否正確',\n    apiIntegration: 'API 整合',\n    apiKeys: 'API 金鑰',\n    manageApiIntegration: '管理 API 整合',\n    manageApiKeys: '管理 API 金鑰',\n    createApiKey: '建立 API 金鑰',\n    apiKeyName: 'API 金鑰名稱',\n    apiKeyDescription: 'API 金鑰描述',\n    apiKeyValue: 'API 金鑰值',\n    apiKeyCreated: 'API 金鑰建立成功',\n    apiKeyDeleted: 'API 金鑰刪除成功',\n    apiKeyDeleteConfirm: '確定要刪除此 API 金鑰嗎？',\n    apiKeyNameRequired: 'API 金鑰名稱不能為空',\n    copyApiKey: '複製 API 金鑰',\n    apiKeyCopied: 'API 金鑰已複製到剪貼簿',\n    noApiKeys: '暫無 API 金鑰',\n    apiKeyHint: 'API 金鑰允許外部系統訪問 LangBot 的 Service API',\n    webhooks: 'Webhooks',\n    createWebhook: '建立 Webhook',\n    webhookName: 'Webhook 名稱',\n    webhookUrl: 'Webhook URL',\n    webhookDescription: 'Webhook 描述',\n    webhookEnabled: '是否啟用',\n    webhookCreated: 'Webhook 建立成功',\n    webhookDeleted: 'Webhook 刪除成功',\n    webhookDeleteConfirm: '確定要刪除此 Webhook 嗎？',\n    webhookNameRequired: 'Webhook 名稱不能為空',\n    webhookUrlRequired: 'Webhook URL 不能為空',\n    noWebhooks: '暫無 Webhook',\n    webhookHint: 'Webhook 允許 LangBot 將個人訊息和群組訊息事件推送到外部系統',\n    actions: '操作',\n    apiKeyCreatedMessage: '請複製此 API 金鑰，若按鈕無效，請手動複製。',\n    none: '無',\n  },\n  notFound: {\n    title: '頁面不存在',\n    description:\n      '您要查詢的頁面似乎不存在。請檢查您輸入的 URL 是否正確，或返回首頁。',\n    back: '上一級',\n    home: '返回主頁',\n    help: '查看說明文件',\n  },\n  models: {\n    title: '模型設定',\n    description: '設定和管理可在流程線中使用的模型',\n    createModel: '建立模型',\n    editModel: '編輯模型',\n    getModelListError: '取得模型清單失敗：',\n    modelName: '模型名稱',\n    modelProvider: '模型供應商',\n    modelBaseURL: '基礎 URL',\n    modelAbilities: '模型能力',\n    saveSuccess: '儲存成功',\n    saveError: '儲存失敗：',\n    createSuccess: '建立成功',\n    createError: '建立失敗：',\n    deleteSuccess: '刪除成功',\n    deleteError: '刪除失敗：',\n    deleteConfirmation: '您確定要刪除這個模型嗎？',\n    modelNameRequired: '模型名稱不能為空',\n    modelProviderRequired: '模型供應商不能為空',\n    requestURLRequired: '請求URL不能為空',\n    apiKeyRequired: 'API Key不能為空',\n    keyNameRequired: '鍵名不能為空',\n    mustBeValidNumber: '必須是有效的數字',\n    mustBeTrueOrFalse: '必須是 true 或 false',\n    requestURL: '請求URL',\n    apiKey: 'API Key',\n    abilities: '能力',\n    selectModelAbilities: '選擇模型能力',\n    visionAbility: '視覺能力',\n    functionCallAbility: '函數呼叫',\n    extraParameters: '額外參數',\n    addParameter: '新增參數',\n    keyName: '鍵名',\n    type: '類型',\n    value: '值',\n    string: '字串',\n    number: '數字',\n    boolean: '布林值',\n    selectModelProvider: '選擇模型供應商',\n    modelProviderDescription: '請填寫供應商向您提供的模型名稱',\n    modelManufacturer: '模型廠商',\n    aggregationPlatform: '中轉平台',\n    selfDeployed: '自部署',\n    builtin: '內建',\n    selectModel: '請選擇模型',\n    testSuccess: '測試成功',\n    testError: '測試失敗，請檢查模型設定',\n    llmModels: '對話模型',\n    localProvider: '本地',\n    localProviderDescription: '在本地設定和管理的模型',\n    spaceProviderDescription: '從您的 Space 帳戶同步的模型',\n    spaceDisabledForLocalAccount: '使用 Space 登入以使用雲端模型',\n    syncModels: '同步',\n    syncSuccess: '同步完成：建立 {{created}} 個，更新 {{updated}} 個',\n    syncError: '同步失敗：',\n    spaceModelReadOnly: 'Space 模型為唯讀',\n    noSpaceModels: '暫無 Space 模型。點擊同步按鈕從 Space 取得模型。',\n    noLocalModels: '暫無本地模型。點擊建立按鈕新增模型。',\n    providerCount: '共 {{count}} 個供應商',\n    addModel: '新增模型',\n    addLLMModel: '新增對話模型',\n    addEmbeddingModel: '新增嵌入模型',\n    provider: '供應商',\n    existingProvider: '現有供應商',\n    newProvider: '新供應商',\n    selectProvider: '選擇供應商',\n    requester: '供應商類型',\n    selectRequester: '選擇供應商類型',\n    langbotModelsDescription: '由 LangBot Space 提供的雲端模型',\n    credits: '積分',\n    loginWithSpace: '使用 Space 登入',\n    loginToUseModels: '使用 Space 登入以使用雲端模型',\n    noModels: '暫無模型',\n    editProvider: '編輯供應商',\n    addProvider: '新增供應商',\n    addProviderHint: '新增供應商以使用其他來源的模型',\n    addProviderHintSimple: '新增供應商以使用模型',\n    noProviders: '暫無供應商',\n    providerName: '供應商名稱',\n    providerNameRequired: '供應商名稱不能為空',\n    requesterRequired: '供應商類型不能為空',\n    providerSaved: '供應商已儲存',\n    providerCreated: '供應商已建立',\n    providerSaveError: '儲存供應商失敗：',\n    providerDeleted: '供應商已刪除',\n    providerDeleteError: '刪除供應商失敗：',\n    deleteProviderConfirmation: '您確定要刪除這個供應商嗎？',\n    loadError: '載入資料失敗',\n    chat: '對話',\n    embedding: '嵌入',\n    modelsCount: '{{count}} 個模型',\n    expandModels: '展開',\n    collapseModels: '收起',\n    fallback: {\n      primary: '主模型',\n      fallbackList: '備用模型',\n      addFallback: '新增備用模型',\n    },\n  },\n  bots: {\n    title: '機器人',\n    description: '建立和管理機器人，這是 LangBot 與各個平台連接的入口',\n    createBot: '建立機器人',\n    editBot: '編輯機器人',\n    getBotListError: '取得機器人清單失敗：',\n    botName: '機器人名稱',\n    botDescription: '機器人描述',\n    botNameRequired: '機器人名稱不能為空',\n    botDescriptionRequired: '機器人描述不能為空',\n    adapterRequired: '適配器不能為空',\n    defaultDescription: '一個機器人',\n    getBotConfigError: '取得機器人設定失敗：',\n    saveSuccess: '儲存成功',\n    saveError: '儲存失敗：',\n    createSuccess: '建立成功 請啟用或修改綁定流程線',\n    createError: '建立失敗：',\n    deleteSuccess: '刪除成功',\n    deleteError: '刪除失敗：',\n    deleteConfirmation: '您確定要刪除這個機器人嗎？',\n    platformAdapter: '平台/適配器選擇',\n    selectAdapter: '選擇適配器',\n    adapterConfig: '適配器設定',\n    bindPipeline: '綁定流程線',\n    selectPipeline: '選擇流程線',\n    selectBot: '請選擇機器人',\n    botLogTitle: '機器人日誌',\n    enableAutoRefresh: '開啟自動重新整理',\n    session: '對話',\n    yesterday: '昨天',\n    earlier: '更久之前',\n    dateFormat: '{{month}}月{{day}}日',\n    setBotEnableError: '設定機器人啟用狀態失敗',\n    log: '日誌',\n    configuration: '設定',\n    logs: '日誌',\n    webhookUrl: 'Webhook 回調位址',\n    webhookUrlCopied: 'Webhook 位址已複製',\n    webhookUrlHint:\n      '點擊輸入框自動全選，然後按 Ctrl+C (Mac: Cmd+C) 複製，或點擊右側按鈕',\n    webhookUrlHintEither: '以上兩個地址任選其一填入平台配置即可',\n    logLevel: '日誌級別',\n    allLevels: '全部級別',\n    selectLevel: '選擇級別',\n    levelsSelected: '個級別已選',\n    sessionMonitor: {\n      title: '會話監控',\n      sessions: '會話列表',\n      noSessions: '暫無會話',\n      selectSession: '選擇一個會話查看訊息',\n      noMessages: '該會話暫無訊息',\n      messages: '條訊息',\n      messageCount: '{{count}} 條訊息',\n      loading: '載入中...',\n      loadingSessions: '載入會話中...',\n      loadingMessages: '載入訊息中...',\n      user: '用戶',\n      variables: '變數',\n      platform: '平台',\n      lastActive: '最近活躍',\n      refresh: '重新整理',\n      active: '活躍',\n      inactive: '不活躍',\n    },\n  },\n  plugins: {\n    title: '外掛擴展',\n    description: '安裝和設定用於擴展功能的外掛，請在流程線配置中選用',\n    createPlugin: '建立外掛',\n    editPlugin: '編輯外掛',\n    installed: '已安裝',\n    marketplace: 'Marketplace',\n    arrange: '編排',\n    install: '安裝',\n    installFromGithub: '來自 GitHub',\n    onlySupportGithub: '目前僅支援從 GitHub 安裝',\n    enterGithubLink: '請輸入外掛的Github連結',\n    installing: '正在安裝外掛...',\n    installSuccess: '外掛安裝成功',\n    installFailed: '外掛安裝失敗：',\n    searchPlugin: '搜尋外掛',\n    sortBy: '排序方式',\n    mostStars: '最多星標',\n    recentlyAdded: '最近新增',\n    recentlyUpdated: '最近更新',\n    noMatchingPlugins: '沒有找到符合的外掛',\n    loading: '載入中...',\n    getPluginListError: '取得外掛清單失敗:',\n    pluginConfig: '外掛設定',\n    noPluginInstalled: '暫未安裝任何外掛',\n    pluginSort: '外掛排序',\n    pluginSortDescription:\n      '外掛順序會影響同一事件內的處理順序，請拖曳外掛卡片排序',\n    pluginSortSuccess: '外掛排序成功',\n    pluginSortError: '外掛排序失敗：',\n    pluginNoConfig: '外掛沒有設定項目。',\n    systemDisabled: '外掛系統未啟用',\n    systemDisabledDesc: '尚未啟用外掛系統，請根據文檔修改配置',\n    connectionError: '外掛系統連接異常',\n    connectionErrorDesc: '請檢查外掛系統配置或聯絡管理員',\n    errorDetails: '錯誤詳情',\n    loadingStatus: '正在檢查外掛系統狀態...',\n    failedToGetStatus: '取得外掛系統狀態失敗',\n    pluginSystemNotReady: '外掛系統未就緒，無法執行此操作',\n    debugInfo: '偵錯資訊',\n    debugInfoTitle: '外掛偵錯資訊',\n    debugUrl: '偵錯位址',\n    debugKey: '偵錯金鑰',\n    noDebugKey: '(未設定)',\n    debugKeyDisabled: '未設定偵錯金鑰，外掛偵錯無需認證',\n    failedToGetDebugInfo: '取得偵錯資訊失敗',\n    copiedToClipboard: '已複製到剪貼簿',\n    deleting: '刪除中...',\n    deletePlugin: '刪除外掛',\n    cancel: '取消',\n    saveConfig: '儲存設定',\n    saving: '儲存中...',\n    confirmDeletePlugin: '您確定要刪除外掛（{{author}}/{{name}}）嗎？',\n    deleteDataCheckbox: '同時刪除外掛設定和持久化儲存',\n    confirmDelete: '確認刪除',\n    deleteError: '刪除失敗：',\n    close: '關閉',\n    deleteConfirm: '刪除確認',\n    modifyFailed: '修改失敗：',\n    componentName: {\n      Tool: '工具',\n      EventListener: '事件監聽器',\n      Command: '命令',\n      KnowledgeEngine: '知識引擎',\n      Parser: '解析器',\n    },\n    uploadLocal: '本地上傳',\n    debugging: '調試中',\n    uploadLocalPlugin: '上傳本地插件',\n    dragToUpload: '拖拽文件到此處上傳',\n    unsupportedFileType: '不支持的文件類型，僅支持 .lbpkg 和 .zip 文件',\n    uploadingPlugin: '正在上傳插件...',\n    uploadSuccess: '上傳成功',\n    uploadFailed: '上傳失敗',\n    selectFileToUpload: '選擇要上傳的插件文件',\n    askConfirm: '確定要安裝插件 \"{{name}}\" ({{version}}) 嗎？',\n    fromGithub: '來自 GitHub',\n    fromLocal: '本地安裝',\n    fromMarketplace: '來自市場',\n    componentsList: '組件: ',\n    noComponents: '無組件',\n    delete: '刪除插件',\n    update: '更新插件',\n    new: '新',\n    updateConfirm: '更新確認',\n    confirmUpdatePlugin: '您確定要更新插件（{{author}}/{{name}}）嗎？',\n    confirmUpdate: '確認更新',\n    updating: '更新中...',\n    updateSuccess: '插件更新成功',\n    updateError: '更新失敗：',\n    saveConfigSuccessNormal: '儲存配置成功',\n    saveConfigError: '儲存配置失敗：',\n    config: '配置',\n    readme: '文件',\n    viewSource: '查看來源',\n    loadingReadme: '正在載入文件...',\n    noReadme: '該插件沒有提供 README 文件',\n    fileUpload: {\n      tooLarge: '檔案大小超過 10MB 限制',\n      success: '檔案上傳成功',\n      failed: '檔案上傳失敗',\n      uploading: '上傳中...',\n      chooseFile: '選擇檔案',\n      addFile: '新增檔案',\n    },\n    enterRepoUrl: '請輸入 GitHub 倉庫地址',\n    repoUrlPlaceholder: '例如: https://github.com/owner/repo',\n    fetchingReleases: '正在獲取 Release 列表...',\n    selectRelease: '選擇 Release',\n    noReleasesFound: '未找到 Release',\n    fetchReleasesError: '獲取 Release 列表失敗：',\n    selectAsset: '選擇要安裝的文件',\n    noAssetsFound: '該 Release 沒有可用的 .lbpkg 文件',\n    fetchAssetsError: '獲取文件列表失敗：',\n    backToReleases: '返回 Release 列表',\n    backToRepoUrl: '返回倉庫地址',\n    backToAssets: '返回文件選擇',\n    releaseTag: 'Tag: {{tag}}',\n    releaseName: '名稱: {{name}}',\n    publishedAt: '發佈於: {{date}}',\n    prerelease: '預發佈',\n    assetSize: '大小: {{size}}',\n    confirmInstall: '確認安裝',\n    installFromGithubDesc: '從 GitHub Release 安裝插件',\n  },\n  market: {\n    searchPlaceholder: '搜尋插件...',\n    searchResults: '搜尋到 {{count}} 個插件',\n    totalPlugins: '共 {{count}} 個插件',\n    noPlugins: '暫無插件',\n    noResults: '未找到相關插件',\n    loadingMore: '載入更多...',\n    loading: '載入中...',\n    allLoaded: '已顯示全部插件',\n    install: '安裝',\n    installConfirm: '確定要安裝插件 \"{{name}}\" ({{version}}) 嗎？',\n    downloadComplete: '插件 \"{{name}}\" 下載完成',\n    installFailed: '安裝失敗，請稍後重試',\n    loadFailed: '取得插件列表失敗，請稍後重試',\n    noDescription: '暫無描述',\n    notFound: '插件資訊未找到',\n    sortBy: '排序方式',\n    sort: {\n      recentlyAdded: '最近新增',\n      recentlyUpdated: '最近更新',\n      mostDownloads: '最多下載',\n      leastDownloads: '最少下載',\n    },\n    downloads: '次下載',\n    download: '下載',\n    repository: '代碼倉庫',\n    downloadFailed: '下載失敗',\n    noReadme: '該插件沒有提供 README 文件',\n    description: '描述',\n    tagLabel: '標籤',\n    submissionTitle: '您有插件提交正在審核中： {{name}}',\n    submissionApproved: '您的插件提交已通過審核： {{name}}',\n    submissionRejected: '您的插件提交已被拒絕： {{name}}',\n    clickToRevoke: '撤回',\n    revokeSuccess: '撤回成功',\n    revokeFailed: '撤回失敗',\n    submissionDetails: '插件提交詳情',\n    markAsRead: '已讀',\n    markAsReadSuccess: '已標記為已讀',\n    markAsReadFailed: '標記為已讀失敗',\n    filterByComponent: '組件',\n    allComponents: '全部組件',\n    requestPlugin: '請求插件',\n    tags: {\n      filterByTags: '按標籤篩選',\n      selected: '已選',\n      selectTags: '選擇標籤',\n      clearAll: '清空',\n      noTags: '暫無標籤',\n    },\n    viewDetails: '查看詳情',\n    deprecated: '已棄用',\n    deprecatedTooltip: '請安裝對應「知識引擎」插件',\n  },\n  mcp: {\n    title: 'MCP',\n    createServer: '新增MCP伺服器',\n    editServer: '編輯MCP伺服器',\n    deleteServer: '刪除MCP伺服器',\n    confirmDeleteServer: '您確定要刪除此MCP伺服器嗎？',\n    confirmDeleteTitle: '刪除MCP伺服器',\n    getServerListError: '取得MCP伺服器清單失敗：',\n    serverName: '伺服器名稱',\n    serverMode: '連接模式',\n    stdio: 'Stdio模式',\n    sse: 'SSE模式',\n    selectMode: '選擇連接模式',\n    http: 'HTTP模式',\n    noServerInstalled: '暫未設定任何MCP伺服器',\n    serverNameRequired: '伺服器名稱不能為空',\n    commandRequired: '命令不能為空',\n    urlRequired: 'URL不能為空',\n    timeoutMustBePositive: '逾時時間必須是正數',\n    command: '命令',\n    args: '參數',\n    env: '環境變數',\n    url: 'URL位址',\n    headers: '請求標頭',\n    timeout: '逾時時間',\n    addArgument: '新增參數',\n    addEnvVar: '新增環境變數',\n    addHeader: '新增請求標頭',\n    keyName: '鍵名',\n    value: '值',\n    testing: '測試中...',\n    connecting: '連接中...',\n    testSuccess: '測試成功',\n    testFailed: '刷新失敗：',\n    testError: '刷新出錯',\n    refreshSuccess: '刷新成功',\n    refreshFailed: '刷新失敗：',\n    connectionSuccess: '連接成功',\n    connectionFailed: '連接失敗，請檢查URL',\n    connectionFailedStatus: '連接失敗',\n    toolsFound: '個工具',\n    unknownError: '未知錯誤',\n    noToolsFound: '未找到任何工具',\n    parseResultFailed: '解析測試結果失敗',\n    noResultReturned: '測試未返回結果',\n    getTaskFailed: '獲取任務狀態失敗',\n    noTaskId: '未獲取到任務ID',\n    deleteSuccess: '刪除成功',\n    deleteFailed: '刪除失敗：',\n    deleteError: '刪除失敗：',\n    saveSuccess: '儲存成功',\n    saveError: '儲存失敗：',\n    createSuccess: '建立成功',\n    createFailed: '建立失敗：',\n    createError: '建立失敗：',\n    loadFailed: '載入失敗',\n    modifyFailed: '修改失敗：',\n    toolCount: '工具：{{count}}',\n    statusConnected: '已開啟',\n    statusDisconnected: '未開啟',\n    statusError: '連接錯誤',\n    statusDisabled: '已停用',\n    loading: '載入中...',\n    starCount: '星標：{{count}}',\n    install: '安裝',\n    installFromGithub: '從Github安裝MCP伺服器',\n    add: '新增',\n    name: '名稱',\n    nameRequired: '名稱不能為空',\n    sseTimeout: 'SSE逾時時間',\n    sseTimeoutDescription: '用於建立SSE連接的逾時時間',\n    extraParametersDescription: '額外參數，用於設定MCP伺服器的特定行為',\n    timeoutMustBeNumber: '逾時時間必須是數字',\n    timeoutNonNegative: '逾時時間不能為負數',\n    sseTimeoutMustBeNumber: 'SSE逾時時間必須是數字',\n    sseTimeoutNonNegative: 'SSE逾時時間不能為負數',\n    updateSuccess: '更新成功',\n    updateFailed: '更新失敗：',\n  },\n  pipelines: {\n    title: '流程線',\n    description: '流程線定義了對訊息事件的處理流程，用於綁定到機器人',\n    createPipeline: '建立流程線',\n    editPipeline: '編輯流程線',\n    chat: '對話',\n    configuration: '設定',\n    debugChat: '對話除錯',\n    getPipelineListError: '取得流程線清單失敗：',\n    daysAgo: '天前',\n    today: '今天',\n    updateTime: '更新於',\n    defaultBadge: '預設',\n    sortBy: '排序方式',\n    newestCreated: '最新建立',\n    earliestCreated: '最早建立',\n    recentlyEdited: '最近編輯',\n    earliestEdited: '最早編輯',\n    basicInfo: '基本資訊',\n    aiCapabilities: 'AI 能力',\n    triggerConditions: '觸發條件',\n    safetyControls: '安全控制',\n    outputProcessing: '輸出處理',\n    nameRequired: '名稱不能為空',\n    descriptionRequired: '描述不能為空',\n    createSuccess: '建立成功 請編輯流程線詳細參數',\n    createError: '建立失敗：',\n    saveSuccess: '儲存成功',\n    saveError: '儲存失敗：',\n    copySuffix: ' Copy',\n    deleteConfirmation:\n      '您確定要刪除這個流程線嗎？已綁定此流程線的機器人將無法使用。',\n    defaultPipelineCannotDelete: '預設流程線不可刪除',\n    deleteSuccess: '刪除成功',\n    deleteError: '刪除失敗：',\n    copyConfirmTitle: '確認複製',\n    copyConfirmation:\n      '確定要複製這個流程線嗎？複製將創建一個包含完整配置的新流程線。',\n    unsavedChanges: '有未儲存的變更',\n    extensions: {\n      title: '擴展集成',\n      loadError: '載入插件清單失敗',\n      saveSuccess: '儲存成功',\n      saveError: '儲存失敗',\n      noPluginsAvailable: '暫無可用插件',\n      disabled: '已停用',\n      noPluginsSelected: '未選擇任何插件',\n      addPlugin: '新增插件',\n      selectPlugins: '選擇插件',\n      pluginsTitle: '插件',\n      mcpServersTitle: 'MCP 伺服器',\n      noMCPServersSelected: '未選擇任何 MCP 伺服器',\n      addMCPServer: '新增 MCP 伺服器',\n      selectMCPServers: '選擇 MCP 伺服器',\n      toolCount: '{{count}} 個工具',\n      noPluginsInstalled: '無已安裝的插件',\n      noMCPServersConfigured: '無已配置的 MCP 伺服器',\n      selectAll: '全選',\n      enableAllPlugins: '啟用所有插件',\n      enableAllMCPServers: '啟用所有 MCP 伺服器',\n      allPluginsEnabled: '已啟用所有插件',\n      allMCPServersEnabled: '已啟用所有 MCP 伺服器',\n    },\n    debugDialog: {\n      title: '流程線對話',\n      selectPipeline: '選擇流程線',\n      sessionType: '對話類型',\n      privateChat: '私聊',\n      groupChat: '群聊',\n      send: '傳送',\n      reset: '重設對話',\n      inputPlaceholder: '傳送 {{type}} 訊息...',\n      noMessages: '暫無訊息',\n      userMessage: '使用者',\n      botMessage: '機器人',\n      sendFailed: '傳送失敗',\n      resetSuccess: '對話已重設',\n      resetFailed: '重設失敗',\n      loadMessagesFailed: '載入訊息失敗',\n      loadPipelinesFailed: '載入流程線失敗',\n      atTips: '提及機器人',\n      streaming: '串流傳輸',\n      streamOutput: '串流',\n      connected: 'WebSocket已連接',\n      disconnected: 'WebSocket未連接',\n      connectionError: 'WebSocket連接錯誤',\n      connectionFailed: 'WebSocket連接失敗',\n      notConnected: 'WebSocket未連接，請稍後重試',\n      imageUploadFailed: '圖片上傳失敗',\n      reply: '回覆',\n      replyTo: '回覆給',\n      showMarkdown: '渲染',\n      showRaw: '原文',\n    },\n    monitoring: {\n      title: '監控日誌',\n      description: '檢視此流程線的執行記錄和錯誤資訊（最近24小時）',\n      detailedLogs: '詳細日誌',\n    },\n  },\n  knowledge: {\n    title: '知識庫',\n    createKnowledgeBase: '建立知識庫',\n    editKnowledgeBase: '編輯知識庫',\n    selectKnowledgeBase: '選擇知識庫',\n    selectKnowledgeBases: '選擇知識庫',\n    addKnowledgeBase: '新增知識庫',\n    noKnowledgeBaseSelected: '未選擇知識庫',\n    empty: '無',\n    editDocument: '文檔',\n    description: '設定可用於提升模型回覆品質的知識庫',\n    metadata: '中繼資料',\n    documents: '文檔',\n    kbNameRequired: '知識庫名稱不能為空',\n    kbDescriptionRequired: '知識庫描述不能為空',\n    embeddingModelUUIDRequired: '嵌入模型不能為空',\n    daysAgo: '天前',\n    today: '今天',\n    kbName: '知識庫名稱',\n    kbDescription: '知識庫描述',\n    topK: '召回數量 ',\n    topKRequired: '召回數量不能為空',\n    topKMax: '召回數量最大值為30',\n    topKdescription: '取得相關性高的上位 K 件文獻的數量，範圍為1～30',\n    defaultDescription: '一個知識庫',\n    embeddingModelUUID: '嵌入模型',\n    selectEmbeddingModel: '選擇嵌入模型',\n    embeddingModelDescription: '用於向量化文字，可在模型設定頁面設定',\n    updateTime: '更新於',\n    cannotChangeEmbeddingModel: '知識庫建立後不可修改嵌入模型',\n    updateKnowledgeBaseSuccess: '知識庫更新成功',\n    updateKnowledgeBaseFailed: '知識庫更新失敗：',\n    documentsTab: {\n      name: '名稱',\n      status: '狀態',\n      noResults: '暫無文件',\n      dragAndDrop: '拖曳文檔到此處或點擊上傳',\n      uploading: '上傳中...',\n      supportedFormats: '支援 PDF、Word、TXT、Markdown 等文檔格式',\n      uploadSuccess: '文檔上傳成功！',\n      uploadError: '文檔上傳失敗：',\n      uploadingFile: '上傳文檔中...',\n      fileSizeExceeded: '檔案大小超過 10MB 限制，請分割成較小的檔案後上傳',\n      actions: '操作',\n      delete: '刪除文檔',\n      fileDeleteSuccess: '文檔刪除成功',\n      fileDeleteFailed: '文檔刪除失敗：',\n      processing: '處理中',\n      completed: '完成',\n      failed: '失敗',\n      selectParser: '選擇解析器',\n      builtInParser: '由知識引擎提供',\n      noParserAvailable:\n        '沒有解析器支援此檔案類型，請安裝支援該格式的解析器插件。',\n      confirmUpload: '上傳',\n      cancelUpload: '取消',\n    },\n    deleteKnowledgeBaseConfirmation:\n      '您確定要刪除這個知識庫嗎？此知識庫下的所有文檔將被刪除。',\n    retrieve: '檢索測試',\n    retrieveTest: '檢索測試',\n    query: '查詢',\n    queryPlaceholder: '輸入查詢內容...',\n    distance: '距離',\n    content: '內容',\n    fileName: '文檔名稱',\n    noResults: '暫無結果',\n    retrieveError: '檢索失敗：',\n    noEnginesAvailable: '沒有可用的知識庫引擎',\n    installEngineHint: '請先安裝「知識引擎」插件',\n    unknownEngine: '未知引擎',\n    loadKnowledgeBaseFailed: '知識庫載入失敗：',\n    deleteKnowledgeBaseFailed: '知識庫刪除失敗：',\n    getKnowledgeBaseListError: '取得知識庫列表失敗：',\n    addExternal: '添加外部知識庫',\n    createExternalSuccess: '外部知識庫創建成功',\n    updateExternalSuccess: '外部知識庫更新成功',\n    deleteExternalSuccess: '外部知識庫刪除成功',\n    retriever: '檢索器',\n    selectRetriever: '選擇一個檢索器...',\n    retrieverConfiguration: '檢索器配置',\n    retrieverInstallInfo: '您可以從',\n    retrieverMarketLink: '此處安裝知識檢索器插件',\n    migration: {\n      title: '知識庫遷移',\n      description:\n        '新版本已將知識庫重構為插件化架構，並統一內建知識庫和外部知識庫為「知識引擎」插件，需要對舊知識庫資料進行遷移。您的舊資料已自動備份在資料庫中。',\n      detected:\n        '共檢測到 {{total}} 個知識庫需要遷移（{{internal}} 個內建知識庫，{{external}} 個外部知識庫）。',\n      startWithInstall: '自動安裝插件並遷移',\n      startDataOnly: '僅遷移資料',\n      dataOnlyHint:\n        '「僅遷移資料」適合內網環境使用，請在遷移完成後自行安裝對應插件',\n      dismiss: '丟棄原數據',\n      running: '正在遷移知識庫，請稍候...',\n      success: '知識庫遷移完成',\n      error: '知識庫遷移失敗：',\n      dismissError: '操作失敗',\n      retry: '重試',\n    },\n  },\n  register: {\n    title: '初始化 LangBot 👋',\n    description: '這是您首次啟動 LangBot',\n    adminAccountNote: '您在此處初始化使用的帳號將作為管理員帳號',\n    register: '註冊',\n    initWithSpace: '透過 Space 初始化',\n    spaceRecommended: '推薦：使用官方提供的穩定模型 API 和雲服務',\n    spaceInfoTip1: 'Space 提供統一的帳戶鑑權服務，不會上傳您的任何敏感資訊。',\n    spaceInfoTip2:\n      '使用 Space 帳戶登入可使用 LangBot Models 等雲服務，您將會獲得一定的免費模型調用額度幫助您快速起步。',\n    spaceInfoTip3:\n      '登入方式不會影響其他功能，您在任何情況下都可以配置使用其他來源的模型。',\n    registerLocal: '註冊本地帳號',\n    registerWithPassword: '透過電子郵件密碼組合註冊',\n    initSuccess: '初始化成功 請登入',\n    initFailed: '初始化失敗：',\n  },\n  resetPassword: {\n    title: '重設密碼 🔐',\n    description: '輸入恢復金鑰和新的密碼來重設您的帳戶密碼',\n    recoveryKey: '恢復金鑰',\n    recoveryKeyDescription:\n      '儲存在設定檔案`data/config.yaml`的`system.recovery_key`中',\n    newPassword: '新密碼',\n    enterRecoveryKey: '輸入恢復金鑰',\n    enterNewPassword: '輸入新密碼',\n    recoveryKeyRequired: '恢復金鑰不能為空',\n    newPasswordRequired: '新密碼不能為空',\n    resetPassword: '重設密碼',\n    resetting: '重設中...',\n    resetSuccess: '密碼重設成功，請登入',\n    resetFailed: '密碼重設失敗，請檢查電子郵件和恢復金鑰是否正確',\n    backToLogin: '返回登入',\n  },\n  embedding: {\n    description: '管理嵌入模型，用於向量化文字',\n    createModel: '建立嵌入模型',\n    editModel: '編輯嵌入模型',\n    getModelListError: '取得嵌入模型清單失敗：',\n    embeddingModels: '嵌入模型',\n    extraParametersDescription:\n      '將在請求時附加到請求體中，如 encoding_format, dimensions 等',\n  },\n  llm: {\n    llmModels: '對話模型',\n    description: '管理 LLM 模型，用於對話訊息產生',\n    extraParametersDescription:\n      '將在請求時附加到請求體中，如 max_tokens, temperature, top_p 等',\n  },\n  version: {\n    newVersionAvailable: '有新版本可用',\n    viewUpdateGuide: '查看更新方式',\n    noReleaseNotes: '暫無更新日誌',\n  },\n  account: {\n    settings: '帳戶設定',\n    setPassword: '設定密碼',\n    passwordSetSuccess: '密碼設定成功',\n    passwordStatus: '本地密碼',\n    passwordSet: '已設定',\n    passwordNotSet: '未設定',\n    passwordSetDescription: '您已設定本地密碼，可使用電子郵件密碼登入',\n    spaceStatus: 'Space 帳戶',\n    spaceBound: '已綁定',\n    spaceNotBound: '未綁定',\n    spaceBoundDescription: '已綁定 Space 帳戶，可使用官方模型 API 和雲服務',\n    bindSpace: '綁定 Space 帳戶',\n    bindSpaceDescription: '綁定後可使用官方模型 API 和雲服務',\n    bindSpaceButton: '綁定',\n    bindSpaceConfirmTitle: '確認綁定',\n    bindSpaceConfirmDescription: '您即將把本地實例綁定到 Space 帳戶',\n    bindSpaceWarning:\n      '綁定後，您的登入電子郵件將從 {{localEmail}} 更改為 Space 帳戶的電子郵件。',\n    bindSpaceSuccess: 'Space 帳戶綁定成功',\n    bindSpaceFailed: '綁定 Space 帳戶失敗',\n    bindSpaceInvalidState: '無效的綁定請求，請從帳戶設定重新發起',\n    setPasswordHint: '設定密碼後可使用電子郵件密碼登入',\n    spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配',\n  },\n  monitoring: {\n    title: '日誌監控',\n    description: '監控機器人活動、LLM調用和系統效能',\n    overview: '概覽',\n    totalMessages: '總訊息數',\n    llmCallsCount: 'LLM調用',\n    modelCallsCount: '模型調用',\n    successRate: '成功率',\n    activeSessions: '活躍會話',\n    last24Hours: '最近24小時',\n    filters: {\n      title: '篩選',\n      bot: '機器人',\n      pipeline: '流水線',\n      allBots: '全部機器人',\n      selectBot: '選擇機器人',\n      allPipelines: '全部流水線',\n      selectPipeline: '選擇流水線',\n      loading: '載入中...',\n      timeRange: '時間範圍',\n      customRange: '自訂範圍',\n      from: '從',\n      to: '到',\n      apply: '套用',\n      reset: '重置篩選',\n      lastHour: '最近1小時',\n      last6Hours: '最近6小時',\n      last24Hours: '最近24小時',\n      last7Days: '最近7天',\n      last30Days: '最近30天',\n    },\n    tabs: {\n      messages: '訊息記錄',\n      llmCalls: 'LLM調用',\n      embeddingCalls: 'Embedding調用',\n      modelCalls: '模型調用',\n      sessions: '會話分析',\n      errors: '錯誤日誌',\n    },\n    messageList: {\n      timestamp: '時間戳記',\n      bot: '機器人',\n      pipeline: '流水線',\n      message: '訊息',\n      sessionId: '會話ID',\n      status: '狀態',\n      actions: '操作',\n      viewDetails: '查看詳情',\n      copyId: '複製ID',\n      noMessages: '未找到訊息',\n      noMessagesDescription: '嘗試調整篩選條件或稍後查看',\n      loading: '載入訊息中...',\n      loadMore: '載入更多',\n      autoRefresh: '自動重新整理',\n      platform: '平台',\n      user: '使用者',\n      level: '級別',\n      runner: '執行器',\n      viewConversation: '顯示對話詳情',\n    },\n    llmCalls: {\n      model: '模型',\n      tokens: '代幣數',\n      duration: '持續時間',\n      cost: '成本',\n      noData: '未找到LLM調用記錄',\n      inputTokens: '輸入代幣',\n      outputTokens: '輸出代幣',\n      totalTokens: '總代幣數',\n    },\n    embeddingCalls: {\n      title: 'Embedding調用',\n      model: '模型',\n      tokens: '代幣數',\n      duration: '持續時間',\n      noData: '未找到Embedding調用記錄',\n      promptTokens: '輸入代幣',\n      totalTokens: '總代幣數',\n      inputCount: '輸入數量',\n      knowledgeBase: '知識庫',\n      queryText: '查詢文字',\n    },\n    modelCalls: {\n      title: '模型調用',\n      llmModel: '對話模型',\n      embeddingModel: '嵌入模型',\n      embeddingCall: '嵌入調用',\n      retrieveCall: '檢索調用',\n      noData: '未找到模型調用記錄',\n    },\n    sessions: {\n      sessionId: '會話ID',\n      messageCount: '訊息數',\n      duration: '持續時間',\n      lastActivity: '最後活動',\n      noSessions: '未找到會話',\n      startTime: '開始時間',\n    },\n    errors: {\n      errorType: '錯誤類型',\n      errorMessage: '錯誤訊息',\n      occurredAt: '發生時間',\n      noErrors: '未找到錯誤',\n      stackTrace: '堆疊追蹤',\n      title: '錯誤',\n    },\n    messageDetails: {\n      noData: '此查詢沒有LLM調用或錯誤記錄',\n    },\n    queryVariables: {\n      title: '查詢變數',\n    },\n    viewMonitoring: '查看日誌監控',\n    refreshData: '重新整理資料',\n    exportData: '匯出資料',\n    export: {\n      title: '匯出資料',\n      exporting: '匯出中...',\n      messages: '訊息記錄',\n      llmCalls: 'LLM 呼叫',\n      embeddingCalls: 'Embedding 呼叫',\n      errors: '錯誤日誌',\n      sessions: '會話記錄',\n    },\n  },\n  limitation: {\n    maxBotsReached:\n      '已達到機器人數量上限（{{max}}個）。請先刪除已有機器人後再建立新的。',\n    maxPipelinesReached:\n      '已達到流水線數量上限（{{max}}個）。請先刪除已有流水線後再建立新的。',\n    maxExtensionsReached:\n      '已達到擴充功能數量上限（{{max}}個）。請先刪除已有的 MCP 伺服器或外掛後再新增。',\n  },\n};\n\nexport default zhHant;\n"
  },
  {
    "path": "web/src/i18next.d.ts",
    "content": "import 'react-i18next';\n\ndeclare module 'react-i18next' {\n  interface CustomTypeOptions {\n    defaultNS: 'translation';\n    resources: {\n      translation: typeof import('./i18n/locales/zh-Hans').default;\n    };\n  }\n}\n"
  },
  {
    "path": "web/src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "web/src/styles/github-markdown.css",
    "content": "/* GitHub-style Markdown CSS */\n.markdown-body {\n  -ms-text-size-adjust: 100%;\n  -webkit-text-size-adjust: 100%;\n  color: var(--color-fg-default);\n  background-color: transparent;\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans',\n    Helvetica, Arial, sans-serif;\n  font-size: 16px;\n  line-height: 1.5;\n  word-wrap: break-word;\n}\n\n/* Hide light theme highlight.js styles in dark mode */\n.dark .markdown-body .hljs {\n  background: transparent !important;\n}\n\n/* Ensure code blocks have proper styling */\n.markdown-body pre code.hljs {\n  background: transparent;\n}\n\n.markdown-body .octicon {\n  display: inline-block;\n  fill: currentColor;\n  vertical-align: text-bottom;\n}\n\n.markdown-body h1,\n.markdown-body h2,\n.markdown-body h3,\n.markdown-body h4,\n.markdown-body h5,\n.markdown-body h6 {\n  margin-top: 24px;\n  margin-bottom: 16px;\n  font-weight: 600;\n  line-height: 1.25;\n}\n\n.markdown-body h1 {\n  font-size: 2em;\n  padding-bottom: 0.3em;\n  border-bottom: 1px solid var(--color-border-muted);\n}\n\n.markdown-body h2 {\n  font-size: 1.5em;\n  padding-bottom: 0.3em;\n  border-bottom: 1px solid var(--color-border-muted);\n}\n\n.markdown-body h3 {\n  font-size: 1.25em;\n}\n\n.markdown-body h4 {\n  font-size: 1em;\n}\n\n.markdown-body h5 {\n  font-size: 0.875em;\n}\n\n.markdown-body h6 {\n  font-size: 0.85em;\n  color: var(--color-fg-muted);\n}\n\n.markdown-body p {\n  margin-top: 0;\n  margin-bottom: 16px;\n}\n\n.markdown-body blockquote {\n  margin: 0 0 16px 0;\n  padding: 0 1em;\n  color: var(--color-fg-muted);\n  border-left: 0.25em solid var(--color-border-default);\n}\n\n.markdown-body ul,\n.markdown-body ol {\n  margin-top: 0;\n  margin-bottom: 16px;\n  padding-left: 2em;\n}\n\n.markdown-body ul {\n  list-style-type: disc;\n}\n\n.markdown-body ol {\n  list-style-type: decimal;\n}\n\n.markdown-body li {\n  margin-top: 0.25em;\n}\n\n.markdown-body li + li {\n  margin-top: 0.25em;\n}\n\n.markdown-body li > p {\n  margin-top: 16px;\n  margin-bottom: 16px;\n}\n\n.markdown-body li > p:first-child {\n  margin-top: 0;\n}\n\n.markdown-body li > p:last-child {\n  margin-bottom: 0;\n}\n\n/* Nested lists */\n.markdown-body ul ul,\n.markdown-body ul ol,\n.markdown-body ol ol,\n.markdown-body ol ul {\n  margin-top: 0.25em;\n  margin-bottom: 0;\n}\n\n.markdown-body ul ul {\n  list-style-type: circle;\n}\n\n.markdown-body ul ul ul {\n  list-style-type: square;\n}\n\n.markdown-body code {\n  padding: 0.2em 0.4em;\n  margin: 0;\n  font-size: 85%;\n  background-color: var(--color-neutral-muted);\n  border-radius: 6px;\n  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,\n    'Liberation Mono', monospace;\n}\n\n.markdown-body pre {\n  margin-top: 0;\n  margin-bottom: 16px;\n  padding: 16px;\n  overflow: auto;\n  font-size: 85%;\n  line-height: 1.45;\n  background-color: var(--color-canvas-subtle);\n  border-radius: 6px;\n}\n\n.markdown-body pre code {\n  display: inline;\n  padding: 0;\n  margin: 0;\n  overflow: visible;\n  line-height: inherit;\n  word-wrap: normal;\n  background-color: transparent;\n  border: 0;\n}\n\n.markdown-body a {\n  color: var(--color-accent-fg);\n  text-decoration: none;\n}\n\n.markdown-body a:hover {\n  text-decoration: underline;\n}\n\n.markdown-body table {\n  border-spacing: 0;\n  border-collapse: collapse;\n  display: block;\n  width: max-content;\n  max-width: 100%;\n  overflow: auto;\n  margin-top: 0;\n  margin-bottom: 16px;\n}\n\n.markdown-body table tr {\n  background-color: transparent;\n  border-top: 1px solid var(--color-border-muted);\n}\n\n.markdown-body table tr:nth-child(2n) {\n  background-color: var(--color-canvas-subtle);\n}\n\n.markdown-body table th,\n.markdown-body table td {\n  padding: 6px 13px;\n  border: 1px solid var(--color-border-default);\n}\n\n.markdown-body table th {\n  font-weight: 600;\n  background-color: var(--color-canvas-subtle);\n}\n\n.markdown-body img {\n  max-width: 50%;\n  box-sizing: content-box;\n  background-color: transparent;\n}\n\n.markdown-body hr {\n  height: 0.25em;\n  padding: 0;\n  margin: 24px 0;\n  background-color: var(--color-border-default);\n  border: 0;\n}\n\n/* Light theme colors */\n.markdown-body {\n  --color-fg-default: #1f2328;\n  --color-fg-muted: #656d76;\n  --color-canvas-subtle: #f6f8fa;\n  --color-border-default: #d0d7de;\n  --color-border-muted: #d8dee4;\n  --color-neutral-muted: rgba(175, 184, 193, 0.2);\n  --color-accent-fg: #0969da;\n}\n\n/* Dark theme colors */\n.dark .markdown-body {\n  --color-fg-default: #e6edf3;\n  --color-fg-muted: #8d96a0;\n  --color-canvas-subtle: #161b22;\n  --color-border-default: #30363d;\n  --color-border-muted: #21262d;\n  --color-neutral-muted: rgba(110, 118, 129, 0.4);\n  --color-accent-fg: #4493f8;\n}\n\n/* Code highlighting styles */\n.markdown-body .hljs {\n  display: block;\n  overflow-x: auto;\n  padding: 0;\n  background: transparent;\n  color: var(--color-fg-default);\n}\n\n/* Light theme syntax highlighting */\n.markdown-body .hljs-comment,\n.markdown-body .hljs-quote {\n  color: #6a737d;\n  font-style: italic;\n}\n\n.markdown-body .hljs-keyword,\n.markdown-body .hljs-selector-tag,\n.markdown-body .hljs-subst {\n  color: #d73a49;\n}\n\n.markdown-body .hljs-number,\n.markdown-body .hljs-literal,\n.markdown-body .hljs-variable,\n.markdown-body .hljs-template-variable,\n.markdown-body .hljs-tag .hljs-attr {\n  color: #005cc5;\n}\n\n.markdown-body .hljs-string,\n.markdown-body .hljs-doctag {\n  color: #032f62;\n}\n\n.markdown-body .hljs-title,\n.markdown-body .hljs-section,\n.markdown-body .hljs-selector-id {\n  color: #6f42c1;\n  font-weight: bold;\n}\n\n.markdown-body .hljs-type,\n.markdown-body .hljs-class .hljs-title {\n  color: #6f42c1;\n}\n\n.markdown-body .hljs-tag,\n.markdown-body .hljs-name,\n.markdown-body .hljs-attribute {\n  color: #22863a;\n  font-weight: normal;\n}\n\n.markdown-body .hljs-regexp,\n.markdown-body .hljs-link {\n  color: #032f62;\n}\n\n.markdown-body .hljs-symbol,\n.markdown-body .hljs-bullet {\n  color: #e36209;\n}\n\n.markdown-body .hljs-built_in,\n.markdown-body .hljs-builtin-name {\n  color: #005cc5;\n}\n\n.markdown-body .hljs-meta {\n  color: #6a737d;\n}\n\n.markdown-body .hljs-deletion {\n  background-color: #ffeef0;\n}\n\n.markdown-body .hljs-addition {\n  background-color: #e6ffed;\n}\n\n.markdown-body .hljs-emphasis {\n  font-style: italic;\n}\n\n.markdown-body .hljs-strong {\n  font-weight: bold;\n}\n\n/* Dark theme syntax highlighting */\n.dark .markdown-body .hljs-comment,\n.dark .markdown-body .hljs-quote {\n  color: #8b949e;\n}\n\n.dark .markdown-body .hljs-keyword,\n.dark .markdown-body .hljs-selector-tag,\n.dark .markdown-body .hljs-subst {\n  color: #ff7b72;\n}\n\n.dark .markdown-body .hljs-number,\n.dark .markdown-body .hljs-literal,\n.dark .markdown-body .hljs-variable,\n.dark .markdown-body .hljs-template-variable,\n.dark .markdown-body .hljs-tag .hljs-attr {\n  color: #79c0ff;\n}\n\n.dark .markdown-body .hljs-string,\n.dark .markdown-body .hljs-doctag {\n  color: #a5d6ff;\n}\n\n.dark .markdown-body .hljs-title,\n.dark .markdown-body .hljs-section,\n.dark .markdown-body .hljs-selector-id {\n  color: #d2a8ff;\n  font-weight: bold;\n}\n\n.dark .markdown-body .hljs-type,\n.dark .markdown-body .hljs-class .hljs-title {\n  color: #d2a8ff;\n}\n\n.dark .markdown-body .hljs-tag,\n.dark .markdown-body .hljs-name,\n.dark .markdown-body .hljs-attribute {\n  color: #7ee787;\n}\n\n.dark .markdown-body .hljs-regexp,\n.dark .markdown-body .hljs-link {\n  color: #a5d6ff;\n}\n\n.dark .markdown-body .hljs-symbol,\n.dark .markdown-body .hljs-bullet {\n  color: #ffa657;\n}\n\n.dark .markdown-body .hljs-built_in,\n.dark .markdown-body .hljs-builtin-name {\n  color: #79c0ff;\n}\n\n.dark .markdown-body .hljs-meta {\n  color: #8b949e;\n}\n\n.dark .markdown-body .hljs-deletion {\n  background-color: rgba(248, 81, 73, 0.15);\n}\n\n.dark .markdown-body .hljs-addition {\n  background-color: rgba(46, 160, 67, 0.15);\n}\n"
  },
  {
    "path": "web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2017\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react-jsx\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"@/*\": [\n        \"./src/*\"\n      ]\n    }\n  },\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \".next/types/**/*.ts\",\n    \".next/dev/types/**/*.ts\"\n  ],\n  \"exclude\": [\n    \"node_modules\"\n  ]\n}\n"
  },
  {
    "path": "web/web@0.1.0",
    "content": ""
  }
]