diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml deleted file mode 100644 index 190e00d9fe7ada..00000000000000 --- a/.github/workflows/api-tests.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Run Pytest - -on: - workflow_call: - -concurrency: - group: api-tests-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - test: - name: API Tests - runs-on: ubuntu-latest - defaults: - run: - shell: bash - strategy: - matrix: - python-version: - - "3.11" - - "3.12" - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Setup UV and Python - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - python-version: ${{ matrix.python-version }} - cache-dependency-glob: api/uv.lock - - - name: Check UV lockfile - run: uv lock --project api --check - - - name: Install dependencies - run: uv sync --project api --dev - - - name: Run dify config tests - run: uv run --project api dev/pytest/pytest_config_tests.py - - - name: Set up dotenvs - run: | - cp docker/.env.example docker/.env - cp docker/middleware.env.example docker/middleware.env - - - name: Expose Service Ports - run: sh .github/workflows/expose_service_ports.sh - - - name: Set up Sandbox - uses: hoverkraft-tech/compose-action@v2 - with: - compose-file: | - docker/docker-compose.middleware.yaml - services: | - db_postgres - redis - sandbox - ssrf_proxy - - - name: setup test config - run: | - cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env - - - name: Run API Tests - env: - STORAGE_TYPE: opendal - OPENDAL_SCHEME: fs - OPENDAL_FS_ROOT: /tmp/dify-storage - run: | - uv run --project api pytest \ - --timeout "${PYTEST_TIMEOUT:-180}" \ - api/tests/integration_tests/workflow \ - api/tests/integration_tests/tools \ - api/tests/test_containers_integration_tests \ - api/tests/unit_tests - - - name: Coverage Summary - run: | - set -x - # Extract coverage percentage and create a summary - TOTAL_COVERAGE=$(python -c 'import json; print(json.load(open("coverage.json"))["totals"]["percent_covered_display"])') - - # Create a detailed coverage summary - echo "### Test Coverage Summary :test_tube:" >> $GITHUB_STEP_SUMMARY - echo "Total Coverage: ${TOTAL_COVERAGE}%" >> $GITHUB_STEP_SUMMARY - { - echo "" - echo "
File-level coverage (click to expand)" - echo "" - echo '```' - uv run --project api coverage report -m - echo '```' - echo "
" - } >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml deleted file mode 100644 index 4a8c61e7d2cace..00000000000000 --- a/.github/workflows/autofix.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: autofix.ci -on: - pull_request: - branches: ["main"] - push: - branches: ["main"] -permissions: - contents: read - -jobs: - autofix: - if: github.repository == 'langgenius/dify' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Check Docker Compose inputs - id: docker-compose-changes - uses: tj-actions/changed-files@v47 - with: - files: | - docker/generate_docker_compose - docker/.env.example - docker/docker-compose-template.yaml - docker/docker-compose.yaml - - uses: actions/setup-python@v6 - with: - python-version: "3.11" - - - uses: astral-sh/setup-uv@v7 - - - name: Generate Docker Compose - if: steps.docker-compose-changes.outputs.any_changed == 'true' - run: | - cd docker - ./generate_docker_compose - - - run: | - cd api - uv sync --dev - # fmt first to avoid line too long - uv run ruff format .. - # Fix lint errors - uv run ruff check --fix . - # Format code - uv run ruff format .. - - - name: count migration progress - run: | - cd api - ./cnt_base.sh - - - name: ast-grep - run: | - # ast-grep exits 1 if no matches are found; allow idempotent runs. - uvx --from ast-grep-cli ast-grep --pattern 'db.session.query($WHATEVER).filter($HERE)' --rewrite 'db.session.query($WHATEVER).where($HERE)' -l py --update-all || true - uvx --from ast-grep-cli ast-grep --pattern 'session.query($WHATEVER).filter($HERE)' --rewrite 'session.query($WHATEVER).where($HERE)' -l py --update-all || true - uvx --from ast-grep-cli ast-grep -p '$A = db.Column($$$B)' -r '$A = mapped_column($$$B)' -l py --update-all || true - uvx --from ast-grep-cli ast-grep -p '$A : $T = db.Column($$$B)' -r '$A : $T = mapped_column($$$B)' -l py --update-all || true - # Convert Optional[T] to T | None (ignoring quoted types) - cat > /tmp/optional-rule.yml << 'EOF' - id: convert-optional-to-union - language: python - rule: - kind: generic_type - all: - - has: - kind: identifier - pattern: Optional - - has: - kind: type_parameter - has: - kind: type - pattern: $T - fix: $T | None - EOF - uvx --from ast-grep-cli ast-grep scan . --inline-rules "$(cat /tmp/optional-rule.yml)" --update-all - # Fix forward references that were incorrectly converted (Python doesn't support "Type" | None syntax) - find . -name "*.py" -type f -exec sed -i.bak -E 's/"([^"]+)" \| None/Optional["\1"]/g; s/'"'"'([^'"'"']+)'"'"' \| None/Optional['"'"'\1'"'"']/g' {} \; - find . -name "*.py.bak" -type f -delete - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - package_json_file: web/package.json - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: ./web/pnpm-lock.yaml - - - name: Install web dependencies - run: | - cd web - pnpm install --frozen-lockfile - - - name: ESLint autofix - run: | - cd web - pnpm lint:fix || true - - # mdformat breaks YAML front matter in markdown files. Add --exclude for directories containing YAML front matter. - - name: mdformat - run: | - uvx --python 3.13 mdformat . --exclude ".agents/skills/**" - - - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml deleted file mode 100644 index 704d89619293b7..00000000000000 --- a/.github/workflows/build-push.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Build and Push API & Web - -on: - push: - branches: - - "main" - - "deploy/**" - - "build/**" - - "release/e-*" - - "hotfix/**" - tags: - - "*" - -concurrency: - group: build-push-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -env: - DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - DIFY_WEB_IMAGE_NAME: ${{ vars.DIFY_WEB_IMAGE_NAME || 'langgenius/dify-web' }} - DIFY_API_IMAGE_NAME: ${{ vars.DIFY_API_IMAGE_NAME || 'langgenius/dify-api' }} - -jobs: - build: - runs-on: ${{ matrix.platform == 'linux/arm64' && 'arm64_runner' || 'ubuntu-latest' }} - if: github.repository == 'langgenius/dify' - strategy: - matrix: - include: - - service_name: "build-api-amd64" - image_name_env: "DIFY_API_IMAGE_NAME" - context: "api" - platform: linux/amd64 - - service_name: "build-api-arm64" - image_name_env: "DIFY_API_IMAGE_NAME" - context: "api" - platform: linux/arm64 - - service_name: "build-web-amd64" - image_name_env: "DIFY_WEB_IMAGE_NAME" - context: "web" - platform: linux/amd64 - - service_name: "build-web-arm64" - image_name_env: "DIFY_WEB_IMAGE_NAME" - context: "web" - platform: linux/arm64 - - steps: - - name: Prepare - run: | - platform=${{ matrix.platform }} - echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ env.DOCKERHUB_USER }} - password: ${{ env.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env[matrix.image_name_env] }} - - - name: Build Docker image - id: build - uses: docker/build-push-action@v6 - with: - context: "{{defaultContext}}:${{ matrix.context }}" - platforms: ${{ matrix.platform }} - build-args: COMMIT_SHA=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} - labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env[matrix.image_name_env] }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha,scope=${{ matrix.service_name }} - cache-to: type=gha,mode=max,scope=${{ matrix.service_name }} - - - name: Export digest - env: - DIGEST: ${{ steps.build.outputs.digest }} - run: | - mkdir -p /tmp/digests - sanitized_digest=${DIGEST#sha256:} - touch "/tmp/digests/${sanitized_digest}" - - - name: Upload digest - uses: actions/upload-artifact@v6 - with: - name: digests-${{ matrix.context }}-${{ env.PLATFORM_PAIR }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - create-manifest: - needs: build - runs-on: ubuntu-latest - if: github.repository == 'langgenius/dify' - strategy: - matrix: - include: - - service_name: "merge-api-images" - image_name_env: "DIFY_API_IMAGE_NAME" - context: "api" - - service_name: "merge-web-images" - image_name_env: "DIFY_WEB_IMAGE_NAME" - context: "web" - steps: - - name: Download digests - uses: actions/download-artifact@v7 - with: - path: /tmp/digests - pattern: digests-${{ matrix.context }}-* - merge-multiple: true - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ env.DOCKERHUB_USER }} - password: ${{ env.DOCKERHUB_TOKEN }} - - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env[matrix.image_name_env] }} - tags: | - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-') }} - type=ref,event=branch - type=sha,enable=true,priority=100,prefix=,suffix=,format=long - type=raw,value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/') }} - - - name: Create manifest list and push - working-directory: /tmp/digests - env: - IMAGE_NAME: ${{ env[matrix.image_name_env] }} - run: | - docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf "$IMAGE_NAME@sha256:%s " *) - - - name: Inspect image - env: - IMAGE_NAME: ${{ env[matrix.image_name_env] }} - IMAGE_VERSION: ${{ steps.meta.outputs.version }} - run: | - docker buildx imagetools inspect "$IMAGE_NAME:$IMAGE_VERSION" diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml deleted file mode 100644 index e20cf9850bf61d..00000000000000 --- a/.github/workflows/db-migration-test.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: DB Migration Test - -on: - workflow_call: - -concurrency: - group: db-migration-test-${{ github.ref }} - cancel-in-progress: true - -jobs: - db-migration-test-postgres: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Setup UV and Python - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - python-version: "3.12" - cache-dependency-glob: api/uv.lock - - - name: Install dependencies - run: uv sync --project api - - name: Ensure Offline migration are supported - run: | - # upgrade - uv run --directory api flask db upgrade 'base:head' --sql - # downgrade - uv run --directory api flask db downgrade 'head:base' --sql - - - name: Prepare middleware env - run: | - cd docker - cp middleware.env.example middleware.env - - - name: Set up Middlewares - uses: hoverkraft-tech/compose-action@v2.0.2 - with: - compose-file: | - docker/docker-compose.middleware.yaml - services: | - db_postgres - redis - - - name: Prepare configs - run: | - cd api - cp .env.example .env - - - name: Run DB Migration - env: - DEBUG: true - run: uv run --directory api flask upgrade-db - - db-migration-test-mysql: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Setup UV and Python - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - python-version: "3.12" - cache-dependency-glob: api/uv.lock - - - name: Install dependencies - run: uv sync --project api - - name: Ensure Offline migration are supported - run: | - # upgrade - uv run --directory api flask db upgrade 'base:head' --sql - # downgrade - uv run --directory api flask db downgrade 'head:base' --sql - - - name: Prepare middleware env for MySQL - run: | - cd docker - cp middleware.env.example middleware.env - sed -i 's/DB_TYPE=postgresql/DB_TYPE=mysql/' middleware.env - sed -i 's/DB_HOST=db_postgres/DB_HOST=db_mysql/' middleware.env - sed -i 's/DB_PORT=5432/DB_PORT=3306/' middleware.env - sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env - - - name: Set up Middlewares - uses: hoverkraft-tech/compose-action@v2.0.2 - with: - compose-file: | - docker/docker-compose.middleware.yaml - services: | - db_mysql - redis - - - name: Prepare configs for MySQL - run: | - cd api - cp .env.example .env - sed -i 's/DB_TYPE=postgresql/DB_TYPE=mysql/' .env - sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env - sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env - - - name: Run DB Migration - env: - DEBUG: true - run: uv run --directory api flask upgrade-db diff --git a/.github/workflows/deploy-agent-dev.yml b/.github/workflows/deploy-agent-dev.yml deleted file mode 100644 index dd759f7ba5c0e9..00000000000000 --- a/.github/workflows/deploy-agent-dev.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Deploy Agent Dev - -permissions: - contents: read - -on: - workflow_run: - workflows: ["Build and Push API & Web"] - branches: - - "deploy/agent-dev" - types: - - completed - -jobs: - deploy: - runs-on: ubuntu-latest - if: | - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'deploy/agent-dev' - steps: - - name: Deploy to server - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.AGENT_DEV_SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - script: | - ${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }} diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml deleted file mode 100644 index 38fa0b9a7f1132..00000000000000 --- a/.github/workflows/deploy-dev.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Deploy Dev - -on: - workflow_run: - workflows: ["Build and Push API & Web"] - branches: - - "deploy/dev" - types: - - completed - -jobs: - deploy: - runs-on: ubuntu-latest - if: | - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'deploy/dev' - steps: - - name: Deploy to server - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - script: | - ${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }} diff --git a/.github/workflows/deploy-enterprise.yml b/.github/workflows/deploy-enterprise.yml deleted file mode 100644 index 9cff3a3482c00e..00000000000000 --- a/.github/workflows/deploy-enterprise.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Deploy Enterprise - -permissions: - contents: read - -on: - workflow_run: - workflows: ["Build and Push API & Web"] - branches: - - "deploy/enterprise" - types: - - completed - -jobs: - deploy: - runs-on: ubuntu-latest - if: | - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'deploy/enterprise' - - steps: - - name: trigger deployments - env: - DEV_ENV_ADDRS: ${{ vars.DEV_ENV_ADDRS }} - DEPLOY_SECRET: ${{ secrets.DEPLOY_SECRET }} - run: | - IFS=',' read -ra ENDPOINTS <<< "${DEV_ENV_ADDRS:-}" - BODY='{"project":"dify-api","tag":"deploy-enterprise"}' - - for ENDPOINT in "${ENDPOINTS[@]}"; do - ENDPOINT="$(echo "$ENDPOINT" | xargs)" - [ -z "$ENDPOINT" ] && continue - - API_SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$DEPLOY_SECRET" | awk '{print "sha256="$2}') - - curl -sSf -X POST \ - -H "Content-Type: application/json" \ - -H "X-Hub-Signature-256: $API_SIGNATURE" \ - -d "$BODY" \ - "$ENDPOINT" - done diff --git a/.github/workflows/deploy-hitl.yml b/.github/workflows/deploy-hitl.yml deleted file mode 100644 index 7d5f0a22e7fc90..00000000000000 --- a/.github/workflows/deploy-hitl.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Deploy HITL - -on: - workflow_run: - workflows: ["Build and Push API & Web"] - branches: - - "feat/hitl-frontend" - - "feat/hitl-backend" - types: - - completed - -jobs: - deploy: - runs-on: ubuntu-latest - if: | - github.event.workflow_run.conclusion == 'success' && - ( - github.event.workflow_run.head_branch == 'feat/hitl-frontend' || - github.event.workflow_run.head_branch == 'feat/hitl-backend' - ) - steps: - - name: Deploy to server - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.HITL_SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - script: | - ${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }} diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml deleted file mode 100644 index cadc1b55078ad6..00000000000000 --- a/.github/workflows/docker-build.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Build docker image - -on: - pull_request: - branches: - - "main" - paths: - - api/Dockerfile - - web/Dockerfile - -concurrency: - group: docker-build-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build-docker: - runs-on: ubuntu-latest - strategy: - matrix: - include: - - service_name: "api-amd64" - platform: linux/amd64 - context: "api" - - service_name: "api-arm64" - platform: linux/arm64 - context: "api" - - service_name: "web-amd64" - platform: linux/amd64 - context: "web" - - service_name: "web-arm64" - platform: linux/arm64 - context: "web" - steps: - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build Docker Image - uses: docker/build-push-action@v6 - with: - push: false - context: "{{defaultContext}}:${{ matrix.context }}" - file: "${{ matrix.file }}" - platforms: ${{ matrix.platform }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/expose_service_ports.sh b/.github/workflows/expose_service_ports.sh deleted file mode 100755 index e7d5f6028875a4..00000000000000 --- a/.github/workflows/expose_service_ports.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -yq eval '.services.weaviate.ports += ["8080:8080"]' -i docker/docker-compose.yaml -yq eval '.services.weaviate.ports += ["50051:50051"]' -i docker/docker-compose.yaml -yq eval '.services.qdrant.ports += ["6333:6333"]' -i docker/docker-compose.yaml -yq eval '.services.chroma.ports += ["8000:8000"]' -i docker/docker-compose.yaml -yq eval '.services["milvus-standalone"].ports += ["19530:19530"]' -i docker/docker-compose.yaml -yq eval '.services.pgvector.ports += ["5433:5432"]' -i docker/docker-compose.yaml -yq eval '.services["pgvecto-rs"].ports += ["5431:5432"]' -i docker/docker-compose.yaml -yq eval '.services["elasticsearch"].ports += ["9200:9200"]' -i docker/docker-compose.yaml -yq eval '.services.couchbase-server.ports += ["8091-8096:8091-8096"]' -i docker/docker-compose.yaml -yq eval '.services.couchbase-server.ports += ["11210:11210"]' -i docker/docker-compose.yaml -yq eval '.services.tidb.ports += ["4000:4000"]' -i docker/tidb/docker-compose.yaml -yq eval '.services.oceanbase.ports += ["2881:2881"]' -i docker/docker-compose.yaml -yq eval '.services.opengauss.ports += ["6600:6600"]' -i docker/docker-compose.yaml - -echo "Ports exposed for sandbox, weaviate (HTTP 8080, gRPC 50051), tidb, qdrant, chroma, milvus, pgvector, pgvecto-rs, elasticsearch, couchbase, opengauss" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index 06782b53c17298..00000000000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: "Pull Request Labeler" -on: - pull_request_target: - -jobs: - labeler: - permissions: - contents: read - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@v6 - with: - sync-labels: true diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml deleted file mode 100644 index d6653de950f2ff..00000000000000 --- a/.github/workflows/main-ci.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Main CI Pipeline - -on: - pull_request: - branches: ["main"] - push: - branches: ["main"] - -permissions: - contents: write - pull-requests: write - checks: write - statuses: write - -concurrency: - group: main-ci-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - # Check which paths were changed to determine which tests to run - check-changes: - name: Check Changed Files - runs-on: ubuntu-latest - outputs: - api-changed: ${{ steps.changes.outputs.api }} - web-changed: ${{ steps.changes.outputs.web }} - vdb-changed: ${{ steps.changes.outputs.vdb }} - migration-changed: ${{ steps.changes.outputs.migration }} - steps: - - uses: actions/checkout@v6 - - uses: dorny/paths-filter@v3 - id: changes - with: - filters: | - api: - - 'api/**' - - 'docker/**' - - '.github/workflows/api-tests.yml' - web: - - 'web/**' - - '.github/workflows/web-tests.yml' - vdb: - - 'api/core/rag/datasource/**' - - 'docker/**' - - '.github/workflows/vdb-tests.yml' - - 'api/uv.lock' - - 'api/pyproject.toml' - migration: - - 'api/migrations/**' - - '.github/workflows/db-migration-test.yml' - - # Run tests in parallel - api-tests: - name: API Tests - needs: check-changes - if: needs.check-changes.outputs.api-changed == 'true' - uses: ./.github/workflows/api-tests.yml - - web-tests: - name: Web Tests - needs: check-changes - if: needs.check-changes.outputs.web-changed == 'true' - uses: ./.github/workflows/web-tests.yml - - style-check: - name: Style Check - uses: ./.github/workflows/style.yml - - vdb-tests: - name: VDB Tests - needs: check-changes - if: needs.check-changes.outputs.vdb-changed == 'true' - uses: ./.github/workflows/vdb-tests.yml - - db-migration-test: - name: DB Migration Test - needs: check-changes - if: needs.check-changes.outputs.migration-changed == 'true' - uses: ./.github/workflows/db-migration-test.yml diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml deleted file mode 100644 index b15c26a096be30..00000000000000 --- a/.github/workflows/semantic-pull-request.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Semantic Pull Request - -on: - pull_request: - types: - - opened - - edited - - reopened - - synchronize - -jobs: - lint: - name: Validate PR title - permissions: - pull-requests: read - runs-on: ubuntu-latest - steps: - - name: Check title - uses: amannn/action-semantic-pull-request@v6.1.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index b6df1d7e9324e1..00000000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,30 +0,0 @@ -# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. -# -# You can adjust the behavior by modifying this file. -# For more information, see: -# https://github.com/actions/stale -name: Mark stale issues and pull requests - -on: - schedule: - - cron: '0 3 * * *' - -jobs: - stale: - - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - - steps: - - uses: actions/stale@v10 - with: - days-before-issue-stale: 15 - days-before-issue-close: 3 - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: "Close due to it's no longer active, if you have any questions, you can reopen it." - stale-pr-message: "Close due to it's no longer active, if you have any questions, you can reopen it." - stale-issue-label: 'no-issue-activity' - stale-pr-label: 'no-pr-activity' - any-of-labels: 'duplicate,question,invalid,wontfix,no-issue-activity,no-pr-activity,enhancement,cant-reproduce,help-wanted' diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml deleted file mode 100644 index fdc05d1d65878b..00000000000000 --- a/.github/workflows/style.yml +++ /dev/null @@ -1,176 +0,0 @@ -name: Style check - -on: - workflow_call: - -concurrency: - group: style-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - checks: write - statuses: write - contents: read - -jobs: - python-style: - name: Python Style - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Check changed files - id: changed-files - uses: tj-actions/changed-files@v47 - with: - files: | - api/** - .github/workflows/style.yml - - - name: Setup UV and Python - if: steps.changed-files.outputs.any_changed == 'true' - uses: astral-sh/setup-uv@v7 - with: - enable-cache: false - python-version: "3.12" - cache-dependency-glob: api/uv.lock - - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' - run: uv sync --project api --dev - - - name: Run Import Linter - if: steps.changed-files.outputs.any_changed == 'true' - run: uv run --directory api --dev lint-imports - - - name: Run Basedpyright Checks - if: steps.changed-files.outputs.any_changed == 'true' - run: dev/basedpyright-check - - - name: Run Mypy Type Checks - if: steps.changed-files.outputs.any_changed == 'true' - run: uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped . - - - name: Dotenv check - if: steps.changed-files.outputs.any_changed == 'true' - run: uv run --project api dotenv-linter ./api/.env.example ./web/.env.example - - web-style: - name: Web Style - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./web - permissions: - checks: write - pull-requests: read - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Check changed files - id: changed-files - uses: tj-actions/changed-files@v47 - with: - files: | - web/** - .github/workflows/style.yml - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - package_json_file: web/package.json - run_install: false - - - name: Setup NodeJS - uses: actions/setup-node@v6 - if: steps.changed-files.outputs.any_changed == 'true' - with: - node-version: 24 - cache: pnpm - cache-dependency-path: ./web/pnpm-lock.yaml - - - name: Web dependencies - if: steps.changed-files.outputs.any_changed == 'true' - working-directory: ./web - run: pnpm install --frozen-lockfile - - - name: Web style check - if: steps.changed-files.outputs.any_changed == 'true' - working-directory: ./web - run: | - pnpm run lint:ci - # pnpm run lint:report - # continue-on-error: true - - # - name: Annotate Code - # if: steps.changed-files.outputs.any_changed == 'true' && github.event_name == 'pull_request' - # uses: DerLev/eslint-annotations@51347b3a0abfb503fc8734d5ae31c4b151297fae - # with: - # eslint-report: web/eslint_report.json - # github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Web tsslint - if: steps.changed-files.outputs.any_changed == 'true' - working-directory: ./web - run: pnpm run lint:tss - - - name: Web type check - if: steps.changed-files.outputs.any_changed == 'true' - working-directory: ./web - run: pnpm run type-check - - - name: Web dead code check - if: steps.changed-files.outputs.any_changed == 'true' - working-directory: ./web - run: pnpm run knip - - superlinter: - name: SuperLinter - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Check changed files - id: changed-files - uses: tj-actions/changed-files@v47 - with: - files: | - **.sh - **.yaml - **.yml - **Dockerfile - dev/** - .editorconfig - - - name: Super-linter - uses: super-linter/super-linter/slim@v8 - if: steps.changed-files.outputs.any_changed == 'true' - env: - BASH_SEVERITY: warning - DEFAULT_BRANCH: origin/main - EDITORCONFIG_FILE_NAME: editorconfig-checker.json - FILTER_REGEX_INCLUDE: pnpm-lock.yaml - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - IGNORE_GENERATED_FILES: true - IGNORE_GITIGNORED_FILES: true - VALIDATE_BASH: true - VALIDATE_BASH_EXEC: true - # FIXME: temporarily disabled until api-docker.yaml's run script is fixed for shellcheck - # VALIDATE_GITHUB_ACTIONS: true - VALIDATE_DOCKERFILE_HADOLINT: true - VALIDATE_EDITORCONFIG: true - VALIDATE_XML: true - VALIDATE_YAML: true diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml deleted file mode 100644 index ec392cb3b2efda..00000000000000 --- a/.github/workflows/tool-test-sdks.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: Run Unit Test For SDKs - -on: - pull_request: - branches: - - main - paths: - - sdks/** - -concurrency: - group: sdk-tests-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - name: unit test for Node.js SDK - runs-on: ubuntu-latest - - defaults: - run: - working-directory: sdks/nodejs-client - - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Use Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: '' - cache-dependency-path: 'pnpm-lock.yaml' - - - name: Install Dependencies - run: pnpm install --frozen-lockfile - - - name: Test - run: pnpm test diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml deleted file mode 100644 index 5d9440ff3544a8..00000000000000 --- a/.github/workflows/translate-i18n-claude.yml +++ /dev/null @@ -1,440 +0,0 @@ -name: Translate i18n Files with Claude Code - -# Note: claude-code-action doesn't support push events directly. -# Push events are handled by trigger-i18n-sync.yml which sends repository_dispatch. -# See: https://github.com/langgenius/dify/issues/30743 - -on: - repository_dispatch: - types: [i18n-sync] - workflow_dispatch: - inputs: - files: - description: 'Specific files to translate (space-separated, e.g., "app common"). Leave empty for all files.' - required: false - type: string - languages: - description: 'Specific languages to translate (space-separated, e.g., "zh-Hans ja-JP"). Leave empty for all supported languages.' - required: false - type: string - mode: - description: 'Sync mode: incremental (only changes) or full (re-check all keys)' - required: false - default: 'incremental' - type: choice - options: - - incremental - - full - -permissions: - contents: write - pull-requests: write - -jobs: - translate: - if: github.repository == 'langgenius/dify' - runs-on: ubuntu-latest - timeout-minutes: 60 - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure Git - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - package_json_file: web/package.json - run_install: false - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: ./web/pnpm-lock.yaml - - - name: Detect changed files and generate diff - id: detect_changes - run: | - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - # Manual trigger - if [ -n "${{ github.event.inputs.files }}" ]; then - echo "CHANGED_FILES=${{ github.event.inputs.files }}" >> $GITHUB_OUTPUT - else - # Get all JSON files in en-US directory - files=$(ls web/i18n/en-US/*.json 2>/dev/null | xargs -n1 basename | sed 's/.json$//' | tr '\n' ' ') - echo "CHANGED_FILES=$files" >> $GITHUB_OUTPUT - fi - echo "TARGET_LANGS=${{ github.event.inputs.languages }}" >> $GITHUB_OUTPUT - echo "SYNC_MODE=${{ github.event.inputs.mode || 'incremental' }}" >> $GITHUB_OUTPUT - - # For manual trigger with incremental mode, get diff from last commit - # For full mode, we'll do a complete check anyway - if [ "${{ github.event.inputs.mode }}" == "full" ]; then - echo "Full mode: will check all keys" > /tmp/i18n-diff.txt - echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT - else - git diff HEAD~1..HEAD -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || echo "" > /tmp/i18n-diff.txt - if [ -s /tmp/i18n-diff.txt ]; then - echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT - else - echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT - fi - fi - elif [ "${{ github.event_name }}" == "repository_dispatch" ]; then - # Triggered by push via trigger-i18n-sync.yml workflow - # Validate required payload fields - if [ -z "${{ github.event.client_payload.changed_files }}" ]; then - echo "Error: repository_dispatch payload missing required 'changed_files' field" >&2 - exit 1 - fi - echo "CHANGED_FILES=${{ github.event.client_payload.changed_files }}" >> $GITHUB_OUTPUT - echo "TARGET_LANGS=" >> $GITHUB_OUTPUT - echo "SYNC_MODE=${{ github.event.client_payload.sync_mode || 'incremental' }}" >> $GITHUB_OUTPUT - - # Decode the base64-encoded diff from the trigger workflow - if [ -n "${{ github.event.client_payload.diff_base64 }}" ]; then - if ! echo "${{ github.event.client_payload.diff_base64 }}" | base64 -d > /tmp/i18n-diff.txt 2>&1; then - echo "Warning: Failed to decode base64 diff payload" >&2 - echo "" > /tmp/i18n-diff.txt - echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT - elif [ -s /tmp/i18n-diff.txt ]; then - echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT - else - echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT - fi - else - echo "" > /tmp/i18n-diff.txt - echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT - fi - else - echo "Unsupported event type: ${{ github.event_name }}" - exit 1 - fi - - # Truncate diff if too large (keep first 50KB) - if [ -f /tmp/i18n-diff.txt ]; then - head -c 50000 /tmp/i18n-diff.txt > /tmp/i18n-diff-truncated.txt - mv /tmp/i18n-diff-truncated.txt /tmp/i18n-diff.txt - fi - - echo "Detected files: $(cat $GITHUB_OUTPUT | grep CHANGED_FILES || echo 'none')" - - - name: Run Claude Code for Translation Sync - if: steps.detect_changes.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} - # Allow github-actions bot to trigger this workflow via repository_dispatch - # See: https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - allowed_bots: 'github-actions[bot]' - prompt: | - You are a professional i18n synchronization engineer for the Dify project. - Your task is to keep all language translations in sync with the English source (en-US). - - ## CRITICAL TOOL RESTRICTIONS - - Use **Read** tool to read files (NOT cat or bash) - - Use **Edit** tool to modify JSON files (NOT node, jq, or bash scripts) - - Use **Bash** ONLY for: git commands, gh commands, pnpm commands - - Run bash commands ONE BY ONE, never combine with && or || - - NEVER use `$()` command substitution - it's not supported. Split into separate commands instead. - - ## WORKING DIRECTORY & ABSOLUTE PATHS - Claude Code sandbox working directory may vary. Always use absolute paths: - - For pnpm: `pnpm --dir ${{ github.workspace }}/web ` - - For git: `git -C ${{ github.workspace }} ` - - For gh: `gh --repo ${{ github.repository }} ` - - For file paths: `${{ github.workspace }}/web/i18n/` - - ## EFFICIENCY RULES - - **ONE Edit per language file** - batch all key additions into a single Edit - - Insert new keys at the beginning of JSON (after `{`), lint:fix will sort them - - Translate ALL keys for a language mentally first, then do ONE Edit - - ## Context - - Changed/target files: ${{ steps.detect_changes.outputs.CHANGED_FILES }} - - Target languages (empty means all supported): ${{ steps.detect_changes.outputs.TARGET_LANGS }} - - Sync mode: ${{ steps.detect_changes.outputs.SYNC_MODE }} - - Translation files are located in: ${{ github.workspace }}/web/i18n/{locale}/{filename}.json - - Language configuration is in: ${{ github.workspace }}/web/i18n-config/languages.ts - - Git diff is available: ${{ steps.detect_changes.outputs.DIFF_AVAILABLE }} - - ## CRITICAL DESIGN: Verify First, Then Sync - - You MUST follow this three-phase approach: - - ═══════════════════════════════════════════════════════════════ - ║ PHASE 1: VERIFY - Analyze and Generate Change Report ║ - ═══════════════════════════════════════════════════════════════ - - ### Step 1.1: Analyze Git Diff (for incremental mode) - Use the Read tool to read `/tmp/i18n-diff.txt` to see the git diff. - - Parse the diff to categorize changes: - - Lines with `+` (not `+++`): Added or modified values - - Lines with `-` (not `---`): Removed or old values - - Identify specific keys for each category: - * ADD: Keys that appear only in `+` lines (new keys) - * UPDATE: Keys that appear in both `-` and `+` lines (value changed) - * DELETE: Keys that appear only in `-` lines (removed keys) - - ### Step 1.2: Read Language Configuration - Use the Read tool to read `${{ github.workspace }}/web/i18n-config/languages.ts`. - Extract all languages with `supported: true`. - - ### Step 1.3: Run i18n:check for Each Language - ```bash - pnpm --dir ${{ github.workspace }}/web install --frozen-lockfile - ``` - ```bash - pnpm --dir ${{ github.workspace }}/web run i18n:check - ``` - - This will report: - - Missing keys (need to ADD) - - Extra keys (need to DELETE) - - ### Step 1.4: Generate Change Report - - Create a structured report identifying: - ``` - ╔══════════════════════════════════════════════════════════════╗ - ║ I18N SYNC CHANGE REPORT ║ - ╠══════════════════════════════════════════════════════════════╣ - ║ Files to process: [list] ║ - ║ Languages to sync: [list] ║ - ╠══════════════════════════════════════════════════════════════╣ - ║ ADD (New Keys): ║ - ║ - [filename].[key]: "English value" ║ - ║ ... ║ - ╠══════════════════════════════════════════════════════════════╣ - ║ UPDATE (Modified Keys - MUST re-translate): ║ - ║ - [filename].[key]: "Old value" → "New value" ║ - ║ ... ║ - ╠══════════════════════════════════════════════════════════════╣ - ║ DELETE (Extra Keys): ║ - ║ - [language]/[filename].[key] ║ - ║ ... ║ - ╚══════════════════════════════════════════════════════════════╝ - ``` - - **IMPORTANT**: For UPDATE detection, compare git diff to find keys where - the English value changed. These MUST be re-translated even if target - language already has a translation (it's now stale!). - - ═══════════════════════════════════════════════════════════════ - ║ PHASE 2: SYNC - Execute Changes Based on Report ║ - ═══════════════════════════════════════════════════════════════ - - ### Step 2.1: Process ADD Operations (BATCH per language file) - - **CRITICAL WORKFLOW for efficiency:** - 1. First, translate ALL new keys for ALL languages mentally - 2. Then, for EACH language file, do ONE Edit operation: - - Read the file once - - Insert ALL new keys at the beginning (right after the opening `{`) - - Don't worry about alphabetical order - lint:fix will sort them later - - Example Edit (adding 3 keys to zh-Hans/app.json): - ``` - old_string: '{\n "accessControl"' - new_string: '{\n "newKey1": "translation1",\n "newKey2": "translation2",\n "newKey3": "translation3",\n "accessControl"' - ``` - - **IMPORTANT**: - - ONE Edit per language file (not one Edit per key!) - - Always use the Edit tool. NEVER use bash scripts, node, or jq. - - ### Step 2.2: Process UPDATE Operations - - **IMPORTANT: Special handling for zh-Hans and ja-JP** - If zh-Hans or ja-JP files were ALSO modified in the same push: - - Run: `git -C ${{ github.workspace }} diff HEAD~1 --name-only` and check for zh-Hans or ja-JP files - - If found, it means someone manually translated them. Apply these rules: - - 1. **Missing keys**: Still ADD them (completeness required) - 2. **Existing translations**: Compare with the NEW English value: - - If translation is **completely wrong** or **unrelated** → Update it - - If translation is **roughly correct** (captures the meaning) → Keep it, respect manual work - - When in doubt, **keep the manual translation** - - Example: - - English changed: "Save" → "Save Changes" - - Manual translation: "保存更改" → Keep it (correct meaning) - - Manual translation: "删除" → Update it (completely wrong) - - For other languages: - Use Edit tool to replace the old value with the new translation. - You can batch multiple updates in one Edit if they are adjacent. - - ### Step 2.3: Process DELETE Operations - For extra keys reported by i18n:check: - - Run: `pnpm --dir ${{ github.workspace }}/web run i18n:check --auto-remove` - - Or manually remove from target language JSON files - - ## Translation Guidelines - - - PRESERVE all placeholders exactly as-is: - - `{{variable}}` - Mustache interpolation - - `${variable}` - Template literal - - `content` - HTML tags - - `_one`, `_other` - Pluralization suffixes (these are KEY suffixes, not values) - - **CRITICAL: Variable names and tag names MUST stay in English - NEVER translate them** - - ✅ CORRECT examples: - - English: "{{count}} items" → Japanese: "{{count}} 個のアイテム" - - English: "{{name}} updated" → Korean: "{{name}} 업데이트됨" - - English: "{{email}}" → Chinese: "{{email}}" - - English: "Marketplace" → Japanese: "マーケットプレイス" - - ❌ WRONG examples (NEVER do this - will break the application): - - "{{count}}" → "{{カウント}}" ❌ (variable name translated to Japanese) - - "{{name}}" → "{{이름}}" ❌ (variable name translated to Korean) - - "{{email}}" → "{{邮箱}}" ❌ (variable name translated to Chinese) - - "" → "<メール>" ❌ (tag name translated) - - "" → "<自定义链接>" ❌ (component name translated) - - - Use appropriate language register (formal/informal) based on existing translations - - Match existing translation style in each language - - Technical terms: check existing conventions per language - - For CJK languages: no spaces between characters unless necessary - - For RTL languages (ar-TN, fa-IR): ensure proper text handling - - ## Output Format Requirements - - Alphabetical key ordering (if original file uses it) - - 2-space indentation - - Trailing newline at end of file - - Valid JSON (use proper escaping for special characters) - - ═══════════════════════════════════════════════════════════════ - ║ PHASE 3: RE-VERIFY - Confirm All Issues Resolved ║ - ═══════════════════════════════════════════════════════════════ - - ### Step 3.1: Run Lint Fix (IMPORTANT!) - ```bash - pnpm --dir ${{ github.workspace }}/web lint:fix --quiet -- 'i18n/**/*.json' - ``` - This ensures: - - JSON keys are sorted alphabetically (jsonc/sort-keys rule) - - Valid i18n keys (dify-i18n/valid-i18n-keys rule) - - No extra keys (dify-i18n/no-extra-keys rule) - - ### Step 3.2: Run Final i18n Check - ```bash - pnpm --dir ${{ github.workspace }}/web run i18n:check - ``` - - ### Step 3.3: Fix Any Remaining Issues - If check reports issues: - - Go back to PHASE 2 for unresolved items - - Repeat until check passes - - ### Step 3.4: Generate Final Summary - ``` - ╔══════════════════════════════════════════════════════════════╗ - ║ SYNC COMPLETED SUMMARY ║ - ╠══════════════════════════════════════════════════════════════╣ - ║ Language │ Added │ Updated │ Deleted │ Status ║ - ╠══════════════════════════════════════════════════════════════╣ - ║ zh-Hans │ 5 │ 2 │ 1 │ ✓ Complete ║ - ║ ja-JP │ 5 │ 2 │ 1 │ ✓ Complete ║ - ║ ... │ ... │ ... │ ... │ ... ║ - ╠══════════════════════════════════════════════════════════════╣ - ║ i18n:check │ PASSED - All keys in sync ║ - ╚══════════════════════════════════════════════════════════════╝ - ``` - - ## Mode-Specific Behavior - - **SYNC_MODE = "incremental"** (default): - - Focus on keys identified from git diff - - Also check i18n:check output for any missing/extra keys - - Efficient for small changes - - **SYNC_MODE = "full"**: - - Compare ALL keys between en-US and each language - - Run i18n:check to identify all discrepancies - - Use for first-time sync or fixing historical issues - - ## Important Notes - - 1. Always run i18n:check BEFORE and AFTER making changes - 2. The check script is the source of truth for missing/extra keys - 3. For UPDATE scenario: git diff is the source of truth for changed values - 4. Create a single commit with all translation changes - 5. If any translation fails, continue with others and report failures - - ═══════════════════════════════════════════════════════════════ - ║ PHASE 4: COMMIT AND CREATE PR ║ - ═══════════════════════════════════════════════════════════════ - - After all translations are complete and verified: - - ### Step 4.1: Check for changes - ```bash - git -C ${{ github.workspace }} status --porcelain - ``` - - If there are changes: - - ### Step 4.2: Create a new branch and commit - Run these git commands ONE BY ONE (not combined with &&). - **IMPORTANT**: Do NOT use `$()` command substitution. Use two separate commands: - - 1. First, get the timestamp: - ```bash - date +%Y%m%d-%H%M%S - ``` - (Note the output, e.g., "20260115-143052") - - 2. Then create branch using the timestamp value: - ```bash - git -C ${{ github.workspace }} checkout -b chore/i18n-sync-20260115-143052 - ``` - (Replace "20260115-143052" with the actual timestamp from step 1) - - 3. Stage changes: - ```bash - git -C ${{ github.workspace }} add web/i18n/ - ``` - - 4. Commit: - ```bash - git -C ${{ github.workspace }} commit -m "chore(i18n): sync translations with en-US - Mode: ${{ steps.detect_changes.outputs.SYNC_MODE }}" - ``` - - 5. Push: - ```bash - git -C ${{ github.workspace }} push origin HEAD - ``` - - ### Step 4.3: Create Pull Request - ```bash - gh pr create --repo ${{ github.repository }} --title "chore(i18n): sync translations with en-US" --body "## Summary - - This PR was automatically generated to sync i18n translation files. - - ### Changes - - Mode: ${{ steps.detect_changes.outputs.SYNC_MODE }} - - Files processed: ${{ steps.detect_changes.outputs.CHANGED_FILES }} - - ### Verification - - [x] \`i18n:check\` passed - - [x] \`lint:fix\` applied - - 🤖 Generated with Claude Code GitHub Action" --base main - ``` - - claude_args: | - --max-turns 150 - --allowedTools "Read,Write,Edit,Bash(git *),Bash(git:*),Bash(gh *),Bash(gh:*),Bash(pnpm *),Bash(pnpm:*),Bash(date *),Bash(date:*),Glob,Grep" diff --git a/.github/workflows/trigger-i18n-sync.yml b/.github/workflows/trigger-i18n-sync.yml deleted file mode 100644 index 66a29453b4711c..00000000000000 --- a/.github/workflows/trigger-i18n-sync.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Trigger i18n Sync on Push - -# This workflow bridges the push event to repository_dispatch -# because claude-code-action doesn't support push events directly. -# See: https://github.com/langgenius/dify/issues/30743 - -on: - push: - branches: [main] - paths: - - 'web/i18n/en-US/*.json' - -permissions: - contents: write - -jobs: - trigger: - if: github.repository == 'langgenius/dify' - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Detect changed files and generate diff - id: detect - run: | - BEFORE_SHA="${{ github.event.before }}" - # Handle edge case: force push may have null/zero SHA - if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then - BEFORE_SHA="HEAD~1" - fi - - # Detect changed i18n files - changed=$(git diff --name-only "$BEFORE_SHA" "${{ github.sha }}" -- 'web/i18n/en-US/*.json' 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/.json$//' | tr '\n' ' ' || echo "") - echo "changed_files=$changed" >> $GITHUB_OUTPUT - - # Generate diff for context - git diff "$BEFORE_SHA" "${{ github.sha }}" -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || echo "" > /tmp/i18n-diff.txt - - # Truncate if too large (keep first 50KB to match receiving workflow) - head -c 50000 /tmp/i18n-diff.txt > /tmp/i18n-diff-truncated.txt - mv /tmp/i18n-diff-truncated.txt /tmp/i18n-diff.txt - - # Base64 encode the diff for safe JSON transport (portable, single-line) - diff_base64=$(base64 < /tmp/i18n-diff.txt | tr -d '\n') - echo "diff_base64=$diff_base64" >> $GITHUB_OUTPUT - - if [ -n "$changed" ]; then - echo "has_changes=true" >> $GITHUB_OUTPUT - echo "Detected changed files: $changed" - else - echo "has_changes=false" >> $GITHUB_OUTPUT - echo "No i18n changes detected" - fi - - - name: Trigger i18n sync workflow - if: steps.detect.outputs.has_changes == 'true' - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - event-type: i18n-sync - client-payload: '{"changed_files": "${{ steps.detect.outputs.changed_files }}", "diff_base64": "${{ steps.detect.outputs.diff_base64 }}", "sync_mode": "incremental", "trigger_sha": "${{ github.sha }}"}' diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml deleted file mode 100644 index 7735afdaca9e13..00000000000000 --- a/.github/workflows/vdb-tests.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Run VDB Tests - -on: - workflow_call: - -concurrency: - group: vdb-tests-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - test: - name: VDB Tests - runs-on: ubuntu-latest - strategy: - matrix: - python-version: - - "3.11" - - "3.12" - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Free Disk Space - uses: endersonmenezes/free-disk-space@v3 - with: - remove_dotnet: true - remove_haskell: true - remove_tool_cache: true - - - name: Setup UV and Python - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - python-version: ${{ matrix.python-version }} - cache-dependency-glob: api/uv.lock - - - name: Check UV lockfile - run: uv lock --project api --check - - - name: Install dependencies - run: uv sync --project api --dev - - - name: Set up dotenvs - run: | - cp docker/.env.example docker/.env - cp docker/middleware.env.example docker/middleware.env - - - name: Expose Service Ports - run: sh .github/workflows/expose_service_ports.sh - -# - name: Set up Vector Store (TiDB) -# uses: hoverkraft-tech/compose-action@v2.0.2 -# with: -# compose-file: docker/tidb/docker-compose.yaml -# services: | -# tidb -# tiflash - - - name: Set up Vector Stores (Weaviate, Qdrant, PGVector, Milvus, PgVecto-RS, Chroma, MyScale, ElasticSearch, Couchbase, OceanBase) - uses: hoverkraft-tech/compose-action@v2.0.2 - with: - compose-file: | - docker/docker-compose.yaml - services: | - weaviate - qdrant - couchbase-server - etcd - minio - milvus-standalone - pgvecto-rs - pgvector - chroma - elasticsearch - oceanbase - - - name: setup test config - run: | - echo $(pwd) - ls -lah . - cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env - -# - name: Check VDB Ready (TiDB) -# run: uv run --project api python api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py - - - name: Test Vector Stores - run: uv run --project api bash dev/pytest/pytest_vdb.sh diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 191ce56aaa3cb4..45e3d93d751025 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -1,10 +1,13 @@ name: Web Tests + on: - workflow_call: + pull_request: + types: [opened, synchronize, reopened] + concurrency: - group: web-tests-${{ github.head_ref || github.run_id }} + group: web-tests-${{ github.ref || github.run_id }} cancel-in-progress: true jobs: @@ -38,328 +41,20 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Run git-diff + run: npx @canyonjs/git-diff + - name: Run tests run: pnpm test:coverage - - name: Coverage Summary - if: always() - id: coverage-summary - run: | - set -eo pipefail - - COVERAGE_FILE="coverage/coverage-final.json" - COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json" - - if [ ! -f "$COVERAGE_FILE" ] && [ ! -f "$COVERAGE_SUMMARY_FILE" ]; then - echo "has_coverage=false" >> "$GITHUB_OUTPUT" - echo "### 🚨 Test Coverage Report :test_tube:" >> "$GITHUB_STEP_SUMMARY" - echo "Coverage data not found. Ensure Vitest runs with coverage enabled." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - echo "has_coverage=true" >> "$GITHUB_OUTPUT" - - node <<'NODE' >> "$GITHUB_STEP_SUMMARY" - const fs = require('fs'); - const path = require('path'); - let libCoverage = null; - - try { - libCoverage = require('istanbul-lib-coverage'); - } catch (error) { - libCoverage = null; - } - - const summaryPath = path.join('coverage', 'coverage-summary.json'); - const finalPath = path.join('coverage', 'coverage-final.json'); - - const hasSummary = fs.existsSync(summaryPath); - const hasFinal = fs.existsSync(finalPath); - - if (!hasSummary && !hasFinal) { - console.log('### Test Coverage Summary :test_tube:'); - console.log(''); - console.log('No coverage data found.'); - process.exit(0); - } - - const summary = hasSummary - ? JSON.parse(fs.readFileSync(summaryPath, 'utf8')) - : null; - const coverage = hasFinal - ? JSON.parse(fs.readFileSync(finalPath, 'utf8')) - : null; - - const getLineCoverageFromStatements = (statementMap, statementHits) => { - const lineHits = {}; - - if (!statementMap || !statementHits) { - return lineHits; - } - - Object.entries(statementMap).forEach(([key, statement]) => { - const line = statement?.start?.line; - if (!line) { - return; - } - const hits = statementHits[key] ?? 0; - const previous = lineHits[line]; - lineHits[line] = previous === undefined ? hits : Math.max(previous, hits); - }); - - return lineHits; - }; - - const getFileCoverage = (entry) => ( - libCoverage ? libCoverage.createFileCoverage(entry) : null - ); - - const getLineHits = (entry, fileCoverage) => { - const lineHits = entry.l ?? {}; - if (Object.keys(lineHits).length > 0) { - return lineHits; - } - if (fileCoverage) { - return fileCoverage.getLineCoverage(); - } - return getLineCoverageFromStatements(entry.statementMap ?? {}, entry.s ?? {}); - }; - - const getUncoveredLines = (entry, fileCoverage, lineHits) => { - if (lineHits && Object.keys(lineHits).length > 0) { - return Object.entries(lineHits) - .filter(([, count]) => count === 0) - .map(([line]) => Number(line)) - .sort((a, b) => a - b); - } - if (fileCoverage) { - return fileCoverage.getUncoveredLines(); - } - return []; - }; - - const totals = { - lines: { covered: 0, total: 0 }, - statements: { covered: 0, total: 0 }, - branches: { covered: 0, total: 0 }, - functions: { covered: 0, total: 0 }, - }; - const fileSummaries = []; - - if (summary) { - const totalEntry = summary.total ?? {}; - ['lines', 'statements', 'branches', 'functions'].forEach((key) => { - if (totalEntry[key]) { - totals[key].covered = totalEntry[key].covered ?? 0; - totals[key].total = totalEntry[key].total ?? 0; - } - }); - - Object.entries(summary) - .filter(([file]) => file !== 'total') - .forEach(([file, data]) => { - fileSummaries.push({ - file, - pct: data.lines?.pct ?? data.statements?.pct ?? 0, - lines: { - covered: data.lines?.covered ?? 0, - total: data.lines?.total ?? 0, - }, - }); - }); - } else if (coverage) { - Object.entries(coverage).forEach(([file, entry]) => { - const fileCoverage = getFileCoverage(entry); - const lineHits = getLineHits(entry, fileCoverage); - const statementHits = entry.s ?? {}; - const branchHits = entry.b ?? {}; - const functionHits = entry.f ?? {}; - - const lineTotal = Object.keys(lineHits).length; - const lineCovered = Object.values(lineHits).filter((n) => n > 0).length; - - const statementTotal = Object.keys(statementHits).length; - const statementCovered = Object.values(statementHits).filter((n) => n > 0).length; - - const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0); - const branchCovered = Object.values(branchHits).reduce( - (acc, branches) => acc + branches.filter((n) => n > 0).length, - 0, - ); - - const functionTotal = Object.keys(functionHits).length; - const functionCovered = Object.values(functionHits).filter((n) => n > 0).length; - - totals.lines.total += lineTotal; - totals.lines.covered += lineCovered; - totals.statements.total += statementTotal; - totals.statements.covered += statementCovered; - totals.branches.total += branchTotal; - totals.branches.covered += branchCovered; - totals.functions.total += functionTotal; - totals.functions.covered += functionCovered; - - const pct = (covered, tot) => (tot > 0 ? (covered / tot) * 100 : 0); - - fileSummaries.push({ - file, - pct: pct(lineCovered || statementCovered, lineTotal || statementTotal), - lines: { - covered: lineCovered || statementCovered, - total: lineTotal || statementTotal, - }, - }); - }); - } - - const pct = (covered, tot) => (tot > 0 ? ((covered / tot) * 100).toFixed(2) : '0.00'); - - console.log('### Test Coverage Summary :test_tube:'); - console.log(''); - console.log('| Metric | Coverage | Covered / Total |'); - console.log('|--------|----------|-----------------|'); - console.log(`| Lines | ${pct(totals.lines.covered, totals.lines.total)}% | ${totals.lines.covered} / ${totals.lines.total} |`); - console.log(`| Statements | ${pct(totals.statements.covered, totals.statements.total)}% | ${totals.statements.covered} / ${totals.statements.total} |`); - console.log(`| Branches | ${pct(totals.branches.covered, totals.branches.total)}% | ${totals.branches.covered} / ${totals.branches.total} |`); - console.log(`| Functions | ${pct(totals.functions.covered, totals.functions.total)}% | ${totals.functions.covered} / ${totals.functions.total} |`); - - console.log(''); - console.log('
File coverage (lowest lines first)'); - console.log(''); - console.log('```'); - fileSummaries - .sort((a, b) => (a.pct - b.pct) || (b.lines.total - a.lines.total)) - .slice(0, 25) - .forEach(({ file, pct, lines }) => { - console.log(`${pct.toFixed(2)}%\t${lines.covered}/${lines.total}\t${file}`); - }); - console.log('```'); - console.log('
'); - - if (coverage) { - const pctValue = (covered, tot) => { - if (tot === 0) { - return '0'; - } - return ((covered / tot) * 100) - .toFixed(2) - .replace(/\.?0+$/, ''); - }; - - const formatLineRanges = (lines) => { - if (lines.length === 0) { - return ''; - } - const ranges = []; - let start = lines[0]; - let end = lines[0]; - - for (let i = 1; i < lines.length; i += 1) { - const current = lines[i]; - if (current === end + 1) { - end = current; - continue; - } - ranges.push(start === end ? `${start}` : `${start}-${end}`); - start = current; - end = current; - } - ranges.push(start === end ? `${start}` : `${start}-${end}`); - return ranges.join(','); - }; - - const tableTotals = { - statements: { covered: 0, total: 0 }, - branches: { covered: 0, total: 0 }, - functions: { covered: 0, total: 0 }, - lines: { covered: 0, total: 0 }, - }; - const tableRows = Object.entries(coverage) - .map(([file, entry]) => { - const fileCoverage = getFileCoverage(entry); - const lineHits = getLineHits(entry, fileCoverage); - const statementHits = entry.s ?? {}; - const branchHits = entry.b ?? {}; - const functionHits = entry.f ?? {}; - - const lineTotal = Object.keys(lineHits).length; - const lineCovered = Object.values(lineHits).filter((n) => n > 0).length; - const statementTotal = Object.keys(statementHits).length; - const statementCovered = Object.values(statementHits).filter((n) => n > 0).length; - const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0); - const branchCovered = Object.values(branchHits).reduce( - (acc, branches) => acc + branches.filter((n) => n > 0).length, - 0, - ); - const functionTotal = Object.keys(functionHits).length; - const functionCovered = Object.values(functionHits).filter((n) => n > 0).length; - - tableTotals.lines.total += lineTotal; - tableTotals.lines.covered += lineCovered; - tableTotals.statements.total += statementTotal; - tableTotals.statements.covered += statementCovered; - tableTotals.branches.total += branchTotal; - tableTotals.branches.covered += branchCovered; - tableTotals.functions.total += functionTotal; - tableTotals.functions.covered += functionCovered; - - const uncoveredLines = getUncoveredLines(entry, fileCoverage, lineHits); - - const filePath = entry.path ?? file; - const relativePath = path.isAbsolute(filePath) - ? path.relative(process.cwd(), filePath) - : filePath; - - return { - file: relativePath || file, - statements: pctValue(statementCovered, statementTotal), - branches: pctValue(branchCovered, branchTotal), - functions: pctValue(functionCovered, functionTotal), - lines: pctValue(lineCovered, lineTotal), - uncovered: formatLineRanges(uncoveredLines), - }; - }) - .sort((a, b) => a.file.localeCompare(b.file)); - - const columns = [ - { key: 'file', header: 'File', align: 'left' }, - { key: 'statements', header: '% Stmts', align: 'right' }, - { key: 'branches', header: '% Branch', align: 'right' }, - { key: 'functions', header: '% Funcs', align: 'right' }, - { key: 'lines', header: '% Lines', align: 'right' }, - { key: 'uncovered', header: 'Uncovered Line #s', align: 'left' }, - ]; - - const allFilesRow = { - file: 'All files', - statements: pctValue(tableTotals.statements.covered, tableTotals.statements.total), - branches: pctValue(tableTotals.branches.covered, tableTotals.branches.total), - functions: pctValue(tableTotals.functions.covered, tableTotals.functions.total), - lines: pctValue(tableTotals.lines.covered, tableTotals.lines.total), - uncovered: '', - }; - - const rowsForOutput = [allFilesRow, ...tableRows]; - const formatRow = (row) => `| ${columns - .map(({ key }) => String(row[key] ?? '')) - .join(' | ')} |`; - const headerRow = `| ${columns.map(({ header }) => header).join(' | ')} |`; - const dividerRow = `| ${columns - .map(({ align }) => (align === 'right' ? '---:' : ':---')) - .join(' | ')} |`; - - console.log(''); - console.log('
Vitest coverage table'); - console.log(''); - console.log(headerRow); - console.log(dividerRow); - rowsForOutput.forEach((row) => console.log(formatRow(row))); - console.log('
'); - } - NODE + - name: Upload coverage to Canyon + uses: canyon-project/canyon-action@v1.0.18 + with: + coverage-file: web/coverage/coverage-final.json + canyon-url: https://app.canyonjs.io + instrument-cwd: ${{ github.workspace }} - name: Upload Coverage Artifact - if: steps.coverage-summary.outputs.has_coverage == 'true' uses: actions/upload-artifact@v6 with: name: web-coverage-report @@ -367,47 +62,12 @@ jobs: retention-days: 30 if-no-files-found: error - web-build: - name: Web Build - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./web - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Check changed files - id: changed-files - uses: tj-actions/changed-files@v47 - with: - files: | - web/** - .github/workflows/web-tests.yml - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - package_json_file: web/package.json - run_install: false - - - name: Setup NodeJS - uses: actions/setup-node@v6 - if: steps.changed-files.outputs.any_changed == 'true' + - name: Upload Diff Artifact + uses: actions/upload-artifact@v6 with: - node-version: 24 - cache: pnpm - cache-dependency-path: ./web/pnpm-lock.yaml - - - name: Web dependencies - if: steps.changed-files.outputs.any_changed == 'true' - working-directory: ./web - run: pnpm install --frozen-lockfile - - - name: Web build check - if: steps.changed-files.outputs.any_changed == 'true' - working-directory: ./web - run: pnpm run build + name: web-diff + path: | + web/diff.txt + web/diff.json + retention-days: 30 + if-no-files-found: error diff --git a/api/.importlinter b/api/.importlinter index b676e9759118d0..cc7ffc15c8de7e 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -104,9 +104,7 @@ forbidden_modules = ignore_imports = core.workflow.nodes.loop.loop_node -> core.app.workflow.node_factory core.workflow.graph_engine.command_channels.redis_channel -> extensions.ext_redis - core.workflow.graph_engine.layers.observability -> configs - core.workflow.graph_engine.layers.observability -> extensions.otel.runtime - core.workflow.graph_engine.layers.persistence -> core.ops.ops_trace_manager + core.workflow.workflow_entry -> core.app.workflow.layers.observability core.workflow.graph_engine.worker_management.worker_pool -> configs core.workflow.nodes.agent.agent_node -> core.model_manager core.workflow.nodes.agent.agent_node -> core.provider_manager @@ -147,7 +145,6 @@ ignore_imports = core.workflow.workflow_entry -> models.workflow core.workflow.nodes.agent.agent_node -> core.agent.entities core.workflow.nodes.agent.agent_node -> core.agent.plugin_entities - core.workflow.graph_engine.layers.persistence -> core.app.entities.app_invoke_entities core.workflow.nodes.base.node -> core.app.entities.app_invoke_entities core.workflow.nodes.knowledge_index.knowledge_index_node -> core.app.entities.app_invoke_entities core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.app.app_config.entities @@ -217,7 +214,6 @@ ignore_imports = core.workflow.nodes.llm.node -> core.llm_generator.output_parser.errors core.workflow.nodes.llm.node -> core.llm_generator.output_parser.structured_output core.workflow.nodes.llm.node -> core.model_manager - core.workflow.graph_engine.layers.persistence -> core.ops.entities.trace_entity core.workflow.nodes.agent.entities -> core.prompt.entities.advanced_prompt_entities core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.prompt.simple_prompt_transform core.workflow.nodes.llm.entities -> core.prompt.entities.advanced_prompt_entities diff --git a/api/README.md b/api/README.md index 9d89b490b0d1cf..486ee0e43e5100 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,6 @@ # Dify Backend API -## Setup and Run +## Setup and Run. > [!IMPORTANT] > diff --git a/api/commands.py b/api/commands.py index 3d68de4cb48eb5..4b811fb1e6c64a 100644 --- a/api/commands.py +++ b/api/commands.py @@ -22,7 +22,7 @@ from core.rag.datasource.vdb.vector_factory import Vector from core.rag.datasource.vdb.vector_type import VectorType from core.rag.index_processor.constant.built_in_field import BuiltInField -from core.rag.models.document import Document +from core.rag.models.document import ChildDocument, Document from core.tools.utils.system_oauth_encryption import encrypt_system_oauth_params from events.app_event import app_was_created from extensions.ext_database import db @@ -418,6 +418,22 @@ def migrate_knowledge_vector_database(): "dataset_id": segment.dataset_id, }, ) + if dataset_document.doc_form == "hierarchical_model": + child_chunks = segment.get_child_chunks() + if child_chunks: + child_documents = [] + for child_chunk in child_chunks: + child_document = ChildDocument( + page_content=child_chunk.content, + metadata={ + "doc_id": child_chunk.index_node_id, + "doc_hash": child_chunk.index_node_hash, + "document_id": segment.document_id, + "dataset_id": segment.dataset_id, + }, + ) + child_documents.append(child_document) + document.children = child_documents documents.append(document) segments_count = segments_count + 1 @@ -431,7 +447,13 @@ def migrate_knowledge_vector_database(): fg="green", ) ) + all_child_documents = [] + for doc in documents: + if doc.children: + all_child_documents.extend(doc.children) vector.create(documents) + if all_child_documents: + vector.create(all_child_documents) click.echo(click.style(f"Created vector index for dataset {dataset.id}.", fg="green")) except Exception as e: click.echo(click.style(f"Failed to created vector index for dataset {dataset.id}.", fg="red")) diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py index a258144d35f7b2..d702db090883cd 100644 --- a/api/core/app/apps/advanced_chat/app_runner.py +++ b/api/core/app/apps/advanced_chat/app_runner.py @@ -21,6 +21,7 @@ ) from core.app.features.annotation_reply.annotation_reply import AnnotationReplyFeature from core.app.layers.conversation_variable_persist_layer import ConversationVariablePersistenceLayer +from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.db.session_factory import session_factory from core.moderation.base import ModerationError from core.moderation.input_moderation import InputModeration @@ -28,7 +29,6 @@ from core.workflow.enums import WorkflowType from core.workflow.graph_engine.command_channels.redis_channel import RedisChannel from core.workflow.graph_engine.layers.base import GraphEngineLayer -from core.workflow.graph_engine.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository from core.workflow.runtime import GraphRuntimeState, VariablePool diff --git a/api/core/app/apps/pipeline/pipeline_runner.py b/api/core/app/apps/pipeline/pipeline_runner.py index 34d02a1e516a95..8ea34344b27d49 100644 --- a/api/core/app/apps/pipeline/pipeline_runner.py +++ b/api/core/app/apps/pipeline/pipeline_runner.py @@ -9,12 +9,12 @@ InvokeFrom, RagPipelineGenerateEntity, ) +from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.app.workflow.node_factory import DifyNodeFactory from core.variables.variables import RAGPipelineVariable, RAGPipelineVariableInput from core.workflow.entities.graph_init_params import GraphInitParams from core.workflow.enums import WorkflowType from core.workflow.graph import Graph -from core.workflow.graph_engine.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.workflow.graph_events import GraphEngineEvent, GraphRunFailedEvent from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index 8dbdc1d58c7146..0ee3c177f20a77 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/api/core/app/apps/workflow/app_runner.py @@ -7,10 +7,10 @@ from core.app.apps.workflow.app_config_manager import WorkflowAppConfig from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity +from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.workflow.enums import WorkflowType from core.workflow.graph_engine.command_channels.redis_channel import RedisChannel from core.workflow.graph_engine.layers.base import GraphEngineLayer -from core.workflow.graph_engine.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository from core.workflow.runtime import GraphRuntimeState, VariablePool diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 0e8f8b8dbf54a3..13b7865f555e85 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -157,7 +157,7 @@ def _prepare_single_node_execution( # Create initial runtime state with variable pool containing environment variables graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, environment_variables=workflow.environment_variables, ), @@ -272,7 +272,9 @@ def _get_graph_and_variable_pool_for_single_node_run( ) # init graph - graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id=node_id) + graph = Graph.init( + graph_config=graph_config, node_factory=node_factory, root_node_id=node_id, skip_validation=True + ) if not graph: raise ValueError("graph not found in workflow") diff --git a/api/core/app/workflow/layers/__init__.py b/api/core/app/workflow/layers/__init__.py new file mode 100644 index 00000000000000..945f75303c7c31 --- /dev/null +++ b/api/core/app/workflow/layers/__init__.py @@ -0,0 +1,10 @@ +"""Workflow-level GraphEngine layers that depend on outer infrastructure.""" + +from .observability import ObservabilityLayer +from .persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer + +__all__ = [ + "ObservabilityLayer", + "PersistenceWorkflowInfo", + "WorkflowPersistenceLayer", +] diff --git a/api/core/workflow/graph_engine/layers/observability.py b/api/core/app/workflow/layers/observability.py similarity index 100% rename from api/core/workflow/graph_engine/layers/observability.py rename to api/core/app/workflow/layers/observability.py diff --git a/api/core/workflow/graph_engine/layers/persistence.py b/api/core/app/workflow/layers/persistence.py similarity index 99% rename from api/core/workflow/graph_engine/layers/persistence.py rename to api/core/app/workflow/layers/persistence.py index e81df4f3b79907..41052b4f523a8d 100644 --- a/api/core/workflow/graph_engine/layers/persistence.py +++ b/api/core/app/workflow/layers/persistence.py @@ -45,7 +45,6 @@ from core.workflow.node_events import NodeRunResult from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository -from core.workflow.workflow_entry import WorkflowEntry from libs.datetime_utils import naive_utc_now @@ -316,6 +315,9 @@ def _prepare_workflow_inputs(self) -> Mapping[str, Any]: # workflow inputs stay reusable without binding future runs to this conversation. continue inputs[f"sys.{field_name}"] = value + # Local import to avoid circular dependency during app bootstrapping. + from core.workflow.workflow_entry import WorkflowEntry + handled = WorkflowEntry.handle_special_values(inputs) return handled or {} diff --git a/api/core/workflow/graph/graph.py b/api/core/workflow/graph/graph.py index 7be94c24266df1..31bf6f3b27f1af 100644 --- a/api/core/workflow/graph/graph.py +++ b/api/core/workflow/graph/graph.py @@ -288,6 +288,7 @@ def init( graph_config: Mapping[str, object], node_factory: NodeFactory, root_node_id: str | None = None, + skip_validation: bool = False, ) -> Graph: """ Initialize graph @@ -339,8 +340,9 @@ def init( root_node=root_node, ) - # Validate the graph structure using built-in validators - get_graph_validator().validate(graph) + if not skip_validation: + # Validate the graph structure using built-in validators + get_graph_validator().validate(graph) return graph diff --git a/api/core/workflow/graph_engine/layers/__init__.py b/api/core/workflow/graph_engine/layers/__init__.py index 772433e48c11ea..0a29a52993639c 100644 --- a/api/core/workflow/graph_engine/layers/__init__.py +++ b/api/core/workflow/graph_engine/layers/__init__.py @@ -8,11 +8,9 @@ from .base import GraphEngineLayer from .debug_logging import DebugLoggingLayer from .execution_limits import ExecutionLimitsLayer -from .observability import ObservabilityLayer __all__ = [ "DebugLoggingLayer", "ExecutionLimitsLayer", "GraphEngineLayer", - "ObservabilityLayer", ] diff --git a/api/core/workflow/runtime/variable_pool.py b/api/core/workflow/runtime/variable_pool.py index d205c6ac8f4ca9..c4b077fa693fe6 100644 --- a/api/core/workflow/runtime/variable_pool.py +++ b/api/core/workflow/runtime/variable_pool.py @@ -44,7 +44,7 @@ class VariablePool(BaseModel): ) system_variables: SystemVariable = Field( description="System variables", - default_factory=SystemVariable.empty, + default_factory=SystemVariable.default, ) environment_variables: Sequence[Variable] = Field( description="Environment variables.", @@ -271,4 +271,4 @@ def _add_system_variables(self, system_variable: SystemVariable): @classmethod def empty(cls) -> VariablePool: """Create an empty variable pool.""" - return cls(system_variables=SystemVariable.empty()) + return cls(system_variables=SystemVariable.default()) diff --git a/api/core/workflow/system_variable.py b/api/core/workflow/system_variable.py index cda80917710f17..6946e3e6ab7274 100644 --- a/api/core/workflow/system_variable.py +++ b/api/core/workflow/system_variable.py @@ -3,6 +3,7 @@ from collections.abc import Mapping, Sequence from types import MappingProxyType from typing import Any +from uuid import uuid4 from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator @@ -72,8 +73,8 @@ def validate_json_fields(cls, data): return data @classmethod - def empty(cls) -> SystemVariable: - return cls() + def default(cls) -> SystemVariable: + return cls(workflow_execution_id=str(uuid4())) def to_dict(self) -> dict[SystemVariableKey, Any]: # NOTE: This method is provided for compatibility with legacy code. diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index c7bcc66c8b0db2..b645f29d2755d5 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -7,6 +7,7 @@ from configs import dify_config from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.layers.observability import ObservabilityLayer from core.app.workflow.node_factory import DifyNodeFactory from core.file.models import File from core.workflow.constants import ENVIRONMENT_VARIABLE_NODE_ID @@ -15,7 +16,7 @@ from core.workflow.graph import Graph from core.workflow.graph_engine import GraphEngine from core.workflow.graph_engine.command_channels import InMemoryChannel -from core.workflow.graph_engine.layers import DebugLoggingLayer, ExecutionLimitsLayer, ObservabilityLayer +from core.workflow.graph_engine.layers import DebugLoggingLayer, ExecutionLimitsLayer from core.workflow.graph_engine.protocols.command_channel import CommandChannel from core.workflow.graph_events import GraphEngineEvent, GraphNodeEventBase, GraphRunFailedEvent from core.workflow.nodes import NodeType @@ -276,7 +277,7 @@ def run_free_node( # init variable pool variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, environment_variables=[], ) diff --git a/api/extensions/ext_fastopenapi.py b/api/extensions/ext_fastopenapi.py index 5f98aa7b67279a..e6c1bc6bee7a47 100644 --- a/api/extensions/ext_fastopenapi.py +++ b/api/extensions/ext_fastopenapi.py @@ -36,7 +36,7 @@ def init_app(app: DifyApp) -> None: router.include_router(console_router, prefix="/console/api") CORS( app, - resources={r"/console/api/*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}}, + resources={r"/console/api/.*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}}, supports_credentials=True, allow_headers=list(AUTHENTICATED_HEADERS), methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py index 2d8418900c5479..ccc6abcc065bca 100644 --- a/api/services/rag_pipeline/rag_pipeline.py +++ b/api/services/rag_pipeline/rag_pipeline.py @@ -436,7 +436,7 @@ def run_draft_workflow_node( user_inputs=user_inputs, user_id=account.id, variable_pool=VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs=user_inputs, environment_variables=[], conversation_variables=[], diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index d8c31591788d47..6404136994966e 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -675,7 +675,7 @@ def run_draft_workflow_node( else: variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs=user_inputs, environment_variables=draft_workflow.environment_variables, conversation_variables=[], @@ -1063,7 +1063,7 @@ def _setup_variable_pool( system_variable.conversation_id = conversation_id system_variable.dialogue_count = 1 else: - system_variable = SystemVariable.empty() + system_variable = SystemVariable.default() # init variable pool variable_pool = VariablePool( diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py new file mode 100644 index 00000000000000..f5903d28bda0a2 --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from core.app.apps.base_app_queue_manager import AppQueueManager +from core.app.apps.workflow.app_runner import WorkflowAppRunner +from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity +from core.workflow.runtime import GraphRuntimeState, VariablePool +from core.workflow.system_variable import SystemVariable +from models.workflow import Workflow + + +def _make_graph_state(): + variable_pool = VariablePool( + system_variables=SystemVariable.default(), + user_inputs={}, + environment_variables=[], + conversation_variables=[], + ) + return MagicMock(), variable_pool, GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) + + +@pytest.mark.parametrize( + ("single_iteration_run", "single_loop_run"), + [ + (WorkflowAppGenerateEntity.SingleIterationRunEntity(node_id="iter", inputs={}), None), + (None, WorkflowAppGenerateEntity.SingleLoopRunEntity(node_id="loop", inputs={})), + ], +) +def test_run_uses_single_node_execution_branch( + single_iteration_run: Any, + single_loop_run: Any, +) -> None: + app_config = MagicMock() + app_config.app_id = "app" + app_config.tenant_id = "tenant" + app_config.workflow_id = "workflow" + + app_generate_entity = MagicMock(spec=WorkflowAppGenerateEntity) + app_generate_entity.app_config = app_config + app_generate_entity.inputs = {} + app_generate_entity.files = [] + app_generate_entity.user_id = "user" + app_generate_entity.invoke_from = InvokeFrom.SERVICE_API + app_generate_entity.workflow_execution_id = "execution-id" + app_generate_entity.task_id = "task-id" + app_generate_entity.call_depth = 0 + app_generate_entity.trace_manager = None + app_generate_entity.single_iteration_run = single_iteration_run + app_generate_entity.single_loop_run = single_loop_run + + workflow = MagicMock(spec=Workflow) + workflow.tenant_id = "tenant" + workflow.app_id = "app" + workflow.id = "workflow" + workflow.type = "workflow" + workflow.version = "v1" + workflow.graph_dict = {"nodes": [], "edges": []} + workflow.environment_variables = [] + + runner = WorkflowAppRunner( + application_generate_entity=app_generate_entity, + queue_manager=MagicMock(spec=AppQueueManager), + variable_loader=MagicMock(), + workflow=workflow, + system_user_id="system-user", + workflow_execution_repository=MagicMock(), + workflow_node_execution_repository=MagicMock(), + ) + + graph, variable_pool, graph_runtime_state = _make_graph_state() + mock_workflow_entry = MagicMock() + mock_workflow_entry.graph_engine = MagicMock() + mock_workflow_entry.graph_engine.layer = MagicMock() + mock_workflow_entry.run.return_value = iter([]) + + with ( + patch("core.app.apps.workflow.app_runner.RedisChannel"), + patch("core.app.apps.workflow.app_runner.redis_client"), + patch("core.app.apps.workflow.app_runner.WorkflowEntry", return_value=mock_workflow_entry) as entry_class, + patch.object( + runner, + "_prepare_single_node_execution", + return_value=( + graph, + variable_pool, + graph_runtime_state, + ), + ) as prepare_single, + patch.object(runner, "_init_graph") as init_graph, + ): + runner.run() + + prepare_single.assert_called_once_with( + workflow=workflow, + single_iteration_run=single_iteration_run, + single_loop_run=single_loop_run, + ) + init_graph.assert_not_called() + + entry_kwargs = entry_class.call_args.kwargs + assert entry_kwargs["invoke_from"] == InvokeFrom.DEBUGGER + assert entry_kwargs["variable_pool"] is variable_pool + assert entry_kwargs["graph_runtime_state"] is graph_runtime_state diff --git a/api/tests/unit_tests/core/workflow/graph/test_graph_skip_validation.py b/api/tests/unit_tests/core/workflow/graph/test_graph_skip_validation.py new file mode 100644 index 00000000000000..6858120335234c --- /dev/null +++ b/api/tests/unit_tests/core/workflow/graph/test_graph_skip_validation.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory +from core.workflow.entities import GraphInitParams +from core.workflow.graph import Graph +from core.workflow.graph.validation import GraphValidationError +from core.workflow.nodes import NodeType +from core.workflow.runtime import GraphRuntimeState, VariablePool +from core.workflow.system_variable import SystemVariable +from models.enums import UserFrom + + +def _build_iteration_graph(node_id: str) -> dict[str, Any]: + return { + "nodes": [ + { + "id": node_id, + "data": { + "type": "iteration", + "title": "Iteration", + "iterator_selector": ["start", "items"], + "output_selector": [node_id, "output"], + }, + } + ], + "edges": [], + } + + +def _build_loop_graph(node_id: str) -> dict[str, Any]: + return { + "nodes": [ + { + "id": node_id, + "data": { + "type": "loop", + "title": "Loop", + "loop_count": 1, + "break_conditions": [], + "logical_operator": "and", + "loop_variables": [], + "outputs": {}, + }, + } + ], + "edges": [], + } + + +def _make_factory(graph_config: dict[str, Any]) -> DifyNodeFactory: + graph_init_params = GraphInitParams( + tenant_id="tenant", + app_id="app", + workflow_id="workflow", + graph_config=graph_config, + user_id="user", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + call_depth=0, + ) + graph_runtime_state = GraphRuntimeState( + variable_pool=VariablePool( + system_variables=SystemVariable.default(), + user_inputs={}, + environment_variables=[], + ), + start_at=0.0, + ) + return DifyNodeFactory(graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state) + + +def test_iteration_root_requires_skip_validation(): + node_id = "iteration-node" + graph_config = _build_iteration_graph(node_id) + node_factory = _make_factory(graph_config) + + with pytest.raises(GraphValidationError): + Graph.init( + graph_config=graph_config, + node_factory=node_factory, + root_node_id=node_id, + ) + + graph = Graph.init( + graph_config=graph_config, + node_factory=node_factory, + root_node_id=node_id, + skip_validation=True, + ) + + assert graph.root_node.id == node_id + assert graph.root_node.node_type == NodeType.ITERATION + + +def test_loop_root_requires_skip_validation(): + node_id = "loop-node" + graph_config = _build_loop_graph(node_id) + node_factory = _make_factory(graph_config) + + with pytest.raises(GraphValidationError): + Graph.init( + graph_config=graph_config, + node_factory=node_factory, + root_node_id=node_id, + ) + + graph = Graph.init( + graph_config=graph_config, + node_factory=node_factory, + root_node_id=node_id, + skip_validation=True, + ) + + assert graph.root_node.id == node_id + assert graph.root_node.node_type == NodeType.LOOP diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py index 51da3b7d73f1a0..35a234be0b0f18 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py @@ -90,14 +90,14 @@ def mock_tool_node(): @pytest.fixture def mock_is_instrument_flag_enabled_false(): """Mock is_instrument_flag_enabled to return False.""" - with patch("core.workflow.graph_engine.layers.observability.is_instrument_flag_enabled", return_value=False): + with patch("core.app.workflow.layers.observability.is_instrument_flag_enabled", return_value=False): yield @pytest.fixture def mock_is_instrument_flag_enabled_true(): """Mock is_instrument_flag_enabled to return True.""" - with patch("core.workflow.graph_engine.layers.observability.is_instrument_flag_enabled", return_value=True): + with patch("core.app.workflow.layers.observability.is_instrument_flag_enabled", return_value=True): yield diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py index 8cc080fe94999c..ade846df28c8b4 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py @@ -15,14 +15,14 @@ import pytest from opentelemetry.trace import StatusCode +from core.app.workflow.layers.observability import ObservabilityLayer from core.workflow.enums import NodeType -from core.workflow.graph_engine.layers.observability import ObservabilityLayer class TestObservabilityLayerInitialization: """Test ObservabilityLayer initialization logic.""" - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_initialization_when_otel_enabled(self, tracer_provider_with_memory_exporter): """Test that layer initializes correctly when OTel is enabled.""" @@ -32,7 +32,7 @@ def test_initialization_when_otel_enabled(self, tracer_provider_with_memory_expo assert NodeType.TOOL in layer._parsers assert layer._default_parser is not None - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", False) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", False) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_true") def test_initialization_when_instrument_flag_enabled(self, tracer_provider_with_memory_exporter): """Test that layer enables when instrument flag is enabled.""" @@ -46,7 +46,7 @@ def test_initialization_when_instrument_flag_enabled(self, tracer_provider_with_ class TestObservabilityLayerNodeSpanLifecycle: """Test node span creation and lifecycle management.""" - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_node_span_created_and_ended( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node @@ -63,7 +63,7 @@ def test_node_span_created_and_ended( assert spans[0].name == mock_llm_node.title assert spans[0].status.status_code == StatusCode.OK - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_node_error_recorded_in_span( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node @@ -82,7 +82,7 @@ def test_node_error_recorded_in_span( assert len(spans[0].events) > 0 assert any("exception" in event.name.lower() for event in spans[0].events) - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_node_end_without_start_handled_gracefully( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node @@ -100,7 +100,7 @@ def test_node_end_without_start_handled_gracefully( class TestObservabilityLayerParserIntegration: """Test parser integration for different node types.""" - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_default_parser_used_for_regular_node( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_start_node @@ -119,7 +119,7 @@ def test_default_parser_used_for_regular_node( assert attrs["node.execution_id"] == mock_start_node.execution_id assert attrs["node.type"] == mock_start_node.node_type.value - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_tool_parser_used_for_tool_node( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_tool_node @@ -138,7 +138,7 @@ def test_tool_parser_used_for_tool_node( assert attrs["gen_ai.tool.name"] == mock_tool_node.title assert attrs["gen_ai.tool.type"] == mock_tool_node._node_data.provider_type.value - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_llm_parser_used_for_llm_node( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node, mock_result_event @@ -176,7 +176,7 @@ def test_llm_parser_used_for_llm_node( assert attrs["gen_ai.completion"] == "test completion" assert attrs["gen_ai.response.finish_reason"] == "stop" - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_retrieval_parser_used_for_retrieval_node( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_retrieval_node, mock_result_event @@ -204,7 +204,7 @@ def test_retrieval_parser_used_for_retrieval_node( assert attrs["retrieval.query"] == "test query" assert "retrieval.document" in attrs - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_result_event_extracts_inputs_and_outputs( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_start_node, mock_result_event @@ -235,7 +235,7 @@ def test_result_event_extracts_inputs_and_outputs( class TestObservabilityLayerGraphLifecycle: """Test graph lifecycle management.""" - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_on_graph_start_clears_contexts(self, tracer_provider_with_memory_exporter, mock_llm_node): """Test that on_graph_start clears node contexts.""" @@ -248,7 +248,7 @@ def test_on_graph_start_clears_contexts(self, tracer_provider_with_memory_export layer.on_graph_start() assert len(layer._node_contexts) == 0 - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_on_graph_end_with_no_unfinished_spans( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node @@ -264,7 +264,7 @@ def test_on_graph_end_with_no_unfinished_spans( spans = memory_span_exporter.get_finished_spans() assert len(spans) == 1 - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_on_graph_end_with_unfinished_spans_logs_warning( self, tracer_provider_with_memory_exporter, mock_llm_node, caplog @@ -285,7 +285,7 @@ def test_on_graph_end_with_unfinished_spans_logs_warning( class TestObservabilityLayerDisabledMode: """Test behavior when layer is disabled.""" - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", False) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", False) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_disabled_mode_skips_node_start(self, memory_span_exporter, mock_start_node): """Test that disabled layer doesn't create spans on node start.""" @@ -299,7 +299,7 @@ def test_disabled_mode_skips_node_start(self, memory_span_exporter, mock_start_n spans = memory_span_exporter.get_finished_spans() assert len(spans) == 0 - @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", False) + @patch("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", False) @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") def test_disabled_mode_skips_node_end(self, memory_span_exporter, mock_llm_node): """Test that disabled layer doesn't process node end.""" diff --git a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py index 2a9db2d328f365..cefc4967ac29ed 100644 --- a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py +++ b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py @@ -16,7 +16,7 @@ def test_executor_with_json_body_and_number_variable(): # Prepare the variable pool variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) variable_pool.add(["pre_node_id", "number"], 42) @@ -69,7 +69,7 @@ def test_executor_with_json_body_and_number_variable(): def test_executor_with_json_body_and_object_variable(): # Prepare the variable pool variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) variable_pool.add(["pre_node_id", "object"], {"name": "John Doe", "age": 30, "email": "john@example.com"}) @@ -124,7 +124,7 @@ def test_executor_with_json_body_and_object_variable(): def test_executor_with_json_body_and_nested_object_variable(): # Prepare the variable pool variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) variable_pool.add(["pre_node_id", "object"], {"name": "John Doe", "age": 30, "email": "john@example.com"}) @@ -178,7 +178,7 @@ def test_executor_with_json_body_and_nested_object_variable(): def test_extract_selectors_from_template_with_newline(): - variable_pool = VariablePool(system_variables=SystemVariable.empty()) + variable_pool = VariablePool(system_variables=SystemVariable.default()) variable_pool.add(("node_id", "custom_query"), "line1\nline2") node_data = HttpRequestNodeData( title="Test JSON Body with Nested Object Variable", @@ -205,7 +205,7 @@ def test_extract_selectors_from_template_with_newline(): def test_executor_with_form_data(): # Prepare the variable pool variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) variable_pool.add(["pre_node_id", "text_field"], "Hello, World!") @@ -290,7 +290,7 @@ def create_executor(headers: str) -> Executor: return Executor( node_data=node_data, timeout=timeout, - variable_pool=VariablePool(system_variables=SystemVariable.empty()), + variable_pool=VariablePool(system_variables=SystemVariable.default()), ) executor = create_executor("aa\n cc:") @@ -324,7 +324,7 @@ def create_executor(params: str) -> Executor: return Executor( node_data=node_data, timeout=timeout, - variable_pool=VariablePool(system_variables=SystemVariable.empty()), + variable_pool=VariablePool(system_variables=SystemVariable.default()), ) # Test basic key-value pairs @@ -355,7 +355,7 @@ def create_executor(params: str) -> Executor: def test_empty_api_key_raises_error_bearer(): """Test that empty API key raises AuthorizationConfigError for bearer auth.""" - variable_pool = VariablePool(system_variables=SystemVariable.empty()) + variable_pool = VariablePool(system_variables=SystemVariable.default()) node_data = HttpRequestNodeData( title="test", method="get", @@ -379,7 +379,7 @@ def test_empty_api_key_raises_error_bearer(): def test_empty_api_key_raises_error_basic(): """Test that empty API key raises AuthorizationConfigError for basic auth.""" - variable_pool = VariablePool(system_variables=SystemVariable.empty()) + variable_pool = VariablePool(system_variables=SystemVariable.default()) node_data = HttpRequestNodeData( title="test", method="get", @@ -403,7 +403,7 @@ def test_empty_api_key_raises_error_basic(): def test_empty_api_key_raises_error_custom(): """Test that empty API key raises AuthorizationConfigError for custom auth.""" - variable_pool = VariablePool(system_variables=SystemVariable.empty()) + variable_pool = VariablePool(system_variables=SystemVariable.default()) node_data = HttpRequestNodeData( title="test", method="get", @@ -427,7 +427,7 @@ def test_empty_api_key_raises_error_custom(): def test_whitespace_only_api_key_raises_error(): """Test that whitespace-only API key raises AuthorizationConfigError.""" - variable_pool = VariablePool(system_variables=SystemVariable.empty()) + variable_pool = VariablePool(system_variables=SystemVariable.default()) node_data = HttpRequestNodeData( title="test", method="get", @@ -451,7 +451,7 @@ def test_whitespace_only_api_key_raises_error(): def test_valid_api_key_works(): """Test that valid API key works correctly for bearer auth.""" - variable_pool = VariablePool(system_variables=SystemVariable.empty()) + variable_pool = VariablePool(system_variables=SystemVariable.default()) node_data = HttpRequestNodeData( title="test", method="get", @@ -487,7 +487,7 @@ def test_executor_with_json_body_and_unquoted_uuid_variable(): test_uuid = "57eeeeb1-450b-482c-81b9-4be77e95dee2" variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) variable_pool.add(["pre_node_id", "uuid"], test_uuid) @@ -531,7 +531,7 @@ def test_executor_with_json_body_and_unquoted_uuid_with_newlines(): test_uuid = "57eeeeb1-450b-482c-81b9-4be77e95dee2" variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) variable_pool.add(["pre_node_id", "uuid"], test_uuid) @@ -569,7 +569,7 @@ def test_executor_with_json_body_and_unquoted_uuid_with_newlines(): def test_executor_with_json_body_preserves_numbers_and_strings(): """Test that numbers are preserved and string values are properly quoted.""" variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) variable_pool.add(["node", "count"], 42) diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py index 77264022bc45c9..3d1b8b2f27216c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py @@ -86,7 +86,7 @@ def graph_init_params() -> GraphInitParams: @pytest.fixture def graph_runtime_state() -> GraphRuntimeState: variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) return GraphRuntimeState( diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py index ead233447368ac..d8f6b41f896cac 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py @@ -111,7 +111,7 @@ def test_webhook_node_file_conversion_to_file_variable(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, @@ -184,7 +184,7 @@ def test_webhook_node_file_conversion_with_missing_files(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, @@ -219,7 +219,7 @@ def test_webhook_node_file_conversion_with_none_file(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, @@ -256,7 +256,7 @@ def test_webhook_node_file_conversion_with_non_dict_file(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, @@ -300,7 +300,7 @@ def test_webhook_node_file_conversion_mixed_parameters(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, @@ -370,7 +370,7 @@ def test_webhook_node_different_file_types(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, @@ -430,7 +430,7 @@ def test_webhook_node_file_conversion_with_non_dict_wrapper(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py index bbb5511923c018..3b5aedebcaed42 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py @@ -75,7 +75,7 @@ def test_webhook_node_basic_initialization(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) @@ -118,7 +118,7 @@ def test_webhook_node_run_with_headers(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": { @@ -154,7 +154,7 @@ def test_webhook_node_run_with_query_params(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, @@ -190,7 +190,7 @@ def test_webhook_node_run_with_body_params(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, @@ -249,7 +249,7 @@ def test_webhook_node_run_with_file_params(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, @@ -302,7 +302,7 @@ def test_webhook_node_run_mixed_parameters(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {"Authorization": "Bearer token"}, @@ -342,7 +342,7 @@ def test_webhook_node_run_empty_webhook_data(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, # No webhook_data ) @@ -368,7 +368,7 @@ def test_webhook_node_run_case_insensitive_headers(): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": { @@ -398,7 +398,7 @@ def test_webhook_node_variable_pool_user_inputs(): # Add some additional variables to the pool variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": {"headers": {}, "query_params": {}, "body": {}, "files": {}}, "other_var": "should_be_included", @@ -429,7 +429,7 @@ def test_webhook_node_different_methods(method): ) variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={ "webhook_data": { "headers": {}, diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry.py b/api/tests/unit_tests/core/workflow/test_workflow_entry.py index b38e070ffcd4ec..27ffa455d67aaa 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry.py @@ -127,7 +127,7 @@ def get_node_config_by_id(self, target_id: str): return node_config workflow = StubWorkflow() - variable_pool = VariablePool(system_variables=SystemVariable.empty(), user_inputs={}) + variable_pool = VariablePool(system_variables=SystemVariable.default(), user_inputs={}) expected_limits = CodeNodeLimits( max_string_length=dify_config.CODE_MAX_STRING_LENGTH, max_number=dify_config.CODE_MAX_NUMBER, @@ -157,7 +157,7 @@ def test_mapping_user_inputs_to_variable_pool_with_env_variables(self): # Initialize variable pool with environment variables env_var = StringVariable(name="API_KEY", value="existing_key") variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), environment_variables=[env_var], user_inputs={}, ) @@ -198,7 +198,7 @@ def test_mapping_user_inputs_to_variable_pool_with_conversation_variables(self): # Initialize variable pool with conversation variables conv_var = StringVariable(name="last_message", value="Hello") variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), conversation_variables=[conv_var], user_inputs={}, ) @@ -239,7 +239,7 @@ def test_mapping_user_inputs_to_variable_pool_with_regular_variables(self): """Test mapping regular node variables from user inputs to variable pool.""" # Initialize empty variable pool variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) @@ -281,7 +281,7 @@ def test_mapping_user_inputs_to_variable_pool_with_regular_variables(self): def test_mapping_user_inputs_with_file_handling(self): """Test mapping file inputs from user inputs to variable pool.""" variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) @@ -340,7 +340,7 @@ def test_mapping_user_inputs_with_file_handling(self): def test_mapping_user_inputs_missing_variable_error(self): """Test that mapping raises error when required variable is missing.""" variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) @@ -366,7 +366,7 @@ def test_mapping_user_inputs_missing_variable_error(self): def test_mapping_user_inputs_with_alternative_key_format(self): """Test mapping with alternative key format (without node prefix).""" variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) @@ -396,7 +396,7 @@ def test_mapping_user_inputs_with_alternative_key_format(self): def test_mapping_user_inputs_with_complex_selectors(self): """Test mapping with complex node variable keys.""" variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) @@ -432,7 +432,7 @@ def test_mapping_user_inputs_with_complex_selectors(self): def test_mapping_user_inputs_invalid_node_variable(self): """Test that mapping handles invalid node variable format.""" variable_pool = VariablePool( - system_variables=SystemVariable.empty(), + system_variables=SystemVariable.default(), user_inputs={}, ) diff --git a/git.mjs b/git.mjs new file mode 100644 index 00000000000000..1c3c3b07db8854 --- /dev/null +++ b/git.mjs @@ -0,0 +1,13 @@ +import { exec } from 'node:child_process'; + +exec( + 'git add . && git commit -m "chore: daily development"', + (err, stdout, stderr) => { + if (err) { + console.error(err); + return; + } + console.log(stdout); + console.log(stderr); + }, +); diff --git a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx index 49b59704b1993e..7b58fb831390c2 100644 --- a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx +++ b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx @@ -71,7 +71,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { limit: 3, disabled: false, onUpload: (imageFile: ImageFile) => { - if (imageFile.progress === 100) { + if (imageFile.progress === 99) { setUploading(false) setInputImageInfo(undefined) handleSaveAvatar(imageFile.fileId) diff --git a/web/app/components/app/configuration/debug/chat-user-input.spec.tsx b/web/app/components/app/configuration/debug/chat-user-input.spec.tsx new file mode 100644 index 00000000000000..e6678ebf29e0c7 --- /dev/null +++ b/web/app/components/app/configuration/debug/chat-user-input.spec.tsx @@ -0,0 +1,710 @@ +import type { Inputs, ModelConfig } from '@/models/debug' +import type { PromptVariable } from '@/types/app' +import { fireEvent, render, screen } from '@testing-library/react' +import ChatUserInput from './chat-user-input' + +const mockSetInputs = vi.fn() +const mockUseContext = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('use-context-selector', () => ({ + useContext: () => mockUseContext(), + createContext: vi.fn(() => ({})), +})) + +vi.mock('@/app/components/base/input', () => ({ + default: ({ value, onChange, placeholder, autoFocus, maxLength, readOnly, type }: { + value: string + onChange: (e: { target: { value: string } }) => void + placeholder?: string + autoFocus?: boolean + maxLength?: number + readOnly?: boolean + type?: string + }) => ( + + ), +})) + +vi.mock('@/app/components/base/select', () => ({ + default: ({ defaultValue, onSelect, items, disabled, className }: { + defaultValue: string + onSelect: (item: { value: string }) => void + items: { name: string, value: string }[] + allowSearch?: boolean + disabled?: boolean + className?: string + }) => ( + + ), +})) + +vi.mock('@/app/components/base/textarea', () => ({ + default: ({ value, onChange, placeholder, readOnly, className }: { + value: string + onChange: (e: { target: { value: string } }) => void + placeholder?: string + readOnly?: boolean + className?: string + }) => ( +