Compare commits

..

8 Commits

Author SHA1 Message Date
ec67426ff6 refactor(openapi): migrate response handlers to @returns
Complete the request/response contract unification (GareArc review followup):
the remaining handlers that returned a model via manual `.model_dump(mode="json")`
now return the bare model behind `@returns`, so every openapi handler references
its response model once and doc == runtime.

Migrated: version, health, account info/session-revoke (x2), file upload, task
stop, human-input form submit, and workspaces list/detail/switch/invite/remove/
role. Handlers returning a bare dict (account info, version, health) now go through
`@returns` → `(body, status)`; HTTP behavior is unchanged. Health/task-stop/form-submit
now construct their advertised model instead of a hand-built dict.
2026-06-10 10:04:43 +08:00
695cbec567 fix(openapi): sanitize @accepts validation errors
`UnprocessableEntity(exc.json())` leaked the version-pinned pydantic docs url
and echoed the entire user-supplied input, and double-encoded JSON into the
`message` field. Use flask-restx `abort(422, message=..., errors=...)` with
`exc.errors(include_url=False, include_input=False, include_context=False)` so
the body is a structured object with no version/input leak and no double-encoding.

Also trim verbose comments/docstrings across the touched openapi files.
2026-06-10 10:04:43 +08:00
dbe0d23eb3 refactor(openapi): extend @accepts to the remaining body-validation handlers
Finish unifying request validation so every validation failure on the
openapi surface (except the excluded OAuth device-flow) returns 422:

- migrate workspace member invite (POST) and role update (PUT) and the
  human-input form submit (POST) onto @accepts(body=); drop the local
  _validate_body helper. human-input previously ran a bare model_validate
  with no handler, so a malformed body surfaced as 500 — now a clean 422.
  Service-layer domain errors keep their intentional 400s.
- harden @returns to pass (model, status, headers) tuples through verbatim
  instead of assuming a 2-tuple.
- stop test_contract from leaking test-only models into the shared
  openapi_ns, and assert the contract decorators' Swagger metadata
  (params/expect/responses) survives the guard wrapper stack.

Spec-neutral: the migrated handlers already advertised the same expect
models, so the generated swagger/markdown/contracts are unchanged.
2026-06-10 10:04:43 +08:00
fa137d37e5 [autofix.ci] apply automated fixes 2026-06-10 10:04:43 +08:00
2b4104513d refactor(openapi): fold request validation into @accepts/@returns
Migrate the openapi contract handlers (apps describe/list, account sessions,
permitted-external apps, workspace members, app run) off the duplicated
"@doc(params=...) + inline try/except model_validate + manual model_dump"
pattern onto @accepts/@returns. Each model is referenced once, so the advertised
Swagger contract and the enforced runtime contract can no longer drift.

Behavior change: GET /workspaces/<id>/members now returns 422 (was 400) on an
invalid query, unifying it with the other openapi validation failures.

app_run keeps its hand-written SSE response (no response model); only its body
validation moves to @accepts.
2026-06-10 10:04:43 +08:00
1af89d7fd2 feat(openapi): add @accepts/@returns request-contract decorators
Introduce two decorators in controllers/openapi/_contract.py that each own one
slice of the HTTP contract from a single Pydantic model reference:

- @accepts(query=, body=): validate the request, inject the validated model as a
  typed kwarg, and emit the Swagger param/body schema. Validation errors map to
  422 with ValidationError.json() (one shape).
- @returns(status, model): emit the response schema and serialize the handler's
  returned model via model_dump(mode="json"); non-model returns pass through.

Both sit below @auth_router.guard so auth precedes validation and the unit-test
__wrapped__ seam keeps unwrapping exactly the guard layer.
2026-06-10 10:04:43 +08:00
3fb1d3055e fix: agent mode missing file cards for BINARY_LINK and FILE type tool outputs (#36746)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-10 01:41:37 +00:00
yyh
a823649934 feat(dify-ui): file tree (#37235) 2026-06-09 10:41:09 +00:00
48 changed files with 1479 additions and 244 deletions

View File

@ -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

View File

@ -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'

View File

@ -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: |

View File

@ -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/

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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"

View File

@ -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

View 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

View File

@ -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")
)

View File

@ -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:

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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),

View File

@ -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),

View File

@ -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 |

View File

@ -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)

View 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

View File

@ -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"),
)

View File

@ -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))

View File

@ -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 = {

View File

@ -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'
}

View File

@ -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
*/

View File

@ -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:

View File

@ -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"

View 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')
})
})

View 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 />,
}

View 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),
})
}