mirror of
https://github.com/langgenius/dify.git
synced 2026-06-10 10:26:51 +08:00
Compare commits
8 Commits
dependabot
...
cli-reques
| Author | SHA1 | Date | |
|---|---|---|---|
| ec67426ff6 | |||
| 695cbec567 | |||
| dbe0d23eb3 | |||
| fa137d37e5 | |||
| 2b4104513d | |||
| 1af89d7fd2 | |||
| 3fb1d3055e | |||
| a823649934 |
14
.github/workflows/api-tests.yml
vendored
14
.github/workflows/api-tests.yml
vendored
@ -29,13 +29,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@ -91,13 +91,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@ -142,13 +142,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@ -195,7 +195,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
disable_search: true
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
run: echo "autofix.ci updates pull request branches, not merge group refs."
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check Docker Compose inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
@ -66,7 +66,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
|
||||
14
.github/workflows/build-push.yml
vendored
14
.github/workflows/build-push.yml
vendored
@ -68,7 +68,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
@ -78,13 +78,13 @@ jobs:
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env[matrix.image_name_env] }}
|
||||
|
||||
- name: Build Docker image
|
||||
id: build
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: ${{ matrix.build_context }}
|
||||
@ -124,10 +124,10 @@ jobs:
|
||||
file: "web/Dockerfile"
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Validate Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
push: false
|
||||
context: ${{ matrix.build_context }}
|
||||
@ -156,14 +156,14 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env[matrix.image_name_env] }}
|
||||
tags: |
|
||||
|
||||
28
.github/workflows/cli-e2e.yml
vendored
28
.github/workflows/cli-e2e.yml
vendored
@ -79,7 +79,7 @@ jobs:
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
@ -123,7 +123,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -131,7 +131,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
@ -170,7 +170,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -178,7 +178,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
@ -233,7 +233,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -241,7 +241,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
@ -274,7 +274,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
@ -295,7 +295,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -303,7 +303,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
@ -351,7 +351,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -359,7 +359,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
@ -408,7 +408,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-last-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
|
||||
4
.github/workflows/cli-release.yml
vendored
4
.github/workflows/cli-release.yml
vendored
@ -35,7 +35,7 @@ jobs:
|
||||
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -98,7 +98,7 @@ jobs:
|
||||
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
2
.github/workflows/cli-smoke.yml
vendored
2
.github/workflows/cli-smoke.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout cli ref
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/cli-tests.yml
vendored
4
.github/workflows/cli-tests.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
directory: cli/coverage
|
||||
flags: cli
|
||||
|
||||
12
.github/workflows/db-migration-test.yml
vendored
12
.github/workflows/db-migration-test.yml
vendored
@ -13,13 +13,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@ -40,7 +40,7 @@ jobs:
|
||||
cp envs/middleware.env.example middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
@ -63,13 +63,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
|
||||
6
.github/workflows/docker-build.yml
vendored
6
.github/workflows/docker-build.yml
vendored
@ -53,7 +53,7 @@ jobs:
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
push: false
|
||||
@ -77,10 +77,10 @@ jobs:
|
||||
file: "web/Dockerfile"
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
push: false
|
||||
context: ${{ matrix.context }}
|
||||
|
||||
2
.github/workflows/hotfix-cherry-pick.yml
vendored
2
.github/workflows/hotfix-cherry-pick.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/main-ci.yml
vendored
2
.github/workflows/main-ci.yml
vendored
@ -48,7 +48,7 @@ jobs:
|
||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
|
||||
4
.github/workflows/pyrefly-diff.yml
vendored
4
.github/workflows/pyrefly-diff.yml
vendored
@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@ -21,10 +21,10 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
|
||||
steps:
|
||||
- name: Checkout default branch (trusted code)
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
4
.github/workflows/pyrefly-type-coverage.yml
vendored
4
.github/workflows/pyrefly-type-coverage.yml
vendored
@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
days-before-issue-stale: 15
|
||||
days-before-issue-close: 3
|
||||
|
||||
10
.github/workflows/style.yml
vendored
10
.github/workflows/style.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Setup UV and Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.12"
|
||||
@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -171,7 +171,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/tool-test-sdks.yaml
vendored
2
.github/workflows/tool-test-sdks.yaml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
working-directory: sdks/nodejs-client
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/translate-i18n-claude.yml
vendored
4
.github/workflows/translate-i18n-claude.yml
vendored
@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@593d7a5c4e0073569f74772c2b7b64c30ec14707 # v1.0.141
|
||||
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/trigger-i18n-sync.yml
vendored
2
.github/workflows/trigger-i18n-sync.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
4
.github/workflows/vdb-tests-full.yml
vendored
4
.github/workflows/vdb-tests-full.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -36,7 +36,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
4
.github/workflows/vdb-tests.yml
vendored
4
.github/workflows/vdb-tests.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
4
.github/workflows/web-e2e.yml
vendored
4
.github/workflows/web-e2e.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -28,7 +28,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
||||
10
.github/workflows/web-tests.yml
vendored
10
.github/workflows/web-tests.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
directory: web/coverage
|
||||
flags: web
|
||||
@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -117,7 +117,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
directory: packages/dify-ui/coverage
|
||||
flags: dify-ui
|
||||
|
||||
81
api/controllers/openapi/_contract.py
Normal file
81
api/controllers/openapi/_contract.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""Request/response contract decorators for the openapi controllers.
|
||||
|
||||
``@accepts`` and ``@returns`` own one slice of the contract from a single model
|
||||
reference — emitting the Swagger schema AND doing the runtime validation/
|
||||
serialisation — so the advertised and enforced contracts can't drift. Validation
|
||||
failures map to a single shape: 422.
|
||||
|
||||
They must sit BELOW ``@auth_router.guard`` so auth runs before validation and the
|
||||
``view.__wrapped__`` unit-test seam unwraps exactly the guard layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import abort
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from controllers.common.schema import query_params_from_model, query_params_from_request
|
||||
from controllers.openapi import openapi_ns
|
||||
|
||||
|
||||
def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | None = None) -> Callable:
|
||||
"""Validate ``query``/``body`` against the models and inject them as keyword-only kwargs.
|
||||
|
||||
Emits the matching Swagger schema from the same models, so doc and enforcement
|
||||
stay in lockstep.
|
||||
"""
|
||||
|
||||
def decorator(view: Callable) -> Callable:
|
||||
@wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
if query is not None:
|
||||
kwargs["query"] = query_params_from_request(query)
|
||||
if body is not None:
|
||||
kwargs["body"] = body.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as exc:
|
||||
# Sanitized 422 — no pydantic `url` (version) or `input` (user payload) leak.
|
||||
abort(
|
||||
422,
|
||||
message="Request validation failed",
|
||||
errors=exc.errors(include_url=False, include_input=False, include_context=False),
|
||||
)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
if query is not None:
|
||||
openapi_ns.doc(params=query_params_from_model(query))(wrapper)
|
||||
if body is not None:
|
||||
openapi_ns.expect(openapi_ns.models[body.__name__])(wrapper)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def returns(code: int, model: type[BaseModel], description: str | None = None) -> Callable:
|
||||
"""Serialise the handler's returned model and emit the response schema.
|
||||
|
||||
Accepts a ``BaseModel`` (serialised with ``code``) or a ``(model, status[, headers])``
|
||||
tuple (status/headers honoured). Other returns — a bare ``(dict, status)``, an SSE
|
||||
``Response`` — pass through untouched.
|
||||
"""
|
||||
|
||||
def decorator(view: Callable) -> Callable:
|
||||
@wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
result = view(*args, **kwargs)
|
||||
if isinstance(result, BaseModel):
|
||||
return result.model_dump(mode="json"), code
|
||||
if isinstance(result, tuple) and result and isinstance(result[0], BaseModel):
|
||||
payload, *rest = result
|
||||
return (payload.model_dump(mode="json"), *rest)
|
||||
return result
|
||||
|
||||
openapi_ns.response(code, description or model.__name__, openapi_ns.models[model.__name__])(wrapper)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@ -9,15 +9,16 @@ from flask_restx import Resource
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import returns
|
||||
from controllers.openapi._models import ServerVersionResponse
|
||||
|
||||
|
||||
@openapi_ns.route("/_version")
|
||||
class VersionApi(Resource):
|
||||
@openapi_ns.response(200, "Server version", openapi_ns.models[ServerVersionResponse.__name__])
|
||||
@returns(200, ServerVersionResponse, description="Server version")
|
||||
def get(self):
|
||||
edition = dify_config.EDITION if dify_config.EDITION in ("SELF_HOSTED", "CLOUD") else "SELF_HOSTED"
|
||||
return ServerVersionResponse(
|
||||
version=dify_config.project.version,
|
||||
edition=edition,
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
@ -2,17 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import NotFound, UnprocessableEntity
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.schema import query_params_from_model
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._models import (
|
||||
AccountPayload,
|
||||
AccountResponse,
|
||||
PaginationEnvelope,
|
||||
RevokeResponse,
|
||||
SessionListQuery,
|
||||
SessionListResponse,
|
||||
@ -42,8 +39,8 @@ from services.oauth_device_flow import (
|
||||
|
||||
@openapi_ns.route("/account")
|
||||
class AccountApi(Resource):
|
||||
@openapi_ns.response(200, "Account info", openapi_ns.models[AccountResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, AccountResponse, description="Account info")
|
||||
def get(self, *, auth_data: AuthData):
|
||||
enforce(LIMIT_ME_PER_ACCOUNT, key=f"account:{auth_data.account_id}")
|
||||
|
||||
@ -58,31 +55,27 @@ class AccountApi(Resource):
|
||||
account=_account_payload(account) if account else None,
|
||||
workspaces=[_workspace_payload(m) for m in memberships],
|
||||
default_workspace_id=default_ws_id,
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@openapi_ns.route("/account/sessions/self")
|
||||
class AccountSessionsSelfApi(Resource):
|
||||
@openapi_ns.response(200, "Session revoked", openapi_ns.models[RevokeResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, RevokeResponse, description="Session revoked")
|
||||
def delete(self, *, auth_data: AuthData):
|
||||
revoke_oauth_token(db.session, redis_client, str(auth_data.token_id))
|
||||
return RevokeResponse(status="revoked").model_dump(mode="json"), 200
|
||||
return RevokeResponse(status="revoked")
|
||||
|
||||
|
||||
@openapi_ns.route("/account/sessions")
|
||||
class AccountSessionsApi(Resource):
|
||||
@openapi_ns.doc(params=query_params_from_model(SessionListQuery))
|
||||
@openapi_ns.response(200, "Session list", openapi_ns.models[SessionListResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
def get(self, *, auth_data: AuthData):
|
||||
# Validate page/limit through the same model the contract advertises (extra='forbid',
|
||||
# page>=1, 1<=limit<=MAX_PAGE_LIMIT) so the server actually enforces those bounds rather
|
||||
# than silently coercing (e.g. page=0 -> empty slice). Mirrors AppDescribeQuery.
|
||||
try:
|
||||
query = SessionListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
except ValidationError as exc:
|
||||
raise UnprocessableEntity(exc.json())
|
||||
@returns(200, SessionListResponse, description="Session list")
|
||||
@accepts(query=SessionListQuery)
|
||||
def get(self, *, auth_data: AuthData, query: SessionListQuery):
|
||||
# SessionListQuery enforces the advertised bounds (extra='forbid', page>=1,
|
||||
# 1<=limit<=MAX_PAGE_LIMIT) so the server rejects out-of-range paging rather
|
||||
# than silently coercing (e.g. page=0 -> empty slice).
|
||||
ctx = get_auth_ctx()
|
||||
now = datetime.now(UTC)
|
||||
page = query.page
|
||||
@ -106,16 +99,19 @@ class AccountSessionsApi(Resource):
|
||||
for r in sliced
|
||||
]
|
||||
|
||||
return (
|
||||
PaginationEnvelope.build(page=page, limit=limit, total=total, items=items).model_dump(mode="json"),
|
||||
200,
|
||||
return SessionListResponse(
|
||||
page=page,
|
||||
limit=limit,
|
||||
total=total,
|
||||
has_more=page * limit < total,
|
||||
data=items,
|
||||
)
|
||||
|
||||
|
||||
@openapi_ns.route("/account/sessions/<string:session_id>")
|
||||
class AccountSessionByIdApi(Resource):
|
||||
@openapi_ns.response(200, "Session revoked", openapi_ns.models[RevokeResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, RevokeResponse, description="Session revoked")
|
||||
def delete(self, session_id: str, *, auth_data: AuthData):
|
||||
ctx = get_auth_ctx()
|
||||
|
||||
@ -125,7 +121,7 @@ class AccountSessionByIdApi(Resource):
|
||||
raise NotFound("session not found")
|
||||
|
||||
revoke_oauth_token(db.session, redis_client, session_id)
|
||||
return RevokeResponse(status="revoked").model_dump(mode="json"), 200
|
||||
return RevokeResponse(status="revoked")
|
||||
|
||||
|
||||
def _iso(dt: datetime | None) -> str | None:
|
||||
|
||||
@ -7,14 +7,13 @@ from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import BadRequest, HTTPException, InternalServerError, NotFound, UnprocessableEntity
|
||||
|
||||
import services
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._audit import emit_app_run
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._models import AppRunRequest, TaskStopResponse
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
@ -123,23 +122,18 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest], Any]] = {
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/run")
|
||||
class AppRunApi(Resource):
|
||||
@openapi_ns.expect(openapi_ns.models[AppRunRequest.__name__])
|
||||
@openapi_ns.response(200, "Run result (SSE stream)")
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
def post(self, app_id: str, *, auth_data: AuthData):
|
||||
@openapi_ns.response(200, "Run result (SSE stream)")
|
||||
@accepts(body=AppRunRequest)
|
||||
def post(self, app_id: str, *, auth_data: AuthData, body: AppRunRequest):
|
||||
app_model, caller, caller_kind = auth_data.require_app_context()
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
payload = AppRunRequest.model_validate(body)
|
||||
except ValidationError as exc:
|
||||
raise UnprocessableEntity(exc.json())
|
||||
|
||||
handler = _DISPATCH.get(app_model.mode)
|
||||
if handler is None:
|
||||
raise UnprocessableEntity("mode_not_runnable")
|
||||
|
||||
try:
|
||||
stream_obj = handler(app_model, caller, payload)
|
||||
stream_obj = handler(app_model, caller, body)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
@ -159,10 +153,10 @@ class AppRunApi(Resource):
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>/stop")
|
||||
class AppRunTaskStopApi(Resource):
|
||||
@openapi_ns.response(200, "Task stopped", openapi_ns.models[TaskStopResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
@returns(200, TaskStopResponse, description="Task stopped")
|
||||
def post(self, app_id: str, task_id: str, *, auth_data: AuthData):
|
||||
app_model, caller, caller_kind = auth_data.require_app_context()
|
||||
AppQueueManager.set_stop_flag_no_user_check(task_id)
|
||||
GraphEngineManager(redis_client).send_stop_command(task_id)
|
||||
return {"result": "success"}
|
||||
return TaskStopResponse(result="success")
|
||||
|
||||
@ -5,14 +5,12 @@ from __future__ import annotations
|
||||
import uuid as _uuid
|
||||
from typing import Any, cast
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import Conflict, NotFound, UnprocessableEntity
|
||||
|
||||
from controllers.common.fields import Parameters
|
||||
from controllers.common.schema import query_params_from_model
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config
|
||||
from controllers.openapi._models import (
|
||||
AppDescribeInfo,
|
||||
@ -88,15 +86,11 @@ def parameters_payload(app: App) -> dict:
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/describe")
|
||||
class AppDescribeApi(AppReadResource):
|
||||
@openapi_ns.doc(params=query_params_from_model(AppDescribeQuery))
|
||||
@openapi_ns.response(200, "App description", openapi_ns.models[AppDescribeResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
def get(self, app_id: str, *, auth_data: AuthData):
|
||||
try:
|
||||
query = AppDescribeQuery.model_validate(request.args.to_dict(flat=True))
|
||||
except ValidationError as exc:
|
||||
raise UnprocessableEntity(exc.json())
|
||||
|
||||
@returns(200, AppDescribeResponse, description="App description")
|
||||
@accepts(query=AppDescribeQuery)
|
||||
def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
|
||||
# describe is UUID-only (workspace_id query param dropped in #37212).
|
||||
app = self._load(app_id)
|
||||
|
||||
requested = query.fields
|
||||
@ -133,35 +127,22 @@ class AppDescribeApi(AppReadResource):
|
||||
except AppUnavailableError:
|
||||
input_schema = dict(EMPTY_INPUT_SCHEMA)
|
||||
|
||||
return (
|
||||
AppDescribeResponse(
|
||||
info=info,
|
||||
parameters=parameters,
|
||||
input_schema=input_schema,
|
||||
).model_dump(mode="json", exclude_none=False),
|
||||
200,
|
||||
return AppDescribeResponse(
|
||||
info=info,
|
||||
parameters=parameters,
|
||||
input_schema=input_schema,
|
||||
)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps")
|
||||
class AppListApi(Resource):
|
||||
@openapi_ns.doc(params=query_params_from_model(AppListQuery))
|
||||
@openapi_ns.response(200, "App list", openapi_ns.models[AppListResponse.__name__])
|
||||
@auth_router.guard_workspace(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
def get(self, *, auth_data: AuthData):
|
||||
try:
|
||||
query: AppListQuery = AppListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
except ValidationError as exc:
|
||||
raise UnprocessableEntity(exc.json())
|
||||
|
||||
@returns(200, AppListResponse, description="App list")
|
||||
@accepts(query=AppListQuery)
|
||||
def get(self, *, auth_data: AuthData, query: AppListQuery):
|
||||
workspace_id = query.workspace_id
|
||||
|
||||
empty = (
|
||||
AppListResponse(page=query.page, limit=query.limit, total=0, has_more=False, data=[]).model_dump(
|
||||
mode="json"
|
||||
),
|
||||
200,
|
||||
)
|
||||
empty = AppListResponse(page=query.page, limit=query.limit, total=0, has_more=False, data=[])
|
||||
|
||||
if query.name:
|
||||
try:
|
||||
@ -189,7 +170,7 @@ class AppListApi(Resource):
|
||||
workspace_name=tenant_name,
|
||||
)
|
||||
env = AppListResponse(page=1, limit=1, total=1, has_more=False, data=[item])
|
||||
return env.model_dump(mode="json"), 200
|
||||
return env
|
||||
|
||||
tag_ids: list[str] | None = None
|
||||
if query.tag:
|
||||
@ -240,4 +221,4 @@ class AppListApi(Resource):
|
||||
has_more=query.page * query.limit < cast(int, pagination.total),
|
||||
data=items,
|
||||
)
|
||||
return env.model_dump(mode="json"), 200
|
||||
return env
|
||||
|
||||
@ -7,12 +7,10 @@ EE blueprint chain so this module is unreachable there.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._models import (
|
||||
AppListRow,
|
||||
PermittedExternalAppsListQuery,
|
||||
@ -30,20 +28,14 @@ from services.enterprise.app_permitted_service import list_permitted_apps
|
||||
|
||||
@openapi_ns.route("/permitted-external-apps")
|
||||
class PermittedExternalAppsListApi(Resource):
|
||||
@openapi_ns.response(
|
||||
200, "Permitted external apps list", openapi_ns.models[PermittedExternalAppsListResponse.__name__]
|
||||
)
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_READ_PERMITTED_EXTERNAL,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}),
|
||||
edition=frozenset({Edition.EE}),
|
||||
)
|
||||
def get(self, *, auth_data: AuthData):
|
||||
try:
|
||||
query = PermittedExternalAppsListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
except ValidationError as exc:
|
||||
raise UnprocessableEntity(exc.json())
|
||||
|
||||
@returns(200, PermittedExternalAppsListResponse, description="Permitted external apps list")
|
||||
@accepts(query=PermittedExternalAppsListQuery)
|
||||
def get(self, *, auth_data: AuthData, query: PermittedExternalAppsListQuery):
|
||||
page_result = list_permitted_apps(
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
@ -55,7 +47,7 @@ class PermittedExternalAppsListApi(Resource):
|
||||
env = PermittedExternalAppsListResponse(
|
||||
page=query.page, limit=query.limit, total=page_result.total, has_more=False, data=[]
|
||||
)
|
||||
return env.model_dump(mode="json"), 200
|
||||
return env
|
||||
|
||||
apps_by_id: dict[str, App] = {
|
||||
str(a.id): a for a in AppService.find_visible_apps_by_ids(db.session, page_result.app_ids)
|
||||
@ -89,4 +81,4 @@ class PermittedExternalAppsListApi(Resource):
|
||||
has_more=query.page * query.limit < page_result.total,
|
||||
data=items,
|
||||
)
|
||||
return env.model_dump(mode="json"), 200
|
||||
return env
|
||||
|
||||
@ -17,6 +17,7 @@ from controllers.common.errors import (
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import returns
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from extensions.ext_database import db
|
||||
@ -38,8 +39,8 @@ class AppFileUploadApi(Resource):
|
||||
415: "Unsupported file type or blocked extension",
|
||||
}
|
||||
)
|
||||
@openapi_ns.response(HTTPStatus.CREATED, "File uploaded", openapi_ns.models[FileResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
@returns(HTTPStatus.CREATED, FileResponse, description="File uploaded")
|
||||
def post(self, app_id: str, *, auth_data: AuthData):
|
||||
app_model, caller, _ = auth_data.require_app_context()
|
||||
if "file" not in request.files:
|
||||
@ -69,5 +70,4 @@ class AppFileUploadApi(Resource):
|
||||
except services.errors.file.BlockedFileExtensionError as exc:
|
||||
raise BlockedFileExtensionError(exc.description)
|
||||
|
||||
response = FileResponse.model_validate(upload_file, from_attributes=True)
|
||||
return response.model_dump(mode="json"), 201
|
||||
return FileResponse.model_validate(upload_file, from_attributes=True)
|
||||
|
||||
@ -10,13 +10,14 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from flask import Response, request
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._models import FormSubmitResponse
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
@ -70,12 +71,11 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
service.ensure_form_active(form)
|
||||
return _jsonify_form_definition(form)
|
||||
|
||||
@openapi_ns.expect(openapi_ns.models[HumanInputFormSubmitPayload.__name__])
|
||||
@openapi_ns.response(200, "Form submitted", openapi_ns.models[FormSubmitResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
def post(self, app_id: str, form_token: str, *, auth_data: AuthData):
|
||||
@returns(200, FormSubmitResponse, description="Form submitted")
|
||||
@accepts(body=HumanInputFormSubmitPayload)
|
||||
def post(self, app_id: str, form_token: str, *, auth_data: AuthData, body: HumanInputFormSubmitPayload):
|
||||
app_model, caller, caller_kind = auth_data.require_app_context()
|
||||
payload = HumanInputFormSubmitPayload.model_validate(request.get_json(silent=True) or {})
|
||||
|
||||
service = HumanInputService(db.engine)
|
||||
form = service.get_form_by_token(form_token)
|
||||
@ -100,12 +100,12 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
service.submit_form_by_token(
|
||||
recipient_type=form.recipient_type,
|
||||
form_token=form_token,
|
||||
selected_action_id=payload.action,
|
||||
form_data=payload.inputs,
|
||||
selected_action_id=body.action,
|
||||
form_data=body.inputs,
|
||||
submission_user_id=submission_user_id,
|
||||
submission_end_user_id=submission_end_user_id,
|
||||
)
|
||||
except FormNotFoundError:
|
||||
raise NotFound("Form not found")
|
||||
|
||||
return {}, 200
|
||||
return FormSubmitResponse()
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import returns
|
||||
from controllers.openapi._models import HealthResponse
|
||||
|
||||
|
||||
@openapi_ns.route("/_health")
|
||||
class HealthApi(Resource):
|
||||
@openapi_ns.response(200, "Health check", openapi_ns.models[HealthResponse.__name__])
|
||||
@returns(200, HealthResponse, description="Health check")
|
||||
def get(self):
|
||||
return {"ok": True}
|
||||
return HealthResponse(ok=True)
|
||||
|
||||
@ -14,14 +14,13 @@ from __future__ import annotations
|
||||
from itertools import starmap
|
||||
from urllib import parse
|
||||
|
||||
from flask import jsonify, make_response, request
|
||||
from flask import jsonify, make_response
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import query_params_from_model
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._models import (
|
||||
MemberActionResponse,
|
||||
MemberInvitePayload,
|
||||
@ -53,14 +52,6 @@ from services.errors.account import (
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
def _validate_body[M: BaseModel](model: type[M]) -> M:
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
return model.model_validate(body)
|
||||
except ValidationError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
|
||||
|
||||
def _member_response(account: Account) -> MemberResponse:
|
||||
return MemberResponse(
|
||||
id=str(account.id),
|
||||
@ -118,18 +109,18 @@ def _check_member_invite_quota(tenant_id: str) -> None:
|
||||
|
||||
@openapi_ns.route("/workspaces")
|
||||
class WorkspacesApi(Resource):
|
||||
@openapi_ns.response(200, "Workspace list", openapi_ns.models[WorkspaceListResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, WorkspaceListResponse, description="Workspace list")
|
||||
def get(self, *, auth_data: AuthData):
|
||||
rows = TenantService.get_workspaces_for_account(db.session, str(auth_data.account_id))
|
||||
|
||||
return WorkspaceListResponse(workspaces=list(starmap(_workspace_summary, rows))).model_dump(mode="json"), 200
|
||||
return WorkspaceListResponse(workspaces=list(starmap(_workspace_summary, rows)))
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>")
|
||||
class WorkspaceByIdApi(Resource):
|
||||
@openapi_ns.response(200, "Workspace detail", openapi_ns.models[WorkspaceDetailResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, WorkspaceDetailResponse, description="Workspace detail")
|
||||
def get(self, workspace_id: str, *, auth_data: AuthData):
|
||||
row = TenantService.find_workspace_for_account(db.session, str(auth_data.account_id), workspace_id)
|
||||
# 404 (not 403) on non-member so workspace IDs don't leak across tenants.
|
||||
@ -137,7 +128,7 @@ class WorkspaceByIdApi(Resource):
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
tenant, membership = row
|
||||
return _workspace_detail(tenant, membership).model_dump(mode="json"), 200
|
||||
return _workspace_detail(tenant, membership)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/switch")
|
||||
@ -149,8 +140,8 @@ class WorkspaceSwitchApi(Resource):
|
||||
that ``hosts.yml`` never diverges from the server's ``current`` state.
|
||||
"""
|
||||
|
||||
@openapi_ns.response(200, "Workspace detail", openapi_ns.models[WorkspaceDetailResponse.__name__])
|
||||
@auth_router.guard_workspace(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, WorkspaceDetailResponse, description="Workspace detail")
|
||||
def post(self, workspace_id: str, *, auth_data: AuthData):
|
||||
account = _load_account(auth_data.account_id)
|
||||
|
||||
@ -163,7 +154,7 @@ class WorkspaceSwitchApi(Resource):
|
||||
if row is None:
|
||||
raise NotFound("workspace not found")
|
||||
tenant, membership = row
|
||||
return _workspace_detail(tenant, membership).model_dump(mode="json"), 200
|
||||
return _workspace_detail(tenant, membership)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members")
|
||||
@ -174,15 +165,10 @@ class WorkspaceMembersApi(Resource):
|
||||
assigned through invite (ownership transfer is console-only).
|
||||
"""
|
||||
|
||||
@openapi_ns.doc(params=query_params_from_model(MemberListQuery))
|
||||
@openapi_ns.response(200, "Member list", openapi_ns.models[MemberListResponse.__name__])
|
||||
@auth_router.guard_workspace(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
def get(self, workspace_id: str, *, auth_data: AuthData):
|
||||
try:
|
||||
query = MemberListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
except ValidationError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
|
||||
@returns(200, MemberListResponse, description="Member list")
|
||||
@accepts(query=MemberListQuery)
|
||||
def get(self, workspace_id: str, *, auth_data: AuthData, query: MemberListQuery):
|
||||
tenant = _load_tenant(workspace_id)
|
||||
members = TenantService.get_tenant_members(tenant)
|
||||
total = len(members)
|
||||
@ -194,17 +180,16 @@ class WorkspaceMembersApi(Resource):
|
||||
total=total,
|
||||
has_more=query.page * query.limit < total,
|
||||
data=[_member_response(m) for m in page_items],
|
||||
).model_dump(mode="json"), 200
|
||||
)
|
||||
|
||||
@openapi_ns.expect(openapi_ns.models[MemberInvitePayload.__name__])
|
||||
@openapi_ns.response(201, "Member invited", openapi_ns.models[MemberInviteResponse.__name__])
|
||||
@auth_router.guard_workspace(
|
||||
scope=Scope.WORKSPACE_WRITE,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}),
|
||||
)
|
||||
def post(self, workspace_id: str, *, auth_data: AuthData):
|
||||
payload = _validate_body(MemberInvitePayload)
|
||||
@returns(201, MemberInviteResponse, description="Member invited")
|
||||
@accepts(body=MemberInvitePayload)
|
||||
def post(self, workspace_id: str, *, auth_data: AuthData, body: MemberInvitePayload):
|
||||
inviter = _load_account(auth_data.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
|
||||
@ -213,9 +198,9 @@ class WorkspaceMembersApi(Resource):
|
||||
try:
|
||||
token = RegisterService.invite_new_member(
|
||||
tenant=tenant,
|
||||
email=payload.email,
|
||||
email=body.email,
|
||||
language=None,
|
||||
role=payload.role,
|
||||
role=body.role,
|
||||
inviter=inviter,
|
||||
)
|
||||
except AccountAlreadyInTenantError as exc:
|
||||
@ -225,7 +210,7 @@ class WorkspaceMembersApi(Resource):
|
||||
except AccountRegisterError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
|
||||
normalized_email = payload.email.lower()
|
||||
normalized_email = body.email.lower()
|
||||
member = AccountService.get_account_by_email_with_case_fallback(normalized_email)
|
||||
if member is None:
|
||||
# invite_new_member just created or fetched this account.
|
||||
@ -235,11 +220,11 @@ class WorkspaceMembersApi(Resource):
|
||||
invite_url = f"{dify_config.CONSOLE_WEB_URL}/activate?email={encoded_email}&token={token}"
|
||||
return MemberInviteResponse(
|
||||
email=normalized_email,
|
||||
role=payload.role,
|
||||
role=body.role,
|
||||
member_id=str(member.id),
|
||||
invite_url=invite_url,
|
||||
tenant_id=str(tenant.id),
|
||||
).model_dump(mode="json"), 201
|
||||
)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>")
|
||||
@ -251,12 +236,12 @@ class WorkspaceMemberApi(Resource):
|
||||
400 per the spec, with the service's message preserved.
|
||||
"""
|
||||
|
||||
@openapi_ns.response(200, "Member removed", openapi_ns.models[MemberActionResponse.__name__])
|
||||
@auth_router.guard_workspace(
|
||||
scope=Scope.WORKSPACE_WRITE,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}),
|
||||
)
|
||||
@returns(200, MemberActionResponse, description="Member removed")
|
||||
def delete(self, workspace_id: str, member_id: str, *, auth_data: AuthData):
|
||||
operator = _load_account(auth_data.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
@ -273,7 +258,7 @@ class WorkspaceMemberApi(Resource):
|
||||
except MemberNotInTenantError as exc:
|
||||
raise NotFound(str(exc))
|
||||
|
||||
return MemberActionResponse().model_dump(mode="json"), 200
|
||||
return MemberActionResponse()
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>/role")
|
||||
@ -284,15 +269,14 @@ class WorkspaceMemberRoleApi(Resource):
|
||||
standing owner (service NoPermissionError → 400, per spec).
|
||||
"""
|
||||
|
||||
@openapi_ns.expect(openapi_ns.models[MemberRoleUpdatePayload.__name__])
|
||||
@openapi_ns.response(200, "Role updated", openapi_ns.models[MemberActionResponse.__name__])
|
||||
@auth_router.guard_workspace(
|
||||
scope=Scope.WORKSPACE_WRITE,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}),
|
||||
)
|
||||
def put(self, workspace_id: str, member_id: str, *, auth_data: AuthData):
|
||||
payload = _validate_body(MemberRoleUpdatePayload)
|
||||
@returns(200, MemberActionResponse, description="Role updated")
|
||||
@accepts(body=MemberRoleUpdatePayload)
|
||||
def put(self, workspace_id: str, member_id: str, *, auth_data: AuthData, body: MemberRoleUpdatePayload):
|
||||
operator = _load_account(auth_data.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
member = AccountService.get_account_by_id(db.session, member_id)
|
||||
@ -300,7 +284,7 @@ class WorkspaceMemberRoleApi(Resource):
|
||||
raise NotFound("member not found")
|
||||
|
||||
try:
|
||||
TenantService.update_member_role(tenant, member, payload.role, operator)
|
||||
TenantService.update_member_role(tenant, member, body.role, operator)
|
||||
except CannotOperateSelfError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except NoPermissionError as exc:
|
||||
@ -310,7 +294,7 @@ class WorkspaceMemberRoleApi(Resource):
|
||||
except RoleAlreadyAssignedError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
|
||||
return MemberActionResponse().model_dump(mode="json"), 200
|
||||
return MemberActionResponse()
|
||||
|
||||
|
||||
def _workspace_summary(tenant: Tenant, membership: TenantAccountJoin) -> WorkspaceSummaryResponse:
|
||||
|
||||
@ -283,7 +283,11 @@ class ToolEngine:
|
||||
Extract tool response binary
|
||||
"""
|
||||
for response in tool_response:
|
||||
if response.type in {ToolInvokeMessage.MessageType.IMAGE_LINK, ToolInvokeMessage.MessageType.IMAGE}:
|
||||
if response.type in {
|
||||
ToolInvokeMessage.MessageType.IMAGE_LINK,
|
||||
ToolInvokeMessage.MessageType.IMAGE,
|
||||
ToolInvokeMessage.MessageType.BINARY_LINK,
|
||||
}:
|
||||
mimetype = None
|
||||
if not response.meta:
|
||||
raise ValueError("missing meta data")
|
||||
@ -298,7 +302,11 @@ class ToolEngine:
|
||||
mimetype = guess_type_result
|
||||
|
||||
if not mimetype:
|
||||
mimetype = "image/jpeg"
|
||||
mimetype = (
|
||||
"image/jpeg"
|
||||
if response.type != ToolInvokeMessage.MessageType.BINARY_LINK
|
||||
else "application/octet-stream"
|
||||
)
|
||||
|
||||
yield ToolInvokeMessageBinary(
|
||||
mimetype=response.meta.get("mime_type", mimetype),
|
||||
|
||||
@ -172,6 +172,8 @@ class ToolFileMessageTransformer:
|
||||
meta=tool_file_meta,
|
||||
)
|
||||
else:
|
||||
if file.mime_type and "mime_type" not in tool_file_meta:
|
||||
tool_file_meta["mime_type"] = file.mime_type
|
||||
yield ToolInvokeMessage(
|
||||
type=ToolInvokeMessage.MessageType.LINK,
|
||||
message=ToolInvokeMessage.TextMessage(text=url),
|
||||
|
||||
@ -299,6 +299,15 @@ Upload a file to use as an input variable when running the app
|
||||
### /permitted-external-apps
|
||||
|
||||
#### GET
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| limit | query | | No | integer |
|
||||
| mode | query | | No | string |
|
||||
| name | query | | No | string |
|
||||
| page | query | | No | integer |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
|
||||
@ -94,4 +94,4 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app, bypass_pipeline, mo
|
||||
|
||||
queue_mock.set_stop_flag_no_user_check.assert_called_once_with("task-1")
|
||||
graph_instance.send_stop_command.assert_called_once_with("task-1")
|
||||
assert result == {"result": "success"}
|
||||
assert result == ({"result": "success"}, 200)
|
||||
|
||||
210
api/tests/unit_tests/controllers/openapi/test_contract.py
Normal file
210
api/tests/unit_tests/controllers/openapi/test_contract.py
Normal file
@ -0,0 +1,210 @@
|
||||
"""Unit tests for the @accepts / @returns contract decorators.
|
||||
|
||||
Exercises the decorators in isolation (not through a real controller): a plain
|
||||
view function decorated with @accepts/@returns, driven inside a request context.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
from controllers.common.schema import register_response_schema_model, register_schema_model
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
|
||||
|
||||
class ContractQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
page: int = Field(1, ge=1)
|
||||
limit: int = Field(20, ge=1, le=100)
|
||||
|
||||
|
||||
class ContractBody(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
|
||||
|
||||
class ContractResp(BaseModel):
|
||||
value: int
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _register_contract_test_models():
|
||||
# Register for @accepts(body=)/@returns name lookups; drop on teardown so these
|
||||
# test-only models don't leak into the shared openapi_ns / generated spec.
|
||||
register_schema_model(openapi_ns, ContractBody)
|
||||
register_response_schema_model(openapi_ns, ContractResp)
|
||||
yield
|
||||
openapi_ns.models.pop(ContractBody.__name__, None)
|
||||
openapi_ns.models.pop(ContractResp.__name__, None)
|
||||
|
||||
|
||||
def _guard_like(view):
|
||||
"""Stand-in for ``@auth_router.guard`` — an outermost @wraps layer."""
|
||||
|
||||
@wraps(view)
|
||||
def wrapper(*args, **kwargs):
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def test_accepts_injects_validated_query(app):
|
||||
@accepts(query=ContractQuery)
|
||||
def view(*, query):
|
||||
return query
|
||||
|
||||
with app.test_request_context("/?page=3&limit=5"):
|
||||
result = view()
|
||||
|
||||
assert isinstance(result, ContractQuery)
|
||||
assert result.page == 3
|
||||
assert result.limit == 5
|
||||
|
||||
|
||||
def test_accepts_query_uses_defaults_when_absent(app):
|
||||
@accepts(query=ContractQuery)
|
||||
def view(*, query):
|
||||
return query
|
||||
|
||||
with app.test_request_context("/"):
|
||||
result = view()
|
||||
|
||||
assert result.page == 1
|
||||
assert result.limit == 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query_string", ["page=0", "limit=999", "page=abc", "unknown=1"])
|
||||
def test_accepts_rejects_invalid_query_with_422(app, query_string):
|
||||
@accepts(query=ContractQuery)
|
||||
def view(*, query):
|
||||
return query
|
||||
|
||||
with app.test_request_context(f"/?{query_string}"):
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
view()
|
||||
|
||||
|
||||
def test_accepts_validation_error_is_sanitized_and_structured(app):
|
||||
"""422 body is structured and leaks neither the pydantic docs url nor the user input."""
|
||||
|
||||
@accepts(body=ContractBody)
|
||||
def view(*, body):
|
||||
return body
|
||||
|
||||
with app.test_request_context("/", method="POST", json={"secret": "leak-me"}):
|
||||
with pytest.raises(UnprocessableEntity) as exc_info:
|
||||
view()
|
||||
|
||||
data = exc_info.value.data
|
||||
assert data["message"] == "Request validation failed"
|
||||
assert isinstance(data["errors"], list)
|
||||
assert data["errors"]
|
||||
for err in data["errors"]:
|
||||
assert {"type", "loc", "msg"} <= err.keys()
|
||||
assert "url" not in err
|
||||
assert "input" not in err
|
||||
assert "leak-me" not in str(data)
|
||||
|
||||
|
||||
def test_accepts_injects_validated_body(app):
|
||||
@accepts(body=ContractBody)
|
||||
def view(*, body):
|
||||
return body
|
||||
|
||||
with app.test_request_context("/", method="POST", json={"name": "x"}):
|
||||
result = view()
|
||||
|
||||
assert isinstance(result, ContractBody)
|
||||
assert result.name == "x"
|
||||
|
||||
|
||||
def test_accepts_rejects_invalid_body_with_422(app):
|
||||
@accepts(body=ContractBody)
|
||||
def view(*, body):
|
||||
return body
|
||||
|
||||
with app.test_request_context("/", method="POST", json={"wrong": 1}):
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
view()
|
||||
|
||||
|
||||
def test_returns_serializes_model_with_decorator_status(app):
|
||||
@returns(200, ContractResp)
|
||||
def view():
|
||||
return ContractResp(value=7)
|
||||
|
||||
with app.test_request_context("/"):
|
||||
body, status = view()
|
||||
|
||||
assert status == 200
|
||||
assert body == {"value": 7}
|
||||
|
||||
|
||||
def test_returns_serializes_model_in_tuple_and_honors_status(app):
|
||||
@returns(200, ContractResp)
|
||||
def view():
|
||||
return ContractResp(value=9), 201
|
||||
|
||||
with app.test_request_context("/"):
|
||||
body, status = view()
|
||||
|
||||
assert status == 201
|
||||
assert body == {"value": 9}
|
||||
|
||||
|
||||
def test_returns_passes_through_non_model(app):
|
||||
sentinel = object()
|
||||
|
||||
@returns(200, ContractResp)
|
||||
def view():
|
||||
return sentinel
|
||||
|
||||
with app.test_request_context("/"):
|
||||
result = view()
|
||||
|
||||
assert result is sentinel
|
||||
|
||||
|
||||
def test_returns_serializes_model_in_three_tuple_with_headers(app):
|
||||
"""A (model, status, headers) tuple keeps its trailing status/headers intact."""
|
||||
|
||||
@returns(200, ContractResp)
|
||||
def view():
|
||||
return ContractResp(value=3), 202, {"X-Test": "1"}
|
||||
|
||||
with app.test_request_context("/"):
|
||||
body, status, headers = view()
|
||||
|
||||
assert body == {"value": 3}
|
||||
assert status == 202
|
||||
assert headers == {"X-Test": "1"}
|
||||
|
||||
|
||||
# Swagger metadata (read off __apidoc__) must survive @wraps up through the guard layer.
|
||||
|
||||
|
||||
def test_accepts_returns_emit_apidoc_through_guard_stack():
|
||||
@_guard_like
|
||||
@returns(200, ContractResp)
|
||||
@accepts(query=ContractQuery)
|
||||
def view(*, query):
|
||||
return ContractResp(value=1)
|
||||
|
||||
apidoc = getattr(view, "__apidoc__", {})
|
||||
assert "page" in apidoc.get("params", {}) # from @accepts(query=)
|
||||
assert "200" in apidoc.get("responses", {}) # from @returns (flask_restx keys by str code)
|
||||
|
||||
|
||||
def test_accepts_body_emits_expect_through_guard_stack():
|
||||
@_guard_like
|
||||
@accepts(body=ContractBody)
|
||||
def view(*, body):
|
||||
return body
|
||||
|
||||
apidoc = getattr(view, "__apidoc__", {})
|
||||
assert apidoc.get("expect") # body schema advertised via @openapi_ns.expect
|
||||
@ -11,7 +11,7 @@ from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.exceptions import NotFound, UnprocessableEntity
|
||||
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
@ -233,3 +233,24 @@ class TestOpenApiHumanInputFormPost:
|
||||
submission_end_user_id="eu-7",
|
||||
)
|
||||
assert result == ({}, 200)
|
||||
|
||||
def test_post_rejects_invalid_body_with_422(self, app: Flask, bypass_pipeline):
|
||||
"""Malformed body → 422 via @accepts (was an unmapped pydantic error → 500)."""
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
api = OpenApiWorkflowHumanInputFormApi()
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="acct-42")
|
||||
|
||||
with app.test_request_context(
|
||||
"/openapi/v1/apps/app-1/form/human_input/tok-1",
|
||||
method="POST",
|
||||
json={"inputs": {"field1": "val"}}, # missing required "action"
|
||||
):
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
api.post.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
form_token="tok-1",
|
||||
auth_data=_make_auth_data(app_model, caller, "account"),
|
||||
)
|
||||
|
||||
@ -29,7 +29,7 @@ import pytest
|
||||
from flask import Flask
|
||||
from flask.views import MethodView
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, UnprocessableEntity
|
||||
|
||||
from controllers.openapi import bp as openapi_bp
|
||||
from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePayload
|
||||
@ -198,7 +198,7 @@ def test_member_role_route_registered(openapi_app: Flask):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payload validation lands at 400
|
||||
# Payload validation lands at 422 (unified via @accepts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ -227,18 +227,38 @@ def test_role_payload_rejects_extra_field():
|
||||
MemberRoleUpdatePayload.model_validate({"role": "normal", "extra": "x"})
|
||||
|
||||
|
||||
def test_validate_body_helper_maps_validation_error_to_400(app, monkeypatch):
|
||||
"""`_validate_body` is the centralized 400-mapper for invalid request bodies."""
|
||||
from controllers.openapi.workspaces import _validate_body
|
||||
def test_invite_rejects_invalid_body_with_422(app, bypass_pipeline):
|
||||
"""Invalid invite body → 422 via @accepts (was 400 through _validate_body)."""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
with app.test_request_context(
|
||||
"/openapi/v1/workspaces/ws-1/members",
|
||||
f"/openapi/v1/workspaces/{ws_id}/members",
|
||||
method="POST",
|
||||
data=json.dumps({"email": "u@example.com", "role": "owner"}),
|
||||
data=json.dumps({"email": "u@example.com", "role": "owner"}), # owner is not invite-assignable
|
||||
content_type="application/json",
|
||||
):
|
||||
with pytest.raises(BadRequest):
|
||||
_validate_body(MemberInvitePayload)
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
|
||||
def test_update_role_rejects_invalid_body_with_422(app, bypass_pipeline):
|
||||
"""Invalid role-update body surfaces as 422 through @accepts (was 400)."""
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberRoleApi()
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
|
||||
method="PUT",
|
||||
data=json.dumps({"role": "owner"}), # closed enum rejects owner
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
api.put.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -384,7 +404,7 @@ def test_members_list_paginates_with_query_params(app, bypass_pipeline, monkeypa
|
||||
|
||||
|
||||
def test_members_list_rejects_unknown_query_param(app, bypass_pipeline, monkeypatch):
|
||||
"""Strict (`extra='forbid'`) — typos like `?pg=2` surface as 400."""
|
||||
"""Strict (`extra='forbid'`) — typos like `?pg=2` surface as 422 (unified via @accepts)."""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
@ -395,7 +415,7 @@ def test_members_list_rejects_unknown_query_param(app, bypass_pipeline, monkeypa
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members?pg=2"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(BadRequest):
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
api.get.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
zGetHealthResponse,
|
||||
zGetOauthDeviceLookupQuery,
|
||||
zGetOauthDeviceLookupResponse,
|
||||
zGetPermittedExternalAppsQuery,
|
||||
zGetPermittedExternalAppsResponse,
|
||||
zGetVersionResponse,
|
||||
zGetWorkspacesByWorkspaceIdMembersPath,
|
||||
@ -438,6 +439,7 @@ export const get10 = oc
|
||||
path: '/permitted-external-apps',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ query: zGetPermittedExternalAppsQuery.optional() }))
|
||||
.output(zGetPermittedExternalAppsResponse)
|
||||
|
||||
export const permittedExternalApps = {
|
||||
|
||||
@ -656,7 +656,12 @@ export type PostOauthDeviceTokenResponse
|
||||
export type GetPermittedExternalAppsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
query?: {
|
||||
limit?: number
|
||||
mode?: string
|
||||
name?: string
|
||||
page?: number
|
||||
}
|
||||
url: '/permitted-external-apps'
|
||||
}
|
||||
|
||||
|
||||
@ -638,6 +638,13 @@ export const zPostOauthDeviceTokenBody = zDevicePollRequest
|
||||
*/
|
||||
export const zPostOauthDeviceTokenResponse = z.record(z.string(), z.unknown())
|
||||
|
||||
export const zGetPermittedExternalAppsQuery = z.object({
|
||||
limit: z.int().gte(1).lte(200).optional().default(20),
|
||||
mode: z.string().optional(),
|
||||
name: z.string().max(200).optional(),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Permitted external apps list
|
||||
*/
|
||||
|
||||
@ -43,18 +43,18 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
|
||||
| Display | `./kbd` | Keyboard input and shortcut keycap primitives. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./pagination`, `./tabs` | Pagination for page navigation; Tabs for panels. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
|
||||
| Display | `./kbd` | Keyboard input and shortcut keycap primitives. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./file-tree`, `./pagination`, `./tabs` | FileTree for preview-oriented file disclosure lists; Pagination for page navigation; Tabs for panels. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
|
||||
Utilities:
|
||||
|
||||
|
||||
@ -61,6 +61,10 @@
|
||||
"types": "./src/fieldset/index.tsx",
|
||||
"import": "./src/fieldset/index.tsx"
|
||||
},
|
||||
"./file-tree": {
|
||||
"types": "./src/file-tree/index.tsx",
|
||||
"import": "./src/file-tree/index.tsx"
|
||||
},
|
||||
"./form": {
|
||||
"types": "./src/form/index.tsx",
|
||||
"import": "./src/form/index.tsx"
|
||||
|
||||
193
packages/dify-ui/src/file-tree/__tests__/index.spec.tsx
Normal file
193
packages/dify-ui/src/file-tree/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
FileTreeFile,
|
||||
FileTreeFolder,
|
||||
FileTreeFolderPanel,
|
||||
FileTreeFolderTrigger,
|
||||
FileTreeIcon,
|
||||
FileTreeLabel,
|
||||
FileTreeList,
|
||||
FileTreeRoot,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
function TestFileTree({
|
||||
onPreview = vi.fn(),
|
||||
}: {
|
||||
onPreview?: (itemId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<FileTreeRoot aria-label="Project files">
|
||||
<FileTreeList>
|
||||
<FileTreeFolder defaultOpen>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>src</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFolder defaultOpen>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>components</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile selected onClick={() => onPreview('button')}>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>button.tsx</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
<FileTreeFile onClick={() => onPreview('readme')}>
|
||||
<FileTreeIcon type="markdown" />
|
||||
<FileTreeLabel>README.md</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
<FileTreeFile onClick={() => onPreview('index')}>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>index.ts</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
<FileTreeFile onClick={() => onPreview('package')}>
|
||||
<FileTreeIcon type="json" />
|
||||
<FileTreeLabel>package.json</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
)
|
||||
}
|
||||
|
||||
describe('FileTree', () => {
|
||||
it('renders a labelled disclosure list instead of an ARIA treeview', async () => {
|
||||
const screen = await render(<TestFileTree />)
|
||||
const root = screen.getByLabelText('Project files')
|
||||
const src = screen.getByRole('button', { name: 'src' })
|
||||
const selectedFile = screen.getByRole('button', { name: 'button.tsx' })
|
||||
|
||||
await expect.element(root).not.toHaveAttribute('role', 'tree')
|
||||
await expect.element(src).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect.element(src).toHaveAttribute('aria-controls')
|
||||
await expect.element(src).not.toHaveAttribute('aria-current')
|
||||
await expect.element(src).not.toHaveAttribute('data-selected')
|
||||
await expect.element(selectedFile).toHaveAttribute('aria-current', 'true')
|
||||
await expect.element(selectedFile).toHaveAttribute('data-selected')
|
||||
})
|
||||
|
||||
it('uses Figma-aligned row, indentation, icon, and selected label styles', async () => {
|
||||
const screen = await render(<TestFileTree />)
|
||||
|
||||
await expect.element(screen.getByLabelText('Project files')).toHaveClass('gap-px', 'p-1')
|
||||
await expect.element(screen.getByRole('button', { name: 'button.tsx' })).toHaveClass('h-6', 'rounded-md', 'pl-2', 'pr-1.5', 'data-[selected]:bg-state-base-active')
|
||||
await expect.element(screen.getByText('button.tsx')).toHaveClass('group-data-[selected]/file-tree-row:system-sm-medium', 'group-data-[selected]/file-tree-row:text-text-primary')
|
||||
await expect.element(screen.getByText('README.md')).toHaveAttribute('data-label', 'README.md')
|
||||
await expect.element(screen.getByText('README.md')).toHaveClass('after:content-[attr(data-label)]')
|
||||
expect(screen.container.querySelector('.before\\:bottom-\\[-1px\\]')).toBeInTheDocument()
|
||||
expect(screen.container.querySelector('.i-ri-folder-open-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses Remix fill icons for each non-folder file type', async () => {
|
||||
const iconTypes = [
|
||||
['file', 'i-ri-file-3-fill'],
|
||||
['markdown', 'i-ri-markdown-fill'],
|
||||
['json', 'i-ri-braces-fill'],
|
||||
['image', 'i-ri-file-image-fill'],
|
||||
['code', 'i-ri-file-code-fill'],
|
||||
['database', 'i-ri-database-2-fill'],
|
||||
['text', 'i-ri-file-text-fill'],
|
||||
['pdf', 'i-ri-file-pdf-2-fill'],
|
||||
['table', 'i-ri-file-excel-fill'],
|
||||
['archive', 'i-ri-file-zip-fill'],
|
||||
] as const
|
||||
const screen = await render(
|
||||
<FileTreeRoot aria-label="Icon examples">
|
||||
<FileTreeList>
|
||||
{iconTypes.map(([type]) => (
|
||||
<FileTreeFile key={type}>
|
||||
<FileTreeIcon type={type} />
|
||||
<FileTreeLabel>{type}</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
))}
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>,
|
||||
)
|
||||
|
||||
for (const [, iconClassName] of iconTypes)
|
||||
expect(screen.container.querySelector(`.${iconClassName}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses and expands folders with click and native button keyboard behavior', async () => {
|
||||
const screen = await render(<TestFileTree />)
|
||||
const src = screen.getByRole('button', { name: 'src' }).element() as HTMLElement
|
||||
|
||||
src.click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'src' })).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.container.textContent).not.toContain('components')
|
||||
|
||||
src.click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'src' })).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect.element(screen.getByRole('button', { name: 'components' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('activates file preview buttons without navigation semantics', async () => {
|
||||
const onPreview = vi.fn()
|
||||
const screen = await render(<TestFileTree onPreview={onPreview} />)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'README.md' }).element()).click()
|
||||
|
||||
expect(onPreview).toHaveBeenCalledWith('readme')
|
||||
await expect.element(screen.getByRole('button', { name: 'README.md' })).not.toHaveAttribute('href')
|
||||
})
|
||||
|
||||
it('does not activate disabled file buttons', async () => {
|
||||
const onPreview = vi.fn()
|
||||
const screen = await render(
|
||||
<FileTreeRoot aria-label="Disabled files">
|
||||
<FileTreeList>
|
||||
<FileTreeFile disabled onClick={() => onPreview('disabled')}>
|
||||
<FileTreeIcon type="file" />
|
||||
<FileTreeLabel>disabled.txt</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'disabled.txt' }).element()).click()
|
||||
|
||||
expect(onPreview).not.toHaveBeenCalled()
|
||||
await expect.element(screen.getByRole('button', { name: 'disabled.txt' })).toBeDisabled()
|
||||
await expect.element(screen.getByRole('button', { name: 'disabled.txt' })).toHaveAttribute('data-disabled')
|
||||
await expect.element(screen.getByRole('button', { name: 'disabled.txt' })).toHaveClass('data-disabled:cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('styles disabled folder triggers from the resolved collapsible state', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const screen = await render(
|
||||
<FileTreeRoot aria-label="Disabled folders">
|
||||
<FileTreeList>
|
||||
<FileTreeFolder disabled defaultOpen onOpenChange={onOpenChange}>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>locked</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile>
|
||||
<FileTreeIcon type="file" />
|
||||
<FileTreeLabel>nested.txt</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>,
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'locked' })
|
||||
|
||||
asHTMLElement(trigger.element()).click()
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
await expect.element(trigger).toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect.element(trigger).toHaveClass('aria-disabled:cursor-not-allowed')
|
||||
})
|
||||
})
|
||||
346
packages/dify-ui/src/file-tree/index.stories.tsx
Normal file
346
packages/dify-ui/src/file-tree/index.stories.tsx
Normal file
@ -0,0 +1,346 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FileTreeIconType } from '.'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
FileTreeBadge,
|
||||
FileTreeFile,
|
||||
FileTreeFolder,
|
||||
FileTreeFolderPanel,
|
||||
FileTreeFolderTrigger,
|
||||
FileTreeIcon,
|
||||
FileTreeLabel,
|
||||
FileTreeList,
|
||||
FileTreeMeta,
|
||||
FileTreeRoot,
|
||||
} from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/FileTree',
|
||||
component: FileTreeRoot,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Composable file preview list built with Base UI Collapsible. Folders are disclosure buttons, files are preview buttons, and feature code owns data loading, routing, editing, drag-and-drop, and item actions.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FileTreeRoot>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
type ExampleFileTreeNode = {
|
||||
id: string
|
||||
name: string
|
||||
icon: FileTreeIconType
|
||||
meta?: string
|
||||
badge?: string
|
||||
children?: ExampleFileTreeNode[]
|
||||
}
|
||||
|
||||
const fileTreeData: ExampleFileTreeNode[] = [
|
||||
{
|
||||
id: 'app',
|
||||
name: 'app',
|
||||
icon: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'app-components',
|
||||
name: 'components',
|
||||
icon: 'folder',
|
||||
children: [
|
||||
{ id: 'app-components-file-tree', name: 'file-tree.tsx', icon: 'code' },
|
||||
{ id: 'app-components-readme', name: 'README.md', icon: 'markdown' },
|
||||
{ id: 'app-components-package', name: 'package.json', icon: 'json' },
|
||||
],
|
||||
},
|
||||
{ id: 'app-layout', name: 'layout.tsx', icon: 'code' },
|
||||
{ id: 'app-theme', name: 'theme.css', icon: 'code', meta: 'global' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'assets',
|
||||
name: 'assets',
|
||||
icon: 'folder',
|
||||
children: [
|
||||
{ id: 'assets-hero', name: 'hero.png', icon: 'image' },
|
||||
{ id: 'assets-export', name: 'export.zip', icon: 'archive', badge: '4 MB' },
|
||||
],
|
||||
},
|
||||
{ id: 'schema', name: 'schema.sqlite', icon: 'database' },
|
||||
]
|
||||
|
||||
function FileTreeNodeRows({
|
||||
nodes,
|
||||
selectedItemId,
|
||||
onPreview,
|
||||
}: {
|
||||
nodes: ExampleFileTreeNode[]
|
||||
selectedItemId: string | null
|
||||
onPreview: (itemId: string) => void
|
||||
}) {
|
||||
return nodes.map((node) => {
|
||||
if (node.children?.length) {
|
||||
return (
|
||||
<FileTreeFolder key={node.id} defaultOpen={node.id === 'app' || node.id === 'app-components'}>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>{node.name}</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeNodeRows
|
||||
nodes={node.children}
|
||||
selectedItemId={selectedItemId}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FileTreeFile
|
||||
key={node.id}
|
||||
selected={selectedItemId === node.id}
|
||||
onClick={() => onPreview(node.id)}
|
||||
>
|
||||
<FileTreeIcon type={node.icon} />
|
||||
<FileTreeLabel>{node.name}</FileTreeLabel>
|
||||
{node.meta && <FileTreeMeta>{node.meta}</FileTreeMeta>}
|
||||
{node.badge && <FileTreeBadge>{node.badge}</FileTreeBadge>}
|
||||
</FileTreeFile>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function ComposedFileTree() {
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>('button')
|
||||
|
||||
return (
|
||||
<FileTreeRoot
|
||||
aria-label="Project files"
|
||||
className="w-80 rounded-lg border border-divider-subtle bg-background-default-subtle"
|
||||
>
|
||||
<FileTreeList>
|
||||
<FileTreeFolder defaultOpen>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>src</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFolder defaultOpen>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>components</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile selected={selectedItemId === 'button'} onClick={() => setSelectedItemId('button')}>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>button.tsx</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
<FileTreeFile selected={selectedItemId === 'dialog'} onClick={() => setSelectedItemId('dialog')}>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>dialog.tsx</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
<FileTreeFile selected={selectedItemId === 'readme'} onClick={() => setSelectedItemId('readme')}>
|
||||
<FileTreeIcon type="markdown" />
|
||||
<FileTreeLabel>README.md</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
<FileTreeFile selected={selectedItemId === 'config'} onClick={() => setSelectedItemId('config')}>
|
||||
<FileTreeIcon type="json" />
|
||||
<FileTreeLabel>config.json</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
<FileTreeFile selected={selectedItemId === 'index'} onClick={() => setSelectedItemId('index')}>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>index.ts</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
<FileTreeFile selected={selectedItemId === 'hero'} onClick={() => setSelectedItemId('hero')}>
|
||||
<FileTreeIcon type="image" />
|
||||
<FileTreeLabel>hero.png</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
<FileTreeFile selected={selectedItemId === 'license'} onClick={() => setSelectedItemId('license')}>
|
||||
<FileTreeIcon type="text" />
|
||||
<FileTreeLabel>LICENSE</FileTreeLabel>
|
||||
<FileTreeMeta>root</FileTreeMeta>
|
||||
</FileTreeFile>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
)
|
||||
}
|
||||
|
||||
function DataDrivenFileTree() {
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>('app-components-file-tree')
|
||||
|
||||
return (
|
||||
<FileTreeRoot
|
||||
aria-label="Data-driven project files"
|
||||
className="w-80 rounded-lg border border-divider-subtle bg-background-default-subtle"
|
||||
>
|
||||
<FileTreeList>
|
||||
<FileTreeNodeRows
|
||||
nodes={fileTreeData}
|
||||
selectedItemId={selectedItemId}
|
||||
onPreview={setSelectedItemId}
|
||||
/>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
)
|
||||
}
|
||||
|
||||
function IconGallery() {
|
||||
const iconTypes = [
|
||||
'folder',
|
||||
'file',
|
||||
'markdown',
|
||||
'json',
|
||||
'image',
|
||||
'code',
|
||||
'database',
|
||||
'text',
|
||||
'pdf',
|
||||
'table',
|
||||
'archive',
|
||||
] as const
|
||||
|
||||
return (
|
||||
<FileTreeRoot aria-label="File icon examples" className="w-64 rounded-lg border border-divider-subtle bg-background-default-subtle">
|
||||
<FileTreeList>
|
||||
{iconTypes.map(type => (
|
||||
type === 'folder'
|
||||
? (
|
||||
<FileTreeFolder key={type}>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type={type} />
|
||||
<FileTreeLabel>{type}</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel />
|
||||
</FileTreeFolder>
|
||||
)
|
||||
: (
|
||||
<FileTreeFile key={type}>
|
||||
<FileTreeIcon type={type} />
|
||||
<FileTreeLabel>{type}</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
)
|
||||
))}
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
)
|
||||
}
|
||||
|
||||
function StateFrame({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="w-80 min-w-0 space-y-1">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
|
||||
<FileTreeRoot aria-label={label} className="rounded-lg border border-divider-subtle bg-background-default-subtle">
|
||||
<FileTreeList>
|
||||
{children}
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VisualStates() {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<StateFrame label="Default file">
|
||||
<FileTreeFile>
|
||||
<FileTreeIcon type="file" />
|
||||
<FileTreeLabel>default.txt</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</StateFrame>
|
||||
<StateFrame label="Selected file">
|
||||
<FileTreeFile selected>
|
||||
<FileTreeIcon type="markdown" />
|
||||
<FileTreeLabel>active.md</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</StateFrame>
|
||||
<StateFrame label="Disabled file">
|
||||
<FileTreeFile disabled>
|
||||
<FileTreeIcon type="json" />
|
||||
<FileTreeLabel>disabled.json</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</StateFrame>
|
||||
<StateFrame label="Disabled folder">
|
||||
<FileTreeFolder disabled>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>disabled-folder</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>nested.ts</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
</StateFrame>
|
||||
<StateFrame label="Closed folder">
|
||||
<FileTreeFolder>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>closed-folder</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>nested.ts</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
</StateFrame>
|
||||
<StateFrame label="Open folder">
|
||||
<FileTreeFolder defaultOpen>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>open-folder</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>nested.ts</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
</StateFrame>
|
||||
<StateFrame label="Long label">
|
||||
<FileTreeFile selected>
|
||||
<FileTreeIcon type="text" />
|
||||
<FileTreeLabel>very-long-file-name-that-should-truncate-without-shifting-layout.txt</FileTreeLabel>
|
||||
<FileTreeMeta>preview</FileTreeMeta>
|
||||
</FileTreeFile>
|
||||
</StateFrame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <ComposedFileTree />,
|
||||
}
|
||||
|
||||
export const DataDriven: Story = {
|
||||
render: () => <DataDrivenFileTree />,
|
||||
}
|
||||
|
||||
export const Icons: Story = {
|
||||
render: () => <IconGallery />,
|
||||
}
|
||||
|
||||
export const States: Story = {
|
||||
render: () => <VisualStates />,
|
||||
}
|
||||
378
packages/dify-ui/src/file-tree/index.tsx
Normal file
378
packages/dify-ui/src/file-tree/index.tsx
Normal file
@ -0,0 +1,378 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible'
|
||||
import { mergeProps } from '@base-ui/react/merge-props'
|
||||
import { useRender } from '@base-ui/react/use-render'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
} from 'react'
|
||||
import { cn } from '../cn'
|
||||
|
||||
const FileTreeLevelContext = createContext(1)
|
||||
|
||||
function useFileTreeLevel() {
|
||||
return useContext(FileTreeLevelContext)
|
||||
}
|
||||
|
||||
function getLabelText(children: ReactNode) {
|
||||
return typeof children === 'string' || typeof children === 'number'
|
||||
? String(children)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function renderGuides(level: number) {
|
||||
return Array.from({ length: Math.max(level - 1, 0) }, (_, index) => (
|
||||
<FileTreeGuide key={index} />
|
||||
))
|
||||
}
|
||||
|
||||
type FileTreeRowState = {
|
||||
selected: boolean
|
||||
disabled: boolean
|
||||
level: number
|
||||
}
|
||||
|
||||
function fileTreeRowClassName({
|
||||
className,
|
||||
}: {
|
||||
className?: string
|
||||
}) {
|
||||
return cn(
|
||||
'group/file-tree-row relative flex h-6 w-full min-w-0 cursor-pointer items-center rounded-md pl-2 pr-1.5 text-left outline-hidden select-none',
|
||||
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-state-accent-solid',
|
||||
'data-[selected]:bg-state-base-active',
|
||||
'data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-transparent',
|
||||
'aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:bg-transparent',
|
||||
className,
|
||||
)
|
||||
}
|
||||
|
||||
export type FileTreeRootProps = useRender.ComponentProps<'section'>
|
||||
|
||||
export function FileTreeRoot({
|
||||
render,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeRootProps) {
|
||||
const defaultProps: useRender.ElementProps<'section'> = {
|
||||
className: cn('flex min-w-0 flex-col gap-px p-1', className),
|
||||
children: (
|
||||
<FileTreeLevelContext.Provider value={1}>
|
||||
{children}
|
||||
</FileTreeLevelContext.Provider>
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'section',
|
||||
render,
|
||||
props: mergeProps<'section'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeListProps = useRender.ComponentProps<'ul'>
|
||||
|
||||
export function FileTreeList({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: FileTreeListProps) {
|
||||
const defaultProps: useRender.ElementProps<'ul'> = {
|
||||
className: cn('m-0 flex min-w-0 list-none flex-col gap-px p-0', className),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'ul',
|
||||
render,
|
||||
props: mergeProps<'ul'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeFolderProps
|
||||
= Omit<BaseCollapsible.Root.Props, 'render'>
|
||||
& {
|
||||
render?: BaseCollapsible.Root.Props['render']
|
||||
}
|
||||
|
||||
export function FileTreeFolder({
|
||||
render = <li />,
|
||||
className,
|
||||
...props
|
||||
}: FileTreeFolderProps) {
|
||||
return (
|
||||
<BaseCollapsible.Root
|
||||
render={render}
|
||||
className={cn('min-w-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileTreeFolderTriggerProps
|
||||
= Omit<BaseCollapsible.Trigger.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
level?: number
|
||||
}
|
||||
|
||||
export function FileTreeFolderTrigger({
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
level: levelProp,
|
||||
...props
|
||||
}: FileTreeFolderTriggerProps) {
|
||||
const contextLevel = useFileTreeLevel()
|
||||
const level = levelProp ?? contextLevel
|
||||
|
||||
return (
|
||||
<BaseCollapsible.Trigger
|
||||
className={fileTreeRowClassName({ className })}
|
||||
disabled={disabled}
|
||||
data-disabled={disabled || undefined}
|
||||
{...props}
|
||||
>
|
||||
{renderGuides(level)}
|
||||
<div className="flex min-w-0 flex-[1_0_0] items-center py-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</BaseCollapsible.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileTreeFolderPanelProps
|
||||
= Omit<BaseCollapsible.Panel.Props, 'render'>
|
||||
& {
|
||||
render?: BaseCollapsible.Panel.Props['render']
|
||||
}
|
||||
|
||||
export function FileTreeFolderPanel({
|
||||
render = <ul />,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeFolderPanelProps) {
|
||||
const level = useFileTreeLevel()
|
||||
|
||||
return (
|
||||
<BaseCollapsible.Panel
|
||||
render={render}
|
||||
className={cn('m-0 flex min-w-0 list-none flex-col gap-px p-0', className)}
|
||||
{...props}
|
||||
>
|
||||
<FileTreeLevelContext.Provider value={level + 1}>
|
||||
{children}
|
||||
</FileTreeLevelContext.Provider>
|
||||
</BaseCollapsible.Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileTreeFileProps
|
||||
= Omit<useRender.ComponentProps<'button', FileTreeRowState>, 'type'>
|
||||
& {
|
||||
level?: number
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export function FileTreeFile({
|
||||
render,
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
level: levelProp,
|
||||
selected = false,
|
||||
...props
|
||||
}: FileTreeFileProps) {
|
||||
const contextLevel = useFileTreeLevel()
|
||||
const level = levelProp ?? contextLevel
|
||||
const state: FileTreeRowState = {
|
||||
selected,
|
||||
disabled,
|
||||
level,
|
||||
}
|
||||
const defaultProps = {
|
||||
'type': 'button',
|
||||
'disabled': disabled,
|
||||
'data-selected': selected || undefined,
|
||||
'data-disabled': disabled || undefined,
|
||||
'aria-current': selected ? 'true' : undefined,
|
||||
'className': fileTreeRowClassName({ className }),
|
||||
'children': (
|
||||
<>
|
||||
{renderGuides(level)}
|
||||
<div className="flex min-w-0 flex-[1_0_0] items-center py-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
} as useRender.ElementProps<'button'>
|
||||
|
||||
const file = useRender({
|
||||
defaultTagName: 'button',
|
||||
render,
|
||||
state,
|
||||
props: mergeProps<'button'>(defaultProps, props),
|
||||
})
|
||||
|
||||
return <li className="min-w-0">{file}</li>
|
||||
}
|
||||
|
||||
export type FileTreeGuideProps = useRender.ComponentProps<'span'>
|
||||
|
||||
export function FileTreeGuide({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: FileTreeGuideProps) {
|
||||
const defaultProps: useRender.ElementProps<'span'> = {
|
||||
'aria-hidden': true,
|
||||
'className': cn(
|
||||
'relative h-6 w-5 shrink-0 before:absolute before:bottom-[-1px] before:left-1/2 before:top-0 before:w-px before:-translate-x-1/2 before:bg-divider-subtle',
|
||||
className,
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeIconType
|
||||
= 'folder'
|
||||
| 'file'
|
||||
| 'markdown'
|
||||
| 'json'
|
||||
| 'image'
|
||||
| 'code'
|
||||
| 'database'
|
||||
| 'text'
|
||||
| 'pdf'
|
||||
| 'table'
|
||||
| 'archive'
|
||||
|
||||
const fileTreeIconClassNames: Record<Exclude<FileTreeIconType, 'folder'>, string> = {
|
||||
file: 'i-ri-file-3-fill text-[#A4AABF]',
|
||||
markdown: 'i-ri-markdown-fill text-[#309BEC]',
|
||||
json: 'i-ri-braces-fill text-[#A4AABF]',
|
||||
image: 'i-ri-file-image-fill text-[#00B2EA]',
|
||||
code: 'i-ri-file-code-fill text-[#A4AABF]',
|
||||
database: 'i-ri-database-2-fill text-[#A4AABF]',
|
||||
text: 'i-ri-file-text-fill text-[#6F8BB5]',
|
||||
pdf: 'i-ri-file-pdf-2-fill text-[#EA3434]',
|
||||
table: 'i-ri-file-excel-fill text-[#01AC49]',
|
||||
archive: 'i-ri-file-zip-fill text-[#A4AABF]',
|
||||
}
|
||||
|
||||
export type FileTreeIconProps
|
||||
= Omit<useRender.ComponentProps<'span'>, 'children'>
|
||||
& {
|
||||
type?: FileTreeIconType
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function FileTreeIcon({
|
||||
type = 'file',
|
||||
render,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeIconProps) {
|
||||
const defaultProps: useRender.ElementProps<'span'> = {
|
||||
'aria-hidden': true,
|
||||
'className': cn('relative flex size-5 shrink-0 items-center justify-center text-text-secondary', className),
|
||||
'children': (
|
||||
<>
|
||||
{children ?? (
|
||||
type === 'folder'
|
||||
? (
|
||||
<>
|
||||
<span className="size-4 i-ri-folder-line group-data-panel-open/file-tree-row:hidden" />
|
||||
<span className="hidden size-4 text-text-accent i-ri-folder-open-line group-data-panel-open/file-tree-row:block" />
|
||||
</>
|
||||
)
|
||||
: <span className={cn('size-4', fileTreeIconClassNames[type])} />
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeLabelProps = useRender.ComponentProps<'span'>
|
||||
type FileTreeLabelElementProps = useRender.ElementProps<'span'> & {
|
||||
'data-label'?: string
|
||||
}
|
||||
|
||||
export function FileTreeLabel({
|
||||
render,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeLabelProps) {
|
||||
const labelText = getLabelText(children)
|
||||
const defaultProps = {
|
||||
'data-label': labelText,
|
||||
'className': cn(
|
||||
'min-w-0 truncate rounded-[5px] px-1 py-0.5',
|
||||
labelText && 'after:invisible after:block after:h-0 after:overflow-hidden after:system-sm-medium after:content-[attr(data-label)]',
|
||||
'system-sm-regular text-text-secondary group-data-[selected]/file-tree-row:system-sm-medium group-data-[selected]/file-tree-row:text-text-primary',
|
||||
className,
|
||||
),
|
||||
children,
|
||||
} satisfies FileTreeLabelElementProps
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeMetaProps = useRender.ComponentProps<'span'>
|
||||
|
||||
export function FileTreeMeta({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: FileTreeMetaProps) {
|
||||
const defaultProps: useRender.ElementProps<'span'> = {
|
||||
className: cn('min-w-0 shrink truncate system-xs-regular text-text-tertiary', className),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeBadgeProps = useRender.ComponentProps<'span'>
|
||||
|
||||
export function FileTreeBadge({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: FileTreeBadgeProps) {
|
||||
const defaultProps: useRender.ElementProps<'span'> = {
|
||||
className: cn(
|
||||
'ml-1 inline-flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary',
|
||||
className,
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user