mirror of
https://github.com/langgenius/dify.git
synced 2026-06-09 01:47:34 +08:00
Compare commits
433 Commits
dependabot
...
deploy/saa
| Author | SHA1 | Date | |
|---|---|---|---|
| 5619062ad1 | |||
| 38af34a742 | |||
| 1bf2748130 | |||
| a6084ca3c7 | |||
| d8b847dcf0 | |||
| 2509682e07 | |||
| 5582b35d56 | |||
| db83df9f9c | |||
| 0683c0e7a7 | |||
| 42b7bf8152 | |||
| bc33ef1b97 | |||
| 0f493a52a1 | |||
| fc7cd1100e | |||
| fc4f9db79a | |||
| e9b8a1606e | |||
| 453c4c4c5f | |||
| 58e25d0534 | |||
| d7b9f2a86b | |||
| 18d2423ed1 | |||
| 4176e7d146 | |||
| d0b376d31a | |||
| 3556611c2b | |||
| e37b4af0e8 | |||
| a0eb2ba0ff | |||
| 75f1094459 | |||
| f61f9634f8 | |||
| 56c569d6af | |||
| 11f78289d3 | |||
| 85e600e579 | |||
| 062341ab26 | |||
| 9c24b7bac5 | |||
| c56e9813bb | |||
| 6ea9ba5926 | |||
| 6291452020 | |||
| d46a4c05b1 | |||
| 7a9054fdea | |||
| 1820e6eab8 | |||
| 508baa782c | |||
| f5ee121d2c | |||
| f15a8f02ef | |||
| 0c4b36b3f5 | |||
| 2bf60a67ad | |||
| 37e1d452b8 | |||
| f6433097ad | |||
| 23b1038b99 | |||
| 0dd2d36d68 | |||
| 786c7190f0 | |||
| ed10b82bb1 | |||
| ba19aee2bf | |||
| 41106ef6c9 | |||
| db1aa683bc | |||
| 5ee7bedb56 | |||
| a88c15c906 | |||
| 12bd8d2aa8 | |||
| 813bfea730 | |||
| f0bcb77d55 | |||
| 44a89bf870 | |||
| 60ad023553 | |||
| 9c6a7679ac | |||
| 11b2ba29c1 | |||
| c52df73117 | |||
| 9b8d81c852 | |||
| b28b2892f1 | |||
| 7f5349e707 | |||
| 0aaa1df1b8 | |||
| bd41c5c3c0 | |||
| 95a2eea611 | |||
| d959c73884 | |||
| 19193891cf | |||
| 4746dd39d3 | |||
| a5e6f58285 | |||
| 4bf0398873 | |||
| e6db98ef64 | |||
| 6b694ce829 | |||
| 0ab3b5d677 | |||
| 346fabda27 | |||
| 3da0f12815 | |||
| 303cff1353 | |||
| 10c3849887 | |||
| 29295950ea | |||
| c61a7ce442 | |||
| 92fdbd5c51 | |||
| 09053ab760 | |||
| c1afdc030c | |||
| ab383969a8 | |||
| 8a0aab4d81 | |||
| d389284813 | |||
| 530e366440 | |||
| e4510a7d8f | |||
| 5c2f4709a5 | |||
| 19d7a9b5d9 | |||
| 1d063e3fd6 | |||
| 850c7e311f | |||
| de0ccbb960 | |||
| 0493552c73 | |||
| 407d3e28bb | |||
| 8a51b3a296 | |||
| 8fc3882042 | |||
| 0ffb9667d4 | |||
| e7886c1bac | |||
| 41894ad182 | |||
| 95ea709c91 | |||
| 3a01b91a45 | |||
| f50abac3f9 | |||
| 7f44b2f601 | |||
| 9583f17960 | |||
| c71d2ac460 | |||
| 505ad3994c | |||
| 87c6a82df0 | |||
| 0d97d44222 | |||
| 64f1d125a6 | |||
| 090ef21881 | |||
| 56ed953e2a | |||
| f61f15371a | |||
| 1a7542052e | |||
| 009c6adc8f | |||
| 6d26f6ea73 | |||
| 35e21de9f8 | |||
| 8c925b1422 | |||
| b0d3347de9 | |||
| bf2a35bff8 | |||
| 63d50a61d3 | |||
| 101a6fcc53 | |||
| d407c1fbf7 | |||
| fe6ee8aa04 | |||
| 25228e3cde | |||
| 8049da9331 | |||
| e404195c8c | |||
| b3c7110768 | |||
| 7b2b21c348 | |||
| 3d3a1f4f90 | |||
| 274944f05e | |||
| a0d53b9f07 | |||
| a24d1a0bbc | |||
| fffa89a10e | |||
| 422461f360 | |||
| 2ce913aaac | |||
| 30f0a69fea | |||
| 04e8c6127f | |||
| 20e37a0457 | |||
| 36c7209301 | |||
| ccc9122980 | |||
| 9143d44ec6 | |||
| 1f856960f0 | |||
| a8218d5809 | |||
| 70ec17bc34 | |||
| e4ea9d2e07 | |||
| fd342ccac0 | |||
| 929a1da26c | |||
| b72bfe060d | |||
| dfc7f136ef | |||
| 15d66814ee | |||
| 5f109213a5 | |||
| a53828e826 | |||
| 3a2ea826ff | |||
| 4581ce2f45 | |||
| 3925b2bf4f | |||
| 5744148e6d | |||
| 6ba42e6d73 | |||
| aff22cb5ed | |||
| 58c4a174ba | |||
| c98f65cbcb | |||
| add7c75f18 | |||
| 75e74ee8b9 | |||
| 40a5236553 | |||
| f820813e9f | |||
| c4c3a2b265 | |||
| 99be8b34c8 | |||
| 777265d898 | |||
| 7024913866 | |||
| 3c862c3e98 | |||
| fb497c60dd | |||
| 075af9cd44 | |||
| 18e07cac9a | |||
| 284c1027da | |||
| ba06ed5f41 | |||
| 3fd9d5eb14 | |||
| a70e8eb2b5 | |||
| 15d2714e9d | |||
| f36852646f | |||
| 72c3cd0d67 | |||
| fd0cb47a81 | |||
| 87382673f3 | |||
| 3c716a6eee | |||
| 8da34fb60b | |||
| f0efb73fd0 | |||
| e2bd2355e9 | |||
| 73b50f5ede | |||
| 52cfe62d8d | |||
| f293253a7a | |||
| 1ac3ad4c81 | |||
| 2d6fd70733 | |||
| 639c8d5967 | |||
| 74c9b7fddd | |||
| be997384f3 | |||
| acbea6701c | |||
| 86ac4dadd6 | |||
| f3e11ec0ee | |||
| c9c4fca7a7 | |||
| d23eddc924 | |||
| d65c7229f2 | |||
| 66508326f9 | |||
| 310f49229e | |||
| f400be4280 | |||
| 2855ab3a15 | |||
| e2fd5421d2 | |||
| ff37ba83b4 | |||
| 7b97ec57ef | |||
| 791296cc8d | |||
| 19d34b5a93 | |||
| 5c315ea7fe | |||
| e525880773 | |||
| d82e30561f | |||
| d2508db11f | |||
| 7f76fe68ea | |||
| 08bbd3bfdf | |||
| ac09702d08 | |||
| b14d1e60ec | |||
| b072136ca0 | |||
| c8b6ec5fb0 | |||
| 0b5b4271f0 | |||
| fd60339625 | |||
| f56f93f5c2 | |||
| 2921d27929 | |||
| 98e3bff509 | |||
| 6c43fa459e | |||
| d2eae74b5e | |||
| 87a3980c76 | |||
| 27bdee85fe | |||
| cca8295ad8 | |||
| f8cc85ce28 | |||
| 4d1b3605b5 | |||
| d94be05f68 | |||
| fea7590779 | |||
| 71ba903d4c | |||
| 44831839d1 | |||
| 88c3512471 | |||
| 67dee6e07e | |||
| 95d7fa997b | |||
| 2d4e494162 | |||
| 4a0b177eee | |||
| 15b2a8fdb7 | |||
| 65098a6b4f | |||
| 8055f8840c | |||
| f033f91a68 | |||
| 0b98319bd3 | |||
| 386de25e26 | |||
| d30805353a | |||
| 21c5825508 | |||
| 2d324add39 | |||
| 62beaf493e | |||
| b34e5aa915 | |||
| 2ea19c2b1c | |||
| 5712f29e8b | |||
| 93f7404c6b | |||
| 4f631d6f4c | |||
| 2b3e15cc83 | |||
| 5e1fac09bb | |||
| 35956247ab | |||
| 343531b9dc | |||
| 236f389fce | |||
| 5a2604265c | |||
| 6a8aaa5a36 | |||
| 0ad1e8c2d9 | |||
| 8c7540f698 | |||
| bf345136eb | |||
| c9ed50c3ae | |||
| c78c603a38 | |||
| 48b38446a3 | |||
| 8a8bec4bc6 | |||
| 89571bd241 | |||
| afee58cca7 | |||
| 76a55535f2 | |||
| 29cb993042 | |||
| 00581a4daa | |||
| bfc71bb087 | |||
| a95a6ea263 | |||
| 264e97a4c2 | |||
| 95936a8bac | |||
| ac8a1107ca | |||
| 43f67ef2d1 | |||
| c118fe9ad2 | |||
| 0c96426d91 | |||
| 67fee14770 | |||
| d94006162d | |||
| 3d53cee8a9 | |||
| 1acd1b568a | |||
| 68f939f3b3 | |||
| 1f4b76ba7e | |||
| 4d974d8f72 | |||
| 1dc12d1661 | |||
| 82345977cd | |||
| 83c943bc21 | |||
| 7e34e2347a | |||
| 94a376a5a7 | |||
| 33f6b0c9aa | |||
| 2b130d0d2a | |||
| 33d95ab23a | |||
| 7a8a92082b | |||
| 4f9adfb9ae | |||
| f3974d6176 | |||
| ef00f850e4 | |||
| cb2e404eb6 | |||
| 14e7fc87e4 | |||
| 40b4c3476d | |||
| 1c641d2b44 | |||
| c3c9a349cc | |||
| 169293c8da | |||
| 7815228395 | |||
| dcd40b5004 | |||
| bcc4b208c7 | |||
| c252006644 | |||
| 9e5668c233 | |||
| 52ce49b3c6 | |||
| e90aa76ba2 | |||
| de9373e1b8 | |||
| 58923f38e6 | |||
| 8486a5b213 | |||
| 28a8be0d5f | |||
| f2d4d5b267 | |||
| f62a59a18a | |||
| b488812714 | |||
| 755760b97c | |||
| 955c3fb797 | |||
| 0c9aa20047 | |||
| 065246a9a7 | |||
| 0d12b5ab1c | |||
| 514dcae189 | |||
| 228dd84a91 | |||
| 336ddad096 | |||
| 92bb9a17b7 | |||
| b8868dab90 | |||
| 94225682cd | |||
| 18b6568c2a | |||
| a3a9ded29b | |||
| de78a26920 | |||
| c54d029e7c | |||
| ad4b9dc2c3 | |||
| cdec0c69a6 | |||
| 53acc3726c | |||
| b1d393f4d9 | |||
| 62e9bdd70d | |||
| d36c76c20e | |||
| f525e1a5eb | |||
| e2f779b20d | |||
| e198d6305c | |||
| 5e67514265 | |||
| b63896de87 | |||
| e463389f2c | |||
| cda348ca10 | |||
| ca48050666 | |||
| 9c0f592f34 | |||
| b70241ad36 | |||
| 4abe622b2e | |||
| 16c32c82e3 | |||
| 46424513d1 | |||
| 2c4baa20d8 | |||
| b0ae553f2e | |||
| 0266a12ee5 | |||
| 9d7765d5fd | |||
| d4ef983f42 | |||
| 018f36711d | |||
| dacd333e4a | |||
| b079a26314 | |||
| 7e953ebe0b | |||
| b4d28fca54 | |||
| 728c6b8201 | |||
| f56e23b5fd | |||
| 5600cefa53 | |||
| 561eb9cbd2 | |||
| 83766ca694 | |||
| 678be94d22 | |||
| 9e852429be | |||
| d93c5028f1 | |||
| 54f189305e | |||
| a610a24507 | |||
| 05e8a94bb5 | |||
| b2e2e7b60b | |||
| e7d2e66ff5 | |||
| c51069685c | |||
| 28c208f36a | |||
| 53a1386b87 | |||
| 0e366c7300 | |||
| 939bdde373 | |||
| 13dfa3aba4 | |||
| 2705a7c1db | |||
| 258a751b8c | |||
| 5a35d3d9cd | |||
| c3fbafae83 | |||
| f727c8f838 | |||
| 90af4c39b4 | |||
| f7c3a4e4cb | |||
| be7d043edd | |||
| cef8fe3a4b | |||
| afe0e6c393 | |||
| 37309b931e | |||
| 6a83c6705c | |||
| 3e75d5e443 | |||
| 7be8a5b883 | |||
| 80dcb344f4 | |||
| b029c9b1cd | |||
| 6cb97e9201 | |||
| 4ef2e952bd | |||
| cc5545339c | |||
| 0a8c46a3a7 | |||
| 65770903d1 | |||
| 5a6ba2ffb5 | |||
| aa53afe07d | |||
| 4740a89f4a | |||
| 328db3d67a | |||
| 88062fb247 | |||
| 045da59220 | |||
| 948b0f6bc7 | |||
| 14a59f6e44 | |||
| f9f361113e | |||
| eea6f59307 | |||
| 718f69dc43 | |||
| 82a2ba9264 | |||
| 6c8e032fbb | |||
| 28c2c3bfd3 | |||
| 9d463e1024 | |||
| 7f87616625 | |||
| 43a04ed0c2 | |||
| 5083edd0ce | |||
| 8306fa41b9 | |||
| 8f33305e90 | |||
| 7077a43c1c | |||
| 884a43ae0a | |||
| 914f89f478 | |||
| 163153db18 | |||
| 49d890d514 | |||
| 0292bc2728 | |||
| 5c21120977 |
33
.agents/skills/karpathy-guidelines/SKILL.md
Normal file
33
.agents/skills/karpathy-guidelines/SKILL.md
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
name: karpathy-guidelines
|
||||
description: Lightweight coding guardrails for making focused, simple, and verifiable changes in this repo. Use for all coding work.
|
||||
---
|
||||
|
||||
# Karpathy Guidelines
|
||||
|
||||
Use this skill whenever you touch code in this repository.
|
||||
|
||||
## Principles
|
||||
|
||||
- Keep the change small and directly tied to the user request.
|
||||
- Prefer the simplest implementation that fits the existing codebase.
|
||||
- Read the nearby code first, then match its patterns.
|
||||
- Avoid unrelated refactors, broad rewrites, or style churn.
|
||||
- Preserve existing behavior unless the user explicitly asked to change it.
|
||||
- Treat regressions as a signal to narrow the change, not to add workaround layers.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect the current implementation and tests around the change.
|
||||
2. Make the smallest coherent edit.
|
||||
3. Add or update focused tests when the behavior changes or the risk is non-trivial.
|
||||
4. Run the narrowest relevant verification first.
|
||||
5. Report exactly what was verified and anything left unverified.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Does this change solve the stated problem without expanding scope?
|
||||
- Did it preserve existing route/component/data-flow semantics?
|
||||
- Are new abstractions justified by real complexity?
|
||||
- Are tests focused on the behavior that could regress?
|
||||
- Are unrelated files and generated artifacts left alone?
|
||||
14
.github/workflows/api-tests.yml
vendored
14
.github/workflows/api-tests.yml
vendored
@ -29,13 +29,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@ -91,13 +91,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@ -142,13 +142,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@ -195,7 +195,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
disable_search: true
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
run: echo "autofix.ci updates pull request branches, not merge group refs."
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check Docker Compose inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
@ -66,7 +66,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
|
||||
14
.github/workflows/build-push.yml
vendored
14
.github/workflows/build-push.yml
vendored
@ -68,7 +68,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
@ -78,13 +78,13 @@ jobs:
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env[matrix.image_name_env] }}
|
||||
|
||||
- name: Build Docker image
|
||||
id: build
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: ${{ matrix.build_context }}
|
||||
@ -124,10 +124,10 @@ jobs:
|
||||
file: "web/Dockerfile"
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Validate Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
push: false
|
||||
context: ${{ matrix.build_context }}
|
||||
@ -156,14 +156,14 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env[matrix.image_name_env] }}
|
||||
tags: |
|
||||
|
||||
6
.github/workflows/cli-release.yml
vendored
6
.github/workflows/cli-release.yml
vendored
@ -35,7 +35,7 @@ jobs:
|
||||
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -98,7 +98,7 @@ jobs:
|
||||
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
@ -114,7 +114,7 @@ jobs:
|
||||
run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.0.2
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
|
||||
with:
|
||||
bun-version-file: cli/.bun-version
|
||||
|
||||
|
||||
2
.github/workflows/cli-smoke.yml
vendored
2
.github/workflows/cli-smoke.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout cli ref
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/cli-tests.yml
vendored
4
.github/workflows/cli-tests.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
directory: cli/coverage
|
||||
flags: cli
|
||||
|
||||
12
.github/workflows/db-migration-test.yml
vendored
12
.github/workflows/db-migration-test.yml
vendored
@ -13,13 +13,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@ -40,7 +40,7 @@ jobs:
|
||||
cp envs/middleware.env.example middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
@ -63,13 +63,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
|
||||
6
.github/workflows/docker-build.yml
vendored
6
.github/workflows/docker-build.yml
vendored
@ -53,7 +53,7 @@ jobs:
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
push: false
|
||||
@ -77,10 +77,10 @@ jobs:
|
||||
file: "web/Dockerfile"
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
push: false
|
||||
context: ${{ matrix.context }}
|
||||
|
||||
2
.github/workflows/hotfix-cherry-pick.yml
vendored
2
.github/workflows/hotfix-cherry-pick.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/main-ci.yml
vendored
2
.github/workflows/main-ci.yml
vendored
@ -48,7 +48,7 @@ jobs:
|
||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
|
||||
4
.github/workflows/pyrefly-diff.yml
vendored
4
.github/workflows/pyrefly-diff.yml
vendored
@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@ -21,10 +21,10 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
|
||||
steps:
|
||||
- name: Checkout default branch (trusted code)
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
4
.github/workflows/pyrefly-type-coverage.yml
vendored
4
.github/workflows/pyrefly-type-coverage.yml
vendored
@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
days-before-issue-stale: 15
|
||||
days-before-issue-close: 3
|
||||
|
||||
10
.github/workflows/style.yml
vendored
10
.github/workflows/style.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Setup UV and Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.12"
|
||||
@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -171,7 +171,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/tool-test-sdks.yaml
vendored
2
.github/workflows/tool-test-sdks.yaml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
working-directory: sdks/nodejs-client
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/translate-i18n-claude.yml
vendored
4
.github/workflows/translate-i18n-claude.yml
vendored
@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@fbda2eb1bdc90d319b8d853f5deb53bca199a7c1 # v1.0.140
|
||||
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/trigger-i18n-sync.yml
vendored
2
.github/workflows/trigger-i18n-sync.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
4
.github/workflows/vdb-tests-full.yml
vendored
4
.github/workflows/vdb-tests-full.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -36,7 +36,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
4
.github/workflows/vdb-tests.yml
vendored
4
.github/workflows/vdb-tests.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
4
.github/workflows/web-e2e.yml
vendored
4
.github/workflows/web-e2e.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -28,7 +28,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
||||
10
.github/workflows/web-tests.yml
vendored
10
.github/workflows/web-tests.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
directory: web/coverage
|
||||
flags: web
|
||||
@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -117,7 +117,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
directory: packages/dify-ui/coverage
|
||||
flags: dify-ui
|
||||
|
||||
@ -11,6 +11,7 @@ from .data_migration import (
|
||||
migration_data_wizard,
|
||||
)
|
||||
from .plugin import (
|
||||
backfill_plugin_auto_upgrade,
|
||||
extract_plugins,
|
||||
extract_unique_plugins,
|
||||
install_plugins,
|
||||
@ -49,6 +50,7 @@ from .vector import (
|
||||
__all__ = [
|
||||
"add_qdrant_index",
|
||||
"archive_workflow_runs",
|
||||
"backfill_plugin_auto_upgrade",
|
||||
"clean_expired_messages",
|
||||
"clean_workflow_runs",
|
||||
"cleanup_orphaned_draft_variables",
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import click
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.engine import CursorResult
|
||||
|
||||
from configs import dify_config
|
||||
@ -15,11 +16,13 @@ from core.plugin.plugin_service import PluginService
|
||||
from core.tools.utils.system_encryption import encrypt_system_params
|
||||
from extensions.ext_database import db
|
||||
from models import Tenant
|
||||
from models.account import TenantPluginAutoUpgradeStrategy
|
||||
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
|
||||
from models.provider_ids import DatasourceProviderID, ToolProviderID
|
||||
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from models.tools import ToolOAuthSystemClient
|
||||
from services.plugin.data_migration import PluginDataMigration
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -402,6 +405,110 @@ def migrate_data_for_plugin():
|
||||
click.echo(click.style("Migrate data for plugin completed.", fg="green"))
|
||||
|
||||
|
||||
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
|
||||
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
|
||||
stmt = (
|
||||
select(TenantPluginAutoUpgradeStrategy.tenant_id)
|
||||
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)
|
||||
.having(func.count(func.distinct(TenantPluginAutoUpgradeStrategy.category)) < category_count)
|
||||
.order_by(TenantPluginAutoUpgradeStrategy.tenant_id)
|
||||
)
|
||||
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
def _count_auto_upgrade_strategy_tenant_ids(limit: int | None) -> int:
|
||||
candidate_stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).subquery()
|
||||
return db.session.scalar(select(func.count()).select_from(candidate_stmt)) or 0
|
||||
|
||||
|
||||
def _iter_auto_upgrade_strategy_tenant_ids(limit: int | None):
|
||||
stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).execution_options(yield_per=1000)
|
||||
yield from db.session.scalars(stmt)
|
||||
|
||||
|
||||
@click.command(
|
||||
"backfill-plugin-auto-upgrade",
|
||||
help="Backfill category-scoped plugin auto-upgrade strategies and normalize plugin lists.",
|
||||
)
|
||||
@click.option("--tenant-id", multiple=True, help="Tenant ID to backfill. Can be passed multiple times.")
|
||||
@click.option("--limit", type=int, default=None, help="Maximum number of candidate tenants to process.")
|
||||
@click.option("--batch-size", type=int, default=500, show_default=True, help="Progress reporting batch size.")
|
||||
@click.option("--dry-run", is_flag=True, help="Only print candidate tenant count.")
|
||||
def backfill_plugin_auto_upgrade(
|
||||
tenant_id: tuple[str, ...],
|
||||
limit: int | None,
|
||||
batch_size: int,
|
||||
dry_run: bool,
|
||||
):
|
||||
"""
|
||||
Backfill historical auto-upgrade strategies after the category column exists.
|
||||
|
||||
Missing category rows are created from the tenant's tool/default row. Pure default
|
||||
strategies become latest for model plugins and fix-only for all other categories.
|
||||
Tenants with include/exclude plugin IDs are split
|
||||
by installed plugin category using plugin daemon metadata.
|
||||
"""
|
||||
start_at = time.perf_counter()
|
||||
candidate_count = len(tenant_id) if tenant_id else _count_auto_upgrade_strategy_tenant_ids(limit)
|
||||
click.echo(click.style(f"Found {candidate_count} candidate tenants.", fg="yellow"))
|
||||
|
||||
if dry_run:
|
||||
elapsed = time.perf_counter() - start_at
|
||||
click.echo(click.style(f"Dry run completed. elapsed={elapsed:.2f}s", fg="green"))
|
||||
return
|
||||
|
||||
tenant_ids = list(tenant_id) if tenant_id else _iter_auto_upgrade_strategy_tenant_ids(limit)
|
||||
|
||||
backfilled_count = 0
|
||||
created_count = 0
|
||||
normalized_count = 0
|
||||
skipped_count = 0
|
||||
failed_count = 0
|
||||
for index, current_tenant_id in enumerate(tenant_ids, start=1):
|
||||
try:
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(
|
||||
current_tenant_id,
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
click.echo(click.style(f"Failed tenant {current_tenant_id}: {str(e)}", fg="red"))
|
||||
continue
|
||||
|
||||
if result.created_count > 0:
|
||||
backfilled_count += 1
|
||||
created_count += result.created_count
|
||||
elif not result.normalized:
|
||||
skipped_count += 1
|
||||
if result.normalized:
|
||||
normalized_count += 1
|
||||
|
||||
if batch_size > 0 and index % batch_size == 0:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Processed {index}/{candidate_count} tenants. "
|
||||
f"backfilled={backfilled_count}, created_rows={created_count}, "
|
||||
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
|
||||
f"elapsed={time.perf_counter() - start_at:.2f}s",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
elapsed = time.perf_counter() - start_at
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Backfill plugin auto-upgrade strategy categories completed. "
|
||||
f"backfilled={backfilled_count}, created_rows={created_count}, "
|
||||
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
|
||||
f"elapsed={elapsed:.2f}s",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command("extract-plugins", help="Extract plugins.")
|
||||
@click.option("--output_file", prompt=True, help="The file to store the extracted plugins.", default="plugins.jsonl")
|
||||
@click.option("--workers", prompt=True, help="The number of workers to extract plugins.", default=10)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
@ -14,7 +15,12 @@ from werkzeug.exceptions import BadRequest
|
||||
|
||||
from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse
|
||||
from controllers.common.helpers import FileInfo
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
register_enum_models,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model, with_session
|
||||
from controllers.console.workspace.models import LoadBalancingPayload
|
||||
@ -36,12 +42,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from libs.helper import build_icon_url, to_timestamp
|
||||
from libs.helper import build_icon_url, dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account, App, DatasetPermissionEnum, Workflow
|
||||
from models.model import IconType
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.dsl_entities import ImportMode, ImportStatus
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
@ -68,10 +74,14 @@ _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
|
||||
AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"]
|
||||
|
||||
|
||||
class AppListQuery(BaseModel):
|
||||
class AppListBaseQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)")
|
||||
mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter")
|
||||
sort_by: AppListSortBy = Field(
|
||||
default="last_modified",
|
||||
description="Sort apps by last modified, recently created, or earliest created",
|
||||
)
|
||||
name: str | None = Field(default=None, description="Filter by app name")
|
||||
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
|
||||
creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs")
|
||||
@ -114,6 +124,14 @@ class AppListQuery(BaseModel):
|
||||
raise ValueError("Invalid UUID format in creator_ids.") from exc
|
||||
|
||||
|
||||
class AppListQuery(AppListBaseQuery):
|
||||
pass
|
||||
|
||||
|
||||
class StarredAppListQuery(AppListBaseQuery):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
|
||||
normalized: dict[str, str | list[str]] = {}
|
||||
indexed_tag_ids: list[tuple[int, str]] = []
|
||||
@ -377,6 +395,7 @@ class AppPartial(ResponseModel):
|
||||
create_user_name: str | None = None
|
||||
author_name: str | None = None
|
||||
has_draft_trigger: bool | None = None
|
||||
is_starred: bool = False
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore
|
||||
@property
|
||||
@ -446,12 +465,54 @@ class AppExportResponse(ResponseModel):
|
||||
data: str
|
||||
|
||||
|
||||
def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None:
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
app_ids = [str(app.id) for app in apps]
|
||||
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
|
||||
if len(res) != len(app_ids):
|
||||
raise BadRequest("Invalid app id in webapp auth")
|
||||
|
||||
for app in apps:
|
||||
if str(app.id) in res:
|
||||
app.access_mode = res[str(app.id)].access_mode
|
||||
|
||||
workflow_capable_app_ids = [str(app.id) for app in apps if app.mode in {"workflow", "advanced-chat"}]
|
||||
draft_trigger_app_ids: set[str] = set()
|
||||
if workflow_capable_app_ids:
|
||||
draft_workflows = (
|
||||
session.execute(
|
||||
select(Workflow).where(
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
Workflow.app_id.in_(workflow_capable_app_ids),
|
||||
Workflow.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
trigger_node_types = TRIGGER_NODE_TYPES
|
||||
for workflow in draft_workflows:
|
||||
node_id = None
|
||||
try:
|
||||
for node_id, node_data in workflow.walk_nodes():
|
||||
if node_data.get("type") in trigger_node_types:
|
||||
draft_trigger_app_ids.add(str(workflow.app_id))
|
||||
break
|
||||
except Exception:
|
||||
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
|
||||
continue
|
||||
|
||||
for app in apps:
|
||||
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
|
||||
|
||||
|
||||
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
|
||||
register_response_schema_models(console_ns, RedirectUrlResponse, SimpleResultResponse)
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
AppListQuery,
|
||||
StarredAppListQuery,
|
||||
CreateAppPayload,
|
||||
UpdateAppPayload,
|
||||
CopyAppPayload,
|
||||
@ -495,7 +556,7 @@ register_schema_models(
|
||||
class AppListApi(Resource):
|
||||
@console_ns.doc("list_apps")
|
||||
@console_ns.doc(description="Get list of applications with pagination and filtering")
|
||||
@console_ns.expect(console_ns.models[AppListQuery.__name__])
|
||||
@console_ns.doc(params=query_params_from_model(AppListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -511,6 +572,7 @@ class AppListApi(Resource):
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
mode=args.mode,
|
||||
sort_by=args.sort_by,
|
||||
name=args.name,
|
||||
tag_ids=args.tag_ids,
|
||||
creator_ids=args.creator_ids,
|
||||
@ -524,46 +586,7 @@ class AppListApi(Resource):
|
||||
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
return empty.model_dump(mode="json"), 200
|
||||
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
app_ids = [str(app.id) for app in app_pagination.items]
|
||||
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
|
||||
if len(res) != len(app_ids):
|
||||
raise BadRequest("Invalid app id in webapp auth")
|
||||
|
||||
for app in app_pagination.items:
|
||||
if str(app.id) in res:
|
||||
app.access_mode = res[str(app.id)].access_mode
|
||||
|
||||
workflow_capable_app_ids = [
|
||||
str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
|
||||
]
|
||||
draft_trigger_app_ids: set[str] = set()
|
||||
if workflow_capable_app_ids:
|
||||
draft_workflows = (
|
||||
session.execute(
|
||||
select(Workflow).where(
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
Workflow.app_id.in_(workflow_capable_app_ids),
|
||||
Workflow.tenant_id == current_tenant_id,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
trigger_node_types = TRIGGER_NODE_TYPES
|
||||
for workflow in draft_workflows:
|
||||
node_id = None
|
||||
try:
|
||||
for node_id, node_data in workflow.walk_nodes():
|
||||
if node_data.get("type") in trigger_node_types:
|
||||
draft_trigger_app_ids.add(str(workflow.app_id))
|
||||
break
|
||||
except Exception:
|
||||
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
|
||||
continue
|
||||
|
||||
for app in app_pagination.items:
|
||||
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
|
||||
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
|
||||
|
||||
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
|
||||
return pagination_model.model_dump(mode="json"), 200
|
||||
@ -599,6 +622,78 @@ class AppListApi(Resource):
|
||||
return app_detail.model_dump(mode="json"), 201
|
||||
|
||||
|
||||
@console_ns.route("/apps/starred")
|
||||
class StarredAppListApi(Resource):
|
||||
@console_ns.doc("list_starred_apps")
|
||||
@console_ns.doc(description="Get applications starred by the current account")
|
||||
@console_ns.doc(params=query_params_from_model(StarredAppListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_session(write=False)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
|
||||
args = StarredAppListQuery.model_validate(_normalize_app_list_query_args(request.args))
|
||||
params = StarredAppListParams(
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
mode=args.mode,
|
||||
sort_by=args.sort_by,
|
||||
name=args.name,
|
||||
tag_ids=args.tag_ids,
|
||||
creator_ids=args.creator_ids,
|
||||
is_created_by_me=args.is_created_by_me,
|
||||
)
|
||||
|
||||
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params)
|
||||
if not app_pagination:
|
||||
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
return empty.model_dump(mode="json"), 200
|
||||
|
||||
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
|
||||
|
||||
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
|
||||
return pagination_model.model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/star")
|
||||
class AppStarApi(Resource):
|
||||
@console_ns.doc("star_app")
|
||||
@console_ns.doc(description="Star an application for the current account")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(404, "App not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_current_user_id
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, current_user_id: str, app_model: App):
|
||||
AppService.star_app(session, app=app_model, account_id=current_user_id)
|
||||
return dump_response(SimpleResultResponse, {"result": "success"})
|
||||
|
||||
@console_ns.doc("unstar_app")
|
||||
@console_ns.doc(description="Remove the current account's star from an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(404, "App not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_current_user_id
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def delete(self, session: Session, current_user_id: str, app_model: App):
|
||||
AppService.unstar_app(session, app=app_model, account_id=current_user_id)
|
||||
return dump_response(SimpleResultResponse, {"result": "success"})
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>")
|
||||
class AppApi(Resource):
|
||||
@console_ns.doc("get_app_detail")
|
||||
|
||||
@ -155,19 +155,28 @@ class InstalledAppsListApi(Resource):
|
||||
if current_user.current_tenant is None:
|
||||
raise ValueError("current_user.current_tenant must not be None")
|
||||
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant)
|
||||
installed_app_list: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": installed_app.id,
|
||||
"app": installed_app.app,
|
||||
"app_owner_tenant_id": installed_app.app_owner_tenant_id,
|
||||
"is_pinned": installed_app.is_pinned,
|
||||
"last_used_at": installed_app.last_used_at,
|
||||
"editable": current_user.role in {"owner", "admin"},
|
||||
"uninstallable": current_tenant_id == installed_app.app_owner_tenant_id,
|
||||
}
|
||||
for installed_app in installed_apps
|
||||
if installed_app.app is not None
|
||||
]
|
||||
|
||||
app_ids = [installed_app.app_id for installed_app in installed_apps]
|
||||
apps = db.session.scalars(select(App).where(App.id.in_(app_ids))).all() if app_ids else []
|
||||
apps_by_id = {app.id: app for app in apps}
|
||||
|
||||
installed_app_list: list[dict[str, Any]] = []
|
||||
for installed_app in installed_apps:
|
||||
app_model = apps_by_id.get(installed_app.app_id)
|
||||
if app_model is None:
|
||||
continue
|
||||
|
||||
installed_app_list.append(
|
||||
{
|
||||
"id": installed_app.id,
|
||||
"app": app_model,
|
||||
"app_owner_tenant_id": installed_app.app_owner_tenant_id,
|
||||
"is_pinned": installed_app.is_pinned,
|
||||
"last_used_at": installed_app.last_used_at,
|
||||
"editable": current_user.role in {"owner", "admin"},
|
||||
"uninstallable": current_tenant_id == installed_app.app_owner_tenant_id,
|
||||
}
|
||||
)
|
||||
|
||||
# filter out apps that user doesn't have access to
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
|
||||
@ -65,15 +65,28 @@ class RecommendedAppListResponse(ResponseModel):
|
||||
categories: list[str]
|
||||
|
||||
|
||||
class LearnDifyAppListResponse(ResponseModel):
|
||||
recommended_apps: list[RecommendedAppResponse]
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
RecommendedAppsQuery,
|
||||
RecommendedAppInfoResponse,
|
||||
RecommendedAppResponse,
|
||||
RecommendedAppListResponse,
|
||||
LearnDifyAppListResponse,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_language(language: str | None, user: Account) -> str:
|
||||
if language and language in languages:
|
||||
return language
|
||||
if user.interface_language:
|
||||
return user.interface_language
|
||||
return languages[0]
|
||||
|
||||
|
||||
@console_ns.route("/explore/apps")
|
||||
class RecommendedAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
|
||||
@ -84,13 +97,7 @@ class RecommendedAppListApi(Resource):
|
||||
def get(self, current_user: Account):
|
||||
# language args
|
||||
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
language = args.language
|
||||
if language and language in languages:
|
||||
language_prefix = language
|
||||
elif current_user.interface_language:
|
||||
language_prefix = current_user.interface_language
|
||||
else:
|
||||
language_prefix = languages[0]
|
||||
language_prefix = _resolve_language(args.language, current_user)
|
||||
|
||||
return RecommendedAppListResponse.model_validate(
|
||||
RecommendedAppService.get_recommended_apps_and_categories(language_prefix),
|
||||
@ -98,6 +105,23 @@ class RecommendedAppListApi(Resource):
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/explore/apps/learn-dify")
|
||||
class LearnDifyAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
def get(self, current_user: Account):
|
||||
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
language_prefix = _resolve_language(args.language, current_user)
|
||||
|
||||
return LearnDifyAppListResponse.model_validate(
|
||||
RecommendedAppService.get_learn_dify_apps(language_prefix),
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/explore/apps/<uuid:app_id>")
|
||||
class RecommendedAppApi(Resource):
|
||||
@login_required
|
||||
|
||||
@ -1,28 +1,51 @@
|
||||
import io
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Literal
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
from flask import request, send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.fields import SuccessResponse
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
register_enum_models,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.workspace import plugin_permission_required
|
||||
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
|
||||
from core.helper.position_helper import is_filtered
|
||||
from core.plugin.entities.plugin import PluginCategory, PluginDeclaration, PluginInstallationSource
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from libs.helper import dump_response
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermission
|
||||
from models.provider_ids import ToolProviderID
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from services.plugin.plugin_parameter_service import PluginParameterService
|
||||
from services.plugin.plugin_permission_service import PluginPermissionService
|
||||
from services.tools.tools_transform_service import ToolTransformService
|
||||
|
||||
|
||||
class AutoUpgradeSettingsResponse(TypedDict):
|
||||
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
|
||||
upgrade_time_of_day: int
|
||||
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
|
||||
exclude_plugins: list[str]
|
||||
include_plugins: list[str]
|
||||
|
||||
|
||||
class ParserList(BaseModel):
|
||||
@ -30,6 +53,11 @@ class ParserList(BaseModel):
|
||||
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
|
||||
|
||||
|
||||
class PluginCategoryListQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, description="Page number")
|
||||
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
|
||||
|
||||
|
||||
class ParserLatest(BaseModel):
|
||||
plugin_ids: list[str]
|
||||
|
||||
@ -88,8 +116,8 @@ class ParserUninstall(BaseModel):
|
||||
|
||||
|
||||
class ParserPermissionChange(BaseModel):
|
||||
install_permission: TenantPluginPermission.InstallPermission
|
||||
debug_permission: TenantPluginPermission.DebugPermission
|
||||
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
|
||||
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
|
||||
|
||||
|
||||
class ParserDynamicOptions(BaseModel):
|
||||
@ -125,13 +153,40 @@ class PluginAutoUpgradeSettingsPayload(BaseModel):
|
||||
include_plugins: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ParserPreferencesChange(BaseModel):
|
||||
permission: PluginPermissionSettingsPayload
|
||||
class PluginAutoUpgradeChangeResponse(ResponseModel):
|
||||
success: bool
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class PluginAutoUpgradeSettingsResponseModel(ResponseModel):
|
||||
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
|
||||
upgrade_time_of_day: int
|
||||
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
|
||||
exclude_plugins: list[str]
|
||||
include_plugins: list[str]
|
||||
|
||||
|
||||
class PluginAutoUpgradeFetchResponse(ResponseModel):
|
||||
category: TenantPluginAutoUpgradeStrategy.PluginCategory
|
||||
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
|
||||
|
||||
|
||||
class ParserAutoUpgradeChange(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
category: TenantPluginAutoUpgradeStrategy.PluginCategory
|
||||
auto_upgrade: PluginAutoUpgradeSettingsPayload
|
||||
|
||||
|
||||
class ParserAutoUpgradeFetch(BaseModel):
|
||||
category: TenantPluginAutoUpgradeStrategy.PluginCategory
|
||||
|
||||
|
||||
class ParserExcludePlugin(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
plugin_id: str
|
||||
category: TenantPluginAutoUpgradeStrategy.PluginCategory
|
||||
|
||||
|
||||
class ParserReadme(BaseModel):
|
||||
@ -145,9 +200,67 @@ class PluginDebuggingKeyResponse(ResponseModel):
|
||||
port: int
|
||||
|
||||
|
||||
class PluginCategoryInstalledPluginResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
tenant_id: str
|
||||
plugin_id: str
|
||||
plugin_unique_identifier: str
|
||||
endpoints_active: int
|
||||
endpoints_setups: int
|
||||
installation_id: str
|
||||
declaration: PluginDeclaration
|
||||
runtime_type: str
|
||||
version: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
source: PluginInstallationSource
|
||||
checksum: str
|
||||
meta: Mapping[str, Any]
|
||||
|
||||
|
||||
class PluginCategoryBuiltinToolResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
author: str
|
||||
name: str
|
||||
label: I18nObject
|
||||
description: I18nObject
|
||||
parameters: list[Mapping[str, Any]] | None = None
|
||||
labels: list[str]
|
||||
output_schema: Mapping[str, object]
|
||||
|
||||
|
||||
class PluginCategoryBuiltinToolProviderResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: str
|
||||
author: str
|
||||
name: str
|
||||
plugin_id: str | None
|
||||
plugin_unique_identifier: str | None
|
||||
description: I18nObject
|
||||
icon: str | Mapping[str, str]
|
||||
icon_dark: str | Mapping[str, str] | None
|
||||
label: I18nObject
|
||||
type: ToolProviderType
|
||||
team_credentials: Mapping[str, object]
|
||||
is_team_authorization: bool
|
||||
allow_delete: bool
|
||||
tools: list[PluginCategoryBuiltinToolResponse]
|
||||
labels: list[str]
|
||||
|
||||
|
||||
class PluginCategoryListResponse(ResponseModel):
|
||||
plugins: list[PluginCategoryInstalledPluginResponse]
|
||||
builtin_tools: list[PluginCategoryBuiltinToolProviderResponse]
|
||||
has_more: bool
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ParserList,
|
||||
PluginCategoryListQuery,
|
||||
PluginAutoUpgradeSettingsPayload,
|
||||
PluginPermissionSettingsPayload,
|
||||
ParserLatest,
|
||||
@ -164,21 +277,57 @@ register_schema_models(
|
||||
ParserPermissionChange,
|
||||
ParserDynamicOptions,
|
||||
ParserDynamicOptionsWithCredentials,
|
||||
ParserPreferencesChange,
|
||||
ParserAutoUpgradeChange,
|
||||
ParserAutoUpgradeFetch,
|
||||
ParserExcludePlugin,
|
||||
ParserReadme,
|
||||
)
|
||||
register_response_schema_models(console_ns, PluginDebuggingKeyResponse, SuccessResponse)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
PluginAutoUpgradeChangeResponse,
|
||||
PluginAutoUpgradeFetchResponse,
|
||||
PluginAutoUpgradeSettingsResponseModel,
|
||||
PluginDebuggingKeyResponse,
|
||||
PluginCategoryInstalledPluginResponse,
|
||||
PluginCategoryBuiltinToolResponse,
|
||||
PluginCategoryBuiltinToolProviderResponse,
|
||||
PluginCategoryListResponse,
|
||||
SuccessResponse,
|
||||
)
|
||||
|
||||
register_enum_models(
|
||||
console_ns,
|
||||
TenantPluginPermission.DebugPermission,
|
||||
TenantPluginAutoUpgradeStrategy.PluginCategory,
|
||||
TenantPluginAutoUpgradeStrategy.UpgradeMode,
|
||||
TenantPluginAutoUpgradeStrategy.StrategySetting,
|
||||
TenantPluginPermission.InstallPermission,
|
||||
)
|
||||
|
||||
|
||||
def _default_auto_upgrade_settings(
|
||||
tenant_id: str,
|
||||
category: TenantPluginAutoUpgradeStrategy.PluginCategory,
|
||||
) -> AutoUpgradeSettingsResponse:
|
||||
return {
|
||||
"strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category),
|
||||
"upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id),
|
||||
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
"exclude_plugins": [],
|
||||
"include_plugins": [],
|
||||
}
|
||||
|
||||
|
||||
def _auto_upgrade_settings_to_dict(strategy: TenantPluginAutoUpgradeStrategy) -> AutoUpgradeSettingsResponse:
|
||||
return {
|
||||
"strategy_setting": strategy.strategy_setting,
|
||||
"upgrade_time_of_day": strategy.upgrade_time_of_day,
|
||||
"upgrade_mode": strategy.upgrade_mode,
|
||||
"exclude_plugins": strategy.exclude_plugins,
|
||||
"include_plugins": strategy.include_plugins,
|
||||
}
|
||||
|
||||
|
||||
def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
|
||||
"""
|
||||
Read the uploaded file and validate its actual size before delegating to the plugin service.
|
||||
@ -193,6 +342,33 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
|
||||
return content
|
||||
|
||||
|
||||
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
|
||||
db_builtin_providers = {
|
||||
str(ToolProviderID(provider.provider)): provider
|
||||
for provider in ToolManager.list_default_builtin_providers(tenant_id)
|
||||
}
|
||||
builtin_providers = []
|
||||
|
||||
for provider in ToolManager.list_hardcoded_providers():
|
||||
if is_filtered(
|
||||
include_set=dify_config.POSITION_TOOL_INCLUDES_SET,
|
||||
exclude_set=dify_config.POSITION_TOOL_EXCLUDES_SET,
|
||||
data=provider,
|
||||
name_func=lambda provider_controller: provider_controller.entity.identity.name,
|
||||
):
|
||||
continue
|
||||
|
||||
user_provider = ToolTransformService.builtin_provider_to_user_provider(
|
||||
provider_controller=provider,
|
||||
db_provider=db_builtin_providers.get(provider.entity.identity.name),
|
||||
decrypt_credentials=False,
|
||||
)
|
||||
ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider)
|
||||
builtin_providers.append(user_provider)
|
||||
|
||||
return [provider.to_dict() for provider in BuiltinToolProviderSort.sort(builtin_providers)]
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/debugging-key")
|
||||
class PluginDebuggingKeyApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[PluginDebuggingKeyResponse.__name__])
|
||||
@ -230,6 +406,41 @@ class PluginListApi(Resource):
|
||||
return jsonable_encoder({"plugins": plugins_with_total.list, "total": plugins_with_total.total})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/<string:category>/list")
|
||||
class PluginCategoryListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(PluginCategoryListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[PluginCategoryListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, category: str):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
try:
|
||||
plugin_category = PluginCategory(category)
|
||||
except ValueError:
|
||||
return {"code": "invalid_param", "message": "invalid plugin category"}, 400
|
||||
|
||||
try:
|
||||
plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size)
|
||||
except PluginDaemonClientSideError as e:
|
||||
return {"code": "plugin_error", "message": e.description}, 400
|
||||
|
||||
builtin_tools = []
|
||||
if plugin_category == PluginCategory.Tool:
|
||||
builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id)
|
||||
|
||||
return dump_response(
|
||||
PluginCategoryListResponse,
|
||||
{
|
||||
"plugins": jsonable_encoder(plugins.list),
|
||||
"builtin_tools": builtin_tools,
|
||||
"has_more": plugins.has_more,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/list/latest-versions")
|
||||
class PluginListLatestVersionsApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ParserLatest.__name__])
|
||||
@ -632,11 +843,13 @@ class PluginChangePermissionApi(Resource):
|
||||
|
||||
tenant_id = current_tenant_id
|
||||
|
||||
return {
|
||||
"success": PluginPermissionService.change_permission(
|
||||
tenant_id, args.install_permission, args.debug_permission
|
||||
)
|
||||
}
|
||||
set_permission_result = PluginPermissionService.change_permission(
|
||||
tenant_id, args.install_permission, args.debug_permission
|
||||
)
|
||||
if not set_permission_result:
|
||||
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
|
||||
|
||||
return jsonable_encoder({"success": True})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/permission/fetch")
|
||||
@ -725,9 +938,10 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
|
||||
return jsonable_encoder({"options": options})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/preferences/change")
|
||||
class PluginChangePreferencesApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ParserPreferencesChange.__name__])
|
||||
@console_ns.route("/workspaces/current/plugin/auto-upgrade/change")
|
||||
class PluginChangeAutoUpgradeApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ParserAutoUpgradeChange.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeChangeResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -736,38 +950,17 @@ class PluginChangePreferencesApi(Resource):
|
||||
if not user.is_admin_or_owner:
|
||||
raise Forbidden()
|
||||
|
||||
args = ParserPreferencesChange.model_validate(console_ns.payload)
|
||||
|
||||
permission = args.permission
|
||||
|
||||
install_permission = permission.install_permission
|
||||
debug_permission = permission.debug_permission
|
||||
args = ParserAutoUpgradeChange.model_validate(console_ns.payload)
|
||||
|
||||
auto_upgrade = args.auto_upgrade
|
||||
|
||||
strategy_setting = auto_upgrade.strategy_setting
|
||||
upgrade_time_of_day = auto_upgrade.upgrade_time_of_day
|
||||
upgrade_mode = auto_upgrade.upgrade_mode
|
||||
exclude_plugins = auto_upgrade.exclude_plugins
|
||||
include_plugins = auto_upgrade.include_plugins
|
||||
|
||||
# set permission
|
||||
set_permission_result = PluginPermissionService.change_permission(
|
||||
tenant_id,
|
||||
install_permission,
|
||||
debug_permission,
|
||||
)
|
||||
if not set_permission_result:
|
||||
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
|
||||
|
||||
# set auto upgrade strategy
|
||||
set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy(
|
||||
tenant_id,
|
||||
strategy_setting,
|
||||
upgrade_time_of_day,
|
||||
upgrade_mode,
|
||||
exclude_plugins,
|
||||
include_plugins,
|
||||
auto_upgrade.strategy_setting,
|
||||
auto_upgrade.upgrade_time_of_day,
|
||||
auto_upgrade.upgrade_mode,
|
||||
auto_upgrade.exclude_plugins,
|
||||
auto_upgrade.include_plugins,
|
||||
category=args.category,
|
||||
)
|
||||
if not set_auto_upgrade_strategy_result:
|
||||
return jsonable_encoder({"success": False, "message": "Failed to set auto upgrade strategy"})
|
||||
@ -775,48 +968,36 @@ class PluginChangePreferencesApi(Resource):
|
||||
return jsonable_encoder({"success": True})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/preferences/fetch")
|
||||
class PluginFetchPreferencesApi(Resource):
|
||||
@console_ns.route("/workspaces/current/plugin/auto-upgrade/fetch")
|
||||
class PluginFetchAutoUpgradeApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ParserAutoUpgradeFetch))
|
||||
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeFetchResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
|
||||
permission = PluginPermissionService.get_permission(tenant_id)
|
||||
permission_dict = {
|
||||
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
|
||||
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
|
||||
}
|
||||
args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True))
|
||||
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category)
|
||||
auto_upgrade_dict = (
|
||||
_auto_upgrade_settings_to_dict(auto_upgrade)
|
||||
if auto_upgrade
|
||||
else _default_auto_upgrade_settings(tenant_id, args.category)
|
||||
)
|
||||
|
||||
if permission:
|
||||
permission_dict["install_permission"] = permission.install_permission
|
||||
permission_dict["debug_permission"] = permission.debug_permission
|
||||
|
||||
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id)
|
||||
auto_upgrade_dict = {
|
||||
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
|
||||
"upgrade_time_of_day": 0,
|
||||
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
"exclude_plugins": [],
|
||||
"include_plugins": [],
|
||||
}
|
||||
|
||||
if auto_upgrade:
|
||||
auto_upgrade_dict = {
|
||||
"strategy_setting": auto_upgrade.strategy_setting,
|
||||
"upgrade_time_of_day": auto_upgrade.upgrade_time_of_day,
|
||||
"upgrade_mode": auto_upgrade.upgrade_mode,
|
||||
"exclude_plugins": auto_upgrade.exclude_plugins,
|
||||
"include_plugins": auto_upgrade.include_plugins,
|
||||
return jsonable_encoder(
|
||||
{
|
||||
"category": args.category,
|
||||
"auto_upgrade": auto_upgrade_dict,
|
||||
}
|
||||
|
||||
return jsonable_encoder({"permission": permission_dict, "auto_upgrade": auto_upgrade_dict})
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/preferences/autoupgrade/exclude")
|
||||
@console_ns.route("/workspaces/current/plugin/auto-upgrade/exclude")
|
||||
class PluginAutoUpgradeExcludePluginApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ParserExcludePlugin.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -826,7 +1007,9 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
|
||||
|
||||
args = ParserExcludePlugin.model_validate(console_ns.payload)
|
||||
|
||||
return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id)})
|
||||
return jsonable_encoder(
|
||||
{"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id, args.category)}
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/readme")
|
||||
|
||||
@ -20,7 +20,7 @@ from controllers.console.wraps import (
|
||||
setup_required,
|
||||
)
|
||||
from core.db.session_factory import session_factory
|
||||
from core.entities.mcp_provider import MCPAuthentication, MCPConfiguration
|
||||
from core.entities.mcp_provider import IdentityMode, MCPAuthentication, MCPConfiguration
|
||||
from core.mcp.auth.auth_flow import auth, handle_callback
|
||||
from core.mcp.error import MCPAuthError, MCPError, MCPRefreshTokenError
|
||||
from core.mcp.mcp_client import MCPClient
|
||||
@ -210,6 +210,30 @@ class MCPProviderBasePayload(BaseModel):
|
||||
configuration: dict[str, Any] | None = Field(default_factory=dict)
|
||||
headers: dict[str, Any] | None = Field(default_factory=dict)
|
||||
authentication: dict[str, Any] | None = Field(default_factory=dict)
|
||||
# None means "leave unchanged" on update; the controller resolves it to a
|
||||
# concrete IdentityMode before calling the service (see _resolve_identity_mode).
|
||||
identity_mode: IdentityMode | None = None
|
||||
|
||||
|
||||
def _resolve_identity_mode(requested: IdentityMode | None, *, current: IdentityMode) -> IdentityMode:
|
||||
"""Resolve the effective MCP identity_mode for a create/update request.
|
||||
|
||||
Keeps two API-layer concerns out of the service so the service always
|
||||
receives a concrete value:
|
||||
|
||||
* ``None`` means "leave unchanged" (update semantics) — fall back to
|
||||
``current`` (``IdentityMode.OFF`` for a brand-new provider).
|
||||
* Identity forwarding is an enterprise-only capability. On non-enterprise
|
||||
deployments any non-OFF value is coerced back to OFF so a persisted row
|
||||
can never imply forwarding that the runtime won't perform. This gates the
|
||||
API surface to match the backend gate in
|
||||
``MCPTool._forwarding_requested`` — both the API and the backend
|
||||
invocation must be gated on ``dify_config.ENTERPRISE_ENABLED``.
|
||||
"""
|
||||
mode = current if requested is None else requested
|
||||
if mode != IdentityMode.OFF and not dify_config.ENTERPRISE_ENABLED:
|
||||
return IdentityMode.OFF
|
||||
return mode
|
||||
|
||||
|
||||
class MCPProviderCreatePayload(MCPProviderBasePayload):
|
||||
@ -1000,6 +1024,7 @@ class ToolProviderMCPApi(Resource):
|
||||
headers=payload.headers or {},
|
||||
configuration=configuration,
|
||||
authentication=authentication,
|
||||
identity_mode=_resolve_identity_mode(payload.identity_mode, current=IdentityMode.OFF),
|
||||
)
|
||||
|
||||
# 2) Try to fetch tools immediately after creation so they appear without a second save.
|
||||
@ -1054,6 +1079,11 @@ class ToolProviderMCPApi(Resource):
|
||||
# Step 3: Perform database update in a transaction
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
service = MCPToolManageService(session=session)
|
||||
# Resolve "leave unchanged" (None) against the stored value, and gate
|
||||
# the result on ENTERPRISE_ENABLED — both are API-layer concerns, so
|
||||
# the service receives a concrete IdentityMode.
|
||||
existing = service.get_provider(provider_id=payload.provider_id, tenant_id=current_tenant_id)
|
||||
identity_mode = _resolve_identity_mode(payload.identity_mode, current=IdentityMode(existing.identity_mode))
|
||||
service.update_provider(
|
||||
tenant_id=current_tenant_id,
|
||||
provider_id=payload.provider_id,
|
||||
@ -1067,6 +1097,7 @@ class ToolProviderMCPApi(Resource):
|
||||
configuration=configuration,
|
||||
authentication=authentication,
|
||||
validation_result=validation_result,
|
||||
identity_mode=identity_mode,
|
||||
)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@ -31,9 +31,9 @@ from controllers.console.wraps import (
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import TimestampField, dump_response, to_timestamp
|
||||
from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
|
||||
from services.account_service import TenantService
|
||||
from services.billing_service import BillingService, SubscriptionPlan
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
@ -144,6 +144,7 @@ tenants_fields = {
|
||||
"plan": fields.String,
|
||||
"status": fields.String,
|
||||
"created_at": TimestampField,
|
||||
"last_opened_at": OptionalTimestampField,
|
||||
"current": fields.Boolean,
|
||||
}
|
||||
|
||||
@ -158,7 +159,12 @@ class TenantListApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
tenants = TenantService.get_join_tenants(current_user)
|
||||
tenant_rows: list[tuple[Tenant, TenantAccountJoin]] = [
|
||||
(tenant, membership)
|
||||
for tenant, membership in TenantService.get_workspaces_for_account(db.session, current_user.id)
|
||||
if tenant.status == TenantStatus.NORMAL
|
||||
]
|
||||
tenants = [tenant for tenant, _ in tenant_rows]
|
||||
tenant_dicts = []
|
||||
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
|
||||
is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED
|
||||
@ -171,7 +177,7 @@ class TenantListApi(Resource):
|
||||
if not tenant_plans:
|
||||
logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path")
|
||||
|
||||
for tenant in tenants:
|
||||
for tenant, membership in tenant_rows:
|
||||
plan: str = CloudPlan.SANDBOX
|
||||
if is_saas:
|
||||
tenant_plan = tenant_plans.get(tenant.id)
|
||||
@ -190,6 +196,7 @@ class TenantListApi(Resource):
|
||||
"name": tenant.name,
|
||||
"status": tenant.status,
|
||||
"created_at": tenant.created_at,
|
||||
"last_opened_at": membership.last_opened_at,
|
||||
"plan": plan,
|
||||
"current": tenant.id == current_tenant_id if current_tenant_id else False,
|
||||
}
|
||||
|
||||
@ -37,6 +37,13 @@ class MCPSupportGrantType(StrEnum):
|
||||
REFRESH_TOKEN = "refresh_token"
|
||||
|
||||
|
||||
class IdentityMode(StrEnum):
|
||||
"""How Dify forwards the end-user's identity to an MCP server."""
|
||||
|
||||
OFF = "off"
|
||||
IDP_TOKEN = "idp_token"
|
||||
|
||||
|
||||
class MCPAuthentication(BaseModel):
|
||||
client_id: str
|
||||
client_secret: str | None = None
|
||||
@ -76,6 +83,8 @@ class MCPProviderEntity(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
identity_mode: IdentityMode = IdentityMode.OFF
|
||||
|
||||
@classmethod
|
||||
def from_db_model(cls, db_provider: MCPToolProvider) -> MCPProviderEntity:
|
||||
"""Create entity from database model with decryption"""
|
||||
@ -96,6 +105,7 @@ class MCPProviderEntity(BaseModel):
|
||||
icon=db_provider.icon or "",
|
||||
created_at=db_provider.created_at,
|
||||
updated_at=db_provider.updated_at,
|
||||
identity_mode=IdentityMode(db_provider.identity_mode),
|
||||
)
|
||||
|
||||
@property
|
||||
@ -170,6 +180,7 @@ class MCPProviderEntity(BaseModel):
|
||||
"updated_at": int(self.updated_at.timestamp()),
|
||||
"label": I18nObject(en_US=self.name, zh_Hans=self.name).to_dict(),
|
||||
"description": I18nObject(en_US="", zh_Hans="").to_dict(),
|
||||
"identity_mode": self.identity_mode,
|
||||
}
|
||||
|
||||
# Add configuration
|
||||
|
||||
@ -316,6 +316,7 @@ class IndexingRunner:
|
||||
qa_preview_texts: list[QAPreviewDetail] = []
|
||||
|
||||
total_segments = 0
|
||||
deleted_preview_images = False
|
||||
# doc_form represents the segmentation method (general, parent-child, QA)
|
||||
index_type = doc_form
|
||||
index_processor = IndexProcessorFactory(index_type).init_index_processor()
|
||||
@ -368,6 +369,10 @@ class IndexingRunner:
|
||||
upload_file_id,
|
||||
)
|
||||
db.session.delete(image_file)
|
||||
deleted_preview_images = True
|
||||
|
||||
if deleted_preview_images:
|
||||
db.session.commit()
|
||||
|
||||
if doc_form and doc_form == "qa_model":
|
||||
return IndexingEstimate(total_segments=total_segments * 20, qa_preview=qa_preview_texts, preview=[])
|
||||
|
||||
@ -40,6 +40,7 @@ class MCPClientWithAuthRetry(MCPClient):
|
||||
provider_entity: MCPProviderEntity | None = None,
|
||||
authorization_code: str | None = None,
|
||||
by_server_id: bool = False,
|
||||
forward_identity_active: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize the MCP client with auth retry capability.
|
||||
@ -52,12 +53,15 @@ class MCPClientWithAuthRetry(MCPClient):
|
||||
provider_entity: Provider entity for authentication
|
||||
authorization_code: Optional authorization code for initial auth
|
||||
by_server_id: Whether to look up provider by server ID
|
||||
forward_identity_active: If True, suppress the static-OAuth retry
|
||||
on 401 — the forwarded identity must propagate as-is.
|
||||
"""
|
||||
super().__init__(server_url, headers, timeout, sse_read_timeout)
|
||||
|
||||
self.provider_entity = provider_entity
|
||||
self.authorization_code = authorization_code
|
||||
self.by_server_id = by_server_id
|
||||
self.forward_identity_active = forward_identity_active
|
||||
self._has_retried = False
|
||||
|
||||
def _handle_auth_error(self, error: MCPAuthError) -> None:
|
||||
@ -73,6 +77,8 @@ class MCPClientWithAuthRetry(MCPClient):
|
||||
Raises:
|
||||
MCPAuthError: If authentication fails or max retries reached
|
||||
"""
|
||||
if self.forward_identity_active:
|
||||
raise error
|
||||
if not self.provider_entity:
|
||||
raise error
|
||||
if self._has_retried:
|
||||
|
||||
@ -7,7 +7,7 @@ import threading
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
from typing import TYPE_CHECKING, Any, TypedDict, override
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from cachetools import LRUCache
|
||||
@ -221,6 +221,7 @@ class TracingProviderConfigEntry(TypedDict):
|
||||
|
||||
|
||||
class OpsTraceProviderConfigMap(collections.UserDict[str, TracingProviderConfigEntry]):
|
||||
@override
|
||||
def __getitem__(self, key: str) -> TracingProviderConfigEntry:
|
||||
try:
|
||||
match key:
|
||||
|
||||
@ -168,6 +168,7 @@ class PluginInstallTask(BasePluginEntity):
|
||||
class PluginInstallTaskStartResponse(BaseModel):
|
||||
all_installed: bool = Field(description="Whether all plugins are installed.")
|
||||
task_id: str = Field(description="The ID of the install task.")
|
||||
task: PluginInstallTask | None = Field(default=None, description="The install task.")
|
||||
|
||||
|
||||
class PluginVerification(BaseModel):
|
||||
@ -206,6 +207,11 @@ class PluginListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class PluginListWithoutTotalResponse(BaseModel):
|
||||
list: list[PluginEntity]
|
||||
has_more: bool
|
||||
|
||||
|
||||
class PluginDynamicSelectOptionsResponse(BaseModel):
|
||||
options: Sequence[PluginParameterOption] = Field(description="The options of the dynamic select.")
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ from requests import HTTPError
|
||||
from core.plugin.entities.bundle import PluginBundleDependency
|
||||
from core.plugin.entities.plugin import (
|
||||
MissingPluginDependency,
|
||||
PluginCategory,
|
||||
PluginDeclaration,
|
||||
PluginEntity,
|
||||
PluginInstallation,
|
||||
@ -16,6 +17,7 @@ from core.plugin.entities.plugin_daemon import (
|
||||
PluginInstallTask,
|
||||
PluginInstallTaskStartResponse,
|
||||
PluginListResponse,
|
||||
PluginListWithoutTotalResponse,
|
||||
PluginReadmeResponse,
|
||||
)
|
||||
from core.plugin.impl.base import BasePluginClient
|
||||
@ -74,6 +76,16 @@ class PluginInstaller(BasePluginClient):
|
||||
params={"page": page, "page_size": page_size, "response_type": "paged"},
|
||||
)
|
||||
|
||||
def list_plugins_by_category(
|
||||
self, tenant_id: str, category: PluginCategory, page: int, page_size: int
|
||||
) -> PluginListWithoutTotalResponse:
|
||||
return self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
f"plugin/{tenant_id}/management/{category.value}/list",
|
||||
PluginListWithoutTotalResponse,
|
||||
params={"page": page, "page_size": page_size, "response_type": "paged"},
|
||||
)
|
||||
|
||||
def upload_pkg(
|
||||
self,
|
||||
tenant_id: str,
|
||||
|
||||
@ -23,6 +23,7 @@ from core.helper.marketplace import download_plugin_pkg
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
|
||||
from core.plugin.entities.bundle import PluginBundleDependency
|
||||
from core.plugin.entities.plugin import (
|
||||
PluginCategory,
|
||||
PluginDeclaration,
|
||||
PluginEntity,
|
||||
PluginInstallation,
|
||||
@ -33,6 +34,7 @@ from core.plugin.entities.plugin_daemon import (
|
||||
PluginInstallTask,
|
||||
PluginInstallTaskStatus,
|
||||
PluginListResponse,
|
||||
PluginListWithoutTotalResponse,
|
||||
PluginModelProviderEntity,
|
||||
PluginVerification,
|
||||
)
|
||||
@ -295,6 +297,19 @@ class PluginService:
|
||||
plugins = manager.list_plugins_with_total(tenant_id, page, page_size)
|
||||
return plugins
|
||||
|
||||
@staticmethod
|
||||
def list_by_category(
|
||||
tenant_id: str, category: PluginCategory, page: int, page_size: int
|
||||
) -> PluginListWithoutTotalResponse:
|
||||
"""
|
||||
List plugins in one category with a has-more cursor signal and without calculating total.
|
||||
|
||||
The daemon scans tenant installations in the existing list order and stops once it finds one extra match.
|
||||
This keeps pagination usable before category is persisted on installation rows.
|
||||
"""
|
||||
manager = PluginInstaller()
|
||||
return manager.list_plugins_by_category(tenant_id, category, page, page_size)
|
||||
|
||||
@staticmethod
|
||||
def list_installations_from_ids(tenant_id: str, ids: Sequence[str]) -> Sequence[PluginInstallation]:
|
||||
"""
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import base64
|
||||
import logging
|
||||
import pickle
|
||||
from typing import Any, cast
|
||||
from typing import Any, cast, override
|
||||
|
||||
import numpy as np
|
||||
from sqlalchemy import select
|
||||
@ -25,6 +25,7 @@ class CacheEmbedding(Embeddings):
|
||||
def __init__(self, model_instance: ModelInstance):
|
||||
self._model_instance = model_instance
|
||||
|
||||
@override
|
||||
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Embed search docs in batches of 10."""
|
||||
# use doc embedding cache or store if not exists
|
||||
@ -106,6 +107,7 @@ class CacheEmbedding(Embeddings):
|
||||
|
||||
return text_embeddings
|
||||
|
||||
@override
|
||||
def embed_multimodal_documents(self, multimodel_documents: list[dict[str, Any]]) -> list[list[float]]:
|
||||
"""Embed file documents."""
|
||||
# use doc embedding cache or store if not exists
|
||||
@ -189,6 +191,7 @@ class CacheEmbedding(Embeddings):
|
||||
|
||||
return multimodel_embeddings
|
||||
|
||||
@override
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
"""Embed query text."""
|
||||
# use doc embedding cache or store if not exists
|
||||
@ -232,6 +235,7 @@ class CacheEmbedding(Embeddings):
|
||||
|
||||
return embedding_results # type: ignore
|
||||
|
||||
@override
|
||||
def embed_multimodal_query(self, multimodel_document: dict[str, Any]) -> list[float]:
|
||||
"""Embed multimodal documents."""
|
||||
# use doc embedding cache or store if not exists
|
||||
|
||||
@ -1,13 +1,32 @@
|
||||
"""Abstract interface for document loader implementations."""
|
||||
"""Excel document extractor used for RAG ingestion.
|
||||
|
||||
Supports cell hyperlinks for both `.xls` and `.xlsx`, and embedded worksheet images
|
||||
for `.xlsx` files by converting them into markdown image links. Embedded images are
|
||||
stored with deterministic keys derived from the source upload file and anchor cell so
|
||||
retries can safely reuse the same assets.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import TypedDict, override
|
||||
|
||||
import pandas as pd
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from core.rag.extractor.extractor_base import BaseExtractor
|
||||
from core.rag.models.document import Document
|
||||
from extensions.ext_storage import storage
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import UploadFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Candidate(TypedDict):
|
||||
@ -16,17 +35,42 @@ class Candidate(TypedDict):
|
||||
map: dict[int, str]
|
||||
|
||||
|
||||
class SheetImageCandidate(TypedDict):
|
||||
anchor: tuple[int, int]
|
||||
content_hash: str
|
||||
file_key: str
|
||||
image_bytes: bytes
|
||||
image_ext: str
|
||||
|
||||
|
||||
class ExcelExtractor(BaseExtractor):
|
||||
"""Load Excel files.
|
||||
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to load.
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: str, encoding: str | None = None, autodetect_encoding: bool = False):
|
||||
_file_path: str
|
||||
_encoding: str | None
|
||||
_autodetect_encoding: bool
|
||||
_tenant_id: str | None
|
||||
_user_id: str | None
|
||||
_source_file_id: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: str,
|
||||
tenant_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
source_file_id: str | None = None,
|
||||
encoding: str | None = None,
|
||||
autodetect_encoding: bool = False,
|
||||
):
|
||||
"""Initialize with file path."""
|
||||
self._file_path = file_path
|
||||
self._tenant_id = tenant_id
|
||||
self._user_id = user_id
|
||||
self._source_file_id = source_file_id
|
||||
self._encoding = encoding
|
||||
self._autodetect_encoding = autodetect_encoding
|
||||
|
||||
@ -37,7 +81,8 @@ class ExcelExtractor(BaseExtractor):
|
||||
file_extension = os.path.splitext(self._file_path)[-1].lower()
|
||||
|
||||
if file_extension == ".xlsx":
|
||||
wb = load_workbook(self._file_path, read_only=True, data_only=True)
|
||||
# Worksheet drawing objects, including embedded images, are not available in read-only mode.
|
||||
wb = load_workbook(self._file_path, data_only=True)
|
||||
try:
|
||||
for sheet_name in wb.sheetnames:
|
||||
sheet = wb[sheet_name]
|
||||
@ -45,10 +90,15 @@ class ExcelExtractor(BaseExtractor):
|
||||
if not column_map:
|
||||
continue
|
||||
start_row = header_row_idx + 1
|
||||
sheet_image_map = self._extract_images_from_sheet(
|
||||
sheet_name=sheet_name,
|
||||
sheet=sheet,
|
||||
valid_columns={column_idx + 1 for column_idx in column_map},
|
||||
min_row=start_row,
|
||||
)
|
||||
for row in sheet.iter_rows(min_row=start_row, max_col=max_col_idx, values_only=False):
|
||||
if all(cell.value is None for cell in row):
|
||||
continue
|
||||
page_content = []
|
||||
row_has_content = False
|
||||
for col_idx, cell in enumerate(row):
|
||||
value = cell.value
|
||||
if col_idx in column_map:
|
||||
@ -56,14 +106,27 @@ class ExcelExtractor(BaseExtractor):
|
||||
if hasattr(cell, "hyperlink") and cell.hyperlink:
|
||||
target = getattr(cell.hyperlink, "target", None)
|
||||
if target:
|
||||
value = f"[{value}]({target})"
|
||||
display_value = value if value is not None and str(value).strip() else target
|
||||
value = f"[{display_value}]({target})"
|
||||
cell_row = getattr(cell, "row", None)
|
||||
cell_column = getattr(cell, "column", None)
|
||||
image_links = (
|
||||
sheet_image_map.get((cell_row, cell_column), [])
|
||||
if isinstance(cell_row, int) and isinstance(cell_column, int)
|
||||
else []
|
||||
)
|
||||
if value is None:
|
||||
value = ""
|
||||
elif not isinstance(value, str):
|
||||
value = str(value)
|
||||
value = value.strip().replace('"', '\\"')
|
||||
if image_links:
|
||||
value = " ".join(filter(None, [value, " ".join(image_links)]))
|
||||
value = value.strip()
|
||||
if value:
|
||||
row_has_content = True
|
||||
value = value.replace('"', '\\"')
|
||||
page_content.append(f'"{col_name}":"{value}"')
|
||||
if page_content:
|
||||
if row_has_content and page_content:
|
||||
documents.append(
|
||||
Document(page_content=";".join(page_content), metadata={"source": self._file_path})
|
||||
)
|
||||
@ -89,6 +152,166 @@ class ExcelExtractor(BaseExtractor):
|
||||
|
||||
return documents
|
||||
|
||||
def _extract_images_from_sheet(
|
||||
self, sheet_name: str, sheet, valid_columns: set[int], min_row: int
|
||||
) -> dict[tuple[int, int], list[str]]:
|
||||
"""
|
||||
Extract embedded worksheet images and map them to their anchor cell.
|
||||
|
||||
Images are stored with deterministic keys derived from the source upload file,
|
||||
sheet, anchor cell, and content hash so retried tasks can reuse the same
|
||||
UploadFile rows and storage objects.
|
||||
"""
|
||||
if not self._tenant_id or not self._user_id or not self._source_file_id:
|
||||
return {}
|
||||
|
||||
images = getattr(sheet, "_images", None) or []
|
||||
image_candidates: list[SheetImageCandidate] = []
|
||||
|
||||
for image in images:
|
||||
marker = getattr(getattr(image, "anchor", None), "_from", None)
|
||||
row_idx = getattr(marker, "row", None)
|
||||
col_idx = getattr(marker, "col", None)
|
||||
if row_idx is None or col_idx is None:
|
||||
continue
|
||||
if row_idx + 1 < min_row or col_idx + 1 not in valid_columns:
|
||||
continue
|
||||
|
||||
image_bytes = self._get_image_bytes(image)
|
||||
if not image_bytes:
|
||||
continue
|
||||
|
||||
image_ext = self._get_image_extension(image)
|
||||
if not image_ext:
|
||||
continue
|
||||
|
||||
anchor_row = row_idx + 1
|
||||
anchor_column = col_idx + 1
|
||||
content_hash = self._hash_image_bytes(image_bytes)
|
||||
image_candidates.append(
|
||||
{
|
||||
"anchor": (anchor_row, anchor_column),
|
||||
"content_hash": content_hash,
|
||||
"file_key": self._build_image_file_key(
|
||||
sheet_name=sheet_name,
|
||||
anchor_row=anchor_row,
|
||||
anchor_column=anchor_column,
|
||||
content_hash=content_hash,
|
||||
image_ext=image_ext,
|
||||
),
|
||||
"image_bytes": image_bytes,
|
||||
"image_ext": image_ext,
|
||||
}
|
||||
)
|
||||
|
||||
if not image_candidates:
|
||||
return {}
|
||||
|
||||
image_map: dict[tuple[int, int], list[str]] = {}
|
||||
base_url = dify_config.FILES_URL
|
||||
candidate_keys = sorted({candidate["file_key"] for candidate in image_candidates})
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
existing_upload_files = session.scalars(
|
||||
select(UploadFile).where(
|
||||
UploadFile.tenant_id == self._tenant_id,
|
||||
UploadFile.key.in_(candidate_keys),
|
||||
)
|
||||
).all()
|
||||
upload_files_by_key = {upload_file.key: upload_file for upload_file in existing_upload_files}
|
||||
new_upload_files: list[UploadFile] = []
|
||||
|
||||
for candidate in image_candidates:
|
||||
upload_file = upload_files_by_key.get(candidate["file_key"])
|
||||
if upload_file is None:
|
||||
storage.save(candidate["file_key"], candidate["image_bytes"])
|
||||
mime_type, _ = mimetypes.guess_type(candidate["file_key"])
|
||||
upload_file = UploadFile(
|
||||
tenant_id=self._tenant_id,
|
||||
storage_type=StorageType(dify_config.STORAGE_TYPE),
|
||||
key=candidate["file_key"],
|
||||
name=candidate["file_key"],
|
||||
size=len(candidate["image_bytes"]),
|
||||
extension=candidate["image_ext"],
|
||||
mime_type=mime_type or "",
|
||||
created_by=self._user_id,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_at=naive_utc_now(),
|
||||
used=True,
|
||||
used_by=self._user_id,
|
||||
used_at=naive_utc_now(),
|
||||
hash=candidate["content_hash"],
|
||||
)
|
||||
upload_files_by_key[candidate["file_key"]] = upload_file
|
||||
new_upload_files.append(upload_file)
|
||||
|
||||
image_map.setdefault(candidate["anchor"], []).append(
|
||||
f""
|
||||
)
|
||||
|
||||
if new_upload_files:
|
||||
session.add_all(new_upload_files)
|
||||
session.commit()
|
||||
|
||||
return image_map
|
||||
|
||||
@staticmethod
|
||||
def _hash_image_bytes(image_bytes: bytes) -> str:
|
||||
"""Return a stable content hash for extracted image bytes."""
|
||||
return hashlib.sha256(image_bytes).hexdigest()
|
||||
|
||||
def _build_image_file_key(
|
||||
self,
|
||||
*,
|
||||
sheet_name: str,
|
||||
anchor_row: int,
|
||||
anchor_column: int,
|
||||
content_hash: str,
|
||||
image_ext: str,
|
||||
) -> str:
|
||||
"""Build a deterministic storage key for an embedded worksheet image."""
|
||||
assert self._tenant_id is not None, "tenant_id is required for image extraction"
|
||||
assert self._source_file_id is not None, "source_file_id is required for image extraction"
|
||||
|
||||
normalized_ext = image_ext.strip().lower()
|
||||
sheet_hash = hashlib.sha256(sheet_name.encode("utf-8")).hexdigest()[:16]
|
||||
return (
|
||||
f"image_files/{self._tenant_id}/{self._source_file_id}/"
|
||||
f"{sheet_hash}_r{anchor_row}_c{anchor_column}_{content_hash}.{normalized_ext}"
|
||||
)
|
||||
|
||||
def _get_image_bytes(self, image) -> bytes | None:
|
||||
"""Return embedded image bytes from an openpyxl image object."""
|
||||
data_loader = getattr(image, "_data", None)
|
||||
if not callable(data_loader):
|
||||
return None
|
||||
|
||||
try:
|
||||
data = data_loader()
|
||||
if isinstance(data, bytes):
|
||||
return data
|
||||
if isinstance(data, bytearray):
|
||||
return bytes(data)
|
||||
logger.warning("Unexpected embedded image payload type: %s", type(data).__name__)
|
||||
return None
|
||||
except Exception:
|
||||
logger.warning("Failed to read embedded image bytes from Excel sheet", exc_info=True)
|
||||
return None
|
||||
|
||||
def _get_image_extension(self, image) -> str | None:
|
||||
"""Resolve an image extension from openpyxl metadata."""
|
||||
image_format = getattr(image, "format", None)
|
||||
if isinstance(image_format, str) and image_format.strip():
|
||||
return image_format.strip().lower()
|
||||
|
||||
image_path = getattr(image, "path", None)
|
||||
if isinstance(image_path, str):
|
||||
_, extension = os.path.splitext(image_path)
|
||||
if extension:
|
||||
return extension.lstrip(".").lower()
|
||||
|
||||
return None
|
||||
|
||||
def _find_header_and_columns(self, sheet, scan_rows=10) -> tuple[int, dict[int, str], int]:
|
||||
"""
|
||||
Scan first N rows to find the most likely header row.
|
||||
|
||||
@ -113,7 +113,12 @@ class ExtractProcessor:
|
||||
unstructured_api_key = dify_config.UNSTRUCTURED_API_KEY or ""
|
||||
|
||||
if file_extension in {".xlsx", ".xls"}:
|
||||
extractor = ExcelExtractor(file_path)
|
||||
extractor = ExcelExtractor(
|
||||
file_path,
|
||||
upload_file.tenant_id,
|
||||
upload_file.created_by,
|
||||
upload_file.id,
|
||||
)
|
||||
elif file_extension == ".pdf":
|
||||
assert upload_file is not None
|
||||
extractor = PdfExtractor(file_path, upload_file.tenant_id, upload_file.created_by)
|
||||
@ -151,7 +156,12 @@ class ExtractProcessor:
|
||||
extractor = TextExtractor(file_path, autodetect_encoding=True)
|
||||
else:
|
||||
if file_extension in {".xlsx", ".xls"}:
|
||||
extractor = ExcelExtractor(file_path)
|
||||
extractor = ExcelExtractor(
|
||||
file_path,
|
||||
upload_file.tenant_id,
|
||||
upload_file.created_by,
|
||||
upload_file.id,
|
||||
)
|
||||
elif file_extension == ".pdf":
|
||||
assert upload_file is not None
|
||||
extractor = PdfExtractor(file_path, upload_file.tenant_id, upload_file.created_by)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, TypedDict, cast
|
||||
from typing import Any, TypedDict, cast, override
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -61,6 +61,7 @@ class ParagraphFormatPreviewDict(TypedDict):
|
||||
|
||||
|
||||
class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
@override
|
||||
def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]:
|
||||
text_docs = ExtractProcessor.extract(
|
||||
extract_setting=extract_setting,
|
||||
@ -71,6 +72,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
|
||||
return text_docs
|
||||
|
||||
@override
|
||||
def transform(self, documents: list[Document], current_user: Account | None = None, **kwargs) -> list[Document]:
|
||||
process_rule = kwargs.get("process_rule")
|
||||
if not process_rule:
|
||||
@ -120,6 +122,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
all_documents.extend(split_documents)
|
||||
return all_documents
|
||||
|
||||
@override
|
||||
def load(
|
||||
self,
|
||||
dataset: Dataset,
|
||||
@ -142,6 +145,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
else:
|
||||
keyword.add_texts(documents)
|
||||
|
||||
@override
|
||||
def clean(self, dataset: Dataset, node_ids: list[str] | None, with_keywords: bool = True, **kwargs) -> None:
|
||||
# Note: Summary indexes are now disabled (not deleted) when segments are disabled.
|
||||
# This method is called for actual deletion scenarios (e.g., when segment is deleted).
|
||||
@ -178,6 +182,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
else:
|
||||
keyword.delete()
|
||||
|
||||
@override
|
||||
def retrieve(
|
||||
self,
|
||||
retrieval_method: RetrievalMethod,
|
||||
@ -206,6 +211,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
docs.append(doc)
|
||||
return docs
|
||||
|
||||
@override
|
||||
def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None:
|
||||
documents: list[Any] = []
|
||||
all_multimodal_documents: list[Any] = []
|
||||
@ -271,6 +277,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
keyword = Keyword(dataset)
|
||||
keyword.add_texts(documents)
|
||||
|
||||
@override
|
||||
def format_preview(self, chunks: Any) -> ParagraphFormatPreviewDict:
|
||||
if isinstance(chunks, list):
|
||||
preview = []
|
||||
@ -285,6 +292,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
else:
|
||||
raise ValueError("Chunks is not a list")
|
||||
|
||||
@override
|
||||
def generate_summary_preview(
|
||||
self,
|
||||
tenant_id: str,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, TypedDict, override
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
@ -44,6 +44,7 @@ class ParentChildFormatPreviewDict(TypedDict):
|
||||
|
||||
|
||||
class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
@override
|
||||
def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]:
|
||||
text_docs = ExtractProcessor.extract(
|
||||
extract_setting=extract_setting,
|
||||
@ -54,6 +55,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
|
||||
return text_docs
|
||||
|
||||
@override
|
||||
def transform(self, documents: list[Document], current_user: Account | None = None, **kwargs) -> list[Document]:
|
||||
process_rule = kwargs.get("process_rule")
|
||||
if not process_rule:
|
||||
@ -129,6 +131,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
|
||||
return all_documents
|
||||
|
||||
@override
|
||||
def load(
|
||||
self,
|
||||
dataset: Dataset,
|
||||
@ -149,6 +152,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
if multimodal_documents and dataset.is_multimodal:
|
||||
vector.create_multimodal(multimodal_documents)
|
||||
|
||||
@override
|
||||
def clean(self, dataset: Dataset, node_ids: list[str] | None, with_keywords: bool = True, **kwargs) -> None:
|
||||
# node_ids is segment's node_ids
|
||||
# Note: Summary indexes are now disabled (not deleted) when segments are disabled.
|
||||
@ -219,6 +223,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
@override
|
||||
def retrieve(
|
||||
self,
|
||||
retrieval_method: RetrievalMethod,
|
||||
@ -283,6 +288,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
child_nodes.append(child_document)
|
||||
return child_nodes
|
||||
|
||||
@override
|
||||
def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None:
|
||||
parent_childs = ParentChildStructureChunk.model_validate(chunks)
|
||||
documents = []
|
||||
@ -356,6 +362,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
if all_multimodal_documents and dataset.is_multimodal:
|
||||
vector.create_multimodal(all_multimodal_documents)
|
||||
|
||||
@override
|
||||
def format_preview(self, chunks: Any) -> ParentChildFormatPreviewDict:
|
||||
parent_childs = ParentChildStructureChunk.model_validate(chunks)
|
||||
preview = []
|
||||
@ -369,6 +376,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
}
|
||||
return result
|
||||
|
||||
@override
|
||||
def generate_summary_preview(
|
||||
self,
|
||||
tenant_id: str,
|
||||
|
||||
@ -4,7 +4,7 @@ import logging
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, TypedDict, override
|
||||
|
||||
import pandas as pd
|
||||
from flask import Flask, current_app
|
||||
@ -43,6 +43,7 @@ class QAFormatPreviewDict(TypedDict):
|
||||
|
||||
|
||||
class QAIndexProcessor(BaseIndexProcessor):
|
||||
@override
|
||||
def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]:
|
||||
text_docs = ExtractProcessor.extract(
|
||||
extract_setting=extract_setting,
|
||||
@ -52,6 +53,7 @@ class QAIndexProcessor(BaseIndexProcessor):
|
||||
)
|
||||
return text_docs
|
||||
|
||||
@override
|
||||
def transform(self, documents: list[Document], current_user: Account | None = None, **kwargs) -> list[Document]:
|
||||
preview = kwargs.get("preview")
|
||||
process_rule = kwargs.get("process_rule")
|
||||
@ -139,6 +141,7 @@ class QAIndexProcessor(BaseIndexProcessor):
|
||||
raise ValueError(str(e))
|
||||
return text_docs
|
||||
|
||||
@override
|
||||
def load(
|
||||
self,
|
||||
dataset: Dataset,
|
||||
@ -153,6 +156,7 @@ class QAIndexProcessor(BaseIndexProcessor):
|
||||
if multimodal_documents and dataset.is_multimodal:
|
||||
vector.create_multimodal(multimodal_documents)
|
||||
|
||||
@override
|
||||
def clean(self, dataset: Dataset, node_ids: list[str] | None, with_keywords: bool = True, **kwargs) -> None:
|
||||
# Note: Summary indexes are now disabled (not deleted) when segments are disabled.
|
||||
# This method is called for actual deletion scenarios (e.g., when segment is deleted).
|
||||
@ -183,6 +187,7 @@ class QAIndexProcessor(BaseIndexProcessor):
|
||||
else:
|
||||
vector.delete()
|
||||
|
||||
@override
|
||||
def retrieve(
|
||||
self,
|
||||
retrieval_method: RetrievalMethod,
|
||||
@ -211,6 +216,7 @@ class QAIndexProcessor(BaseIndexProcessor):
|
||||
docs.append(doc)
|
||||
return docs
|
||||
|
||||
@override
|
||||
def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None:
|
||||
qa_chunks = QAStructureChunk.model_validate(chunks)
|
||||
documents = []
|
||||
@ -234,6 +240,7 @@ class QAIndexProcessor(BaseIndexProcessor):
|
||||
else:
|
||||
raise ValueError("Indexing technique must be high quality.")
|
||||
|
||||
@override
|
||||
def format_preview(self, chunks: Any) -> QAFormatPreviewDict:
|
||||
qa_chunks = QAStructureChunk.model_validate(chunks)
|
||||
preview = []
|
||||
@ -246,6 +253,7 @@ class QAIndexProcessor(BaseIndexProcessor):
|
||||
}
|
||||
return result
|
||||
|
||||
@override
|
||||
def generate_summary_preview(
|
||||
self,
|
||||
tenant_id: str,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import base64
|
||||
from typing import override
|
||||
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.rag.index_processor.constant.doc_type import DocType
|
||||
@ -16,6 +17,7 @@ class RerankModelRunner(BaseRerankRunner):
|
||||
def __init__(self, rerank_model_instance: ModelInstance):
|
||||
self.rerank_model_instance = rerank_model_instance
|
||||
|
||||
@override
|
||||
def run(
|
||||
self,
|
||||
query: str,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import math
|
||||
from collections import Counter
|
||||
from typing import override
|
||||
|
||||
import numpy as np
|
||||
|
||||
@ -19,6 +20,7 @@ class WeightRerankRunner(BaseRerankRunner):
|
||||
self.tenant_id = tenant_id
|
||||
self.weights = weights
|
||||
|
||||
@override
|
||||
def run(
|
||||
self,
|
||||
query: str,
|
||||
|
||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import codecs
|
||||
import re
|
||||
from collections.abc import Set as AbstractSet
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, override
|
||||
|
||||
from core.model_manager import ModelInstance
|
||||
from core.rag.splitter.text_splitter import RecursiveCharacterTextSplitter
|
||||
@ -51,6 +51,7 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
|
||||
self._fixed_separator = codecs.decode(fixed_separator, "unicode_escape")
|
||||
self._separators = separators or ["\n\n", "\n", "。", ". ", " ", ""]
|
||||
|
||||
@override
|
||||
def split_text(self, text: str) -> list[str]:
|
||||
"""Split incoming text and return chunks."""
|
||||
if self._fixed_separator:
|
||||
|
||||
@ -7,7 +7,7 @@ from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, override
|
||||
|
||||
from core.rag.models.document import BaseDocumentTransformer, Document
|
||||
|
||||
@ -148,10 +148,12 @@ class TextSplitter(BaseDocumentTransformer, ABC):
|
||||
)
|
||||
return cls(length_function=lambda x: [_huggingface_tokenizer_length(text) for text in x], **kwargs)
|
||||
|
||||
@override
|
||||
def transform_documents(self, documents: Sequence[Document], **kwargs: Any) -> Sequence[Document]:
|
||||
"""Transform sequence of documents by splitting them."""
|
||||
return self.split_documents(list(documents))
|
||||
|
||||
@override
|
||||
async def atransform_documents(self, documents: Sequence[Document], **kwargs: Any) -> Sequence[Document]:
|
||||
"""Asynchronously transform a sequence of documents by splitting them."""
|
||||
raise NotImplementedError
|
||||
@ -211,6 +213,7 @@ class TokenTextSplitter(TextSplitter):
|
||||
self._allowed_special: Literal["all"] | AbstractSet[str] = allowed_special
|
||||
self._disallowed_special: Literal["all"] | AbstractSet[str] = disallowed_special
|
||||
|
||||
@override
|
||||
def split_text(self, text: str) -> list[str]:
|
||||
def _encode(_text: str) -> list[int]:
|
||||
return self._tokenizer.encode(
|
||||
@ -287,5 +290,6 @@ class RecursiveCharacterTextSplitter(TextSplitter):
|
||||
|
||||
return final_chunks
|
||||
|
||||
@override
|
||||
def split_text(self, text: str) -> list[str]:
|
||||
return self._split_text(text, self._separators)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from abc import abstractmethod
|
||||
from os import listdir, path
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.entities.provider_entities import ProviderConfig
|
||||
from core.helper.module_import_helper import load_single_subclass_from_source
|
||||
@ -105,6 +105,7 @@ class BuiltinToolProviderController(ToolProviderController):
|
||||
"""
|
||||
return self.tools
|
||||
|
||||
@override
|
||||
def get_credentials_schema(self) -> list[ProviderConfig]:
|
||||
"""
|
||||
returns the credentials schema of the provider
|
||||
@ -182,6 +183,7 @@ class BuiltinToolProviderController(ToolProviderController):
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def provider_type(self) -> ToolProviderType:
|
||||
"""
|
||||
returns the type of the provider
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
|
||||
|
||||
class AudioToolProvider(BuiltinToolProviderController):
|
||||
@override
|
||||
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
|
||||
pass
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import io
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.model_manager import ModelManager
|
||||
from core.plugin.entities.parameters import PluginParameterOption
|
||||
@ -14,6 +14,7 @@ from services.model_provider_service import ModelProviderService
|
||||
|
||||
|
||||
class ASRTool(BuiltinTool):
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
@ -56,6 +57,7 @@ class ASRTool(BuiltinTool):
|
||||
items.append((provider, model.model))
|
||||
return items
|
||||
|
||||
@override
|
||||
def get_runtime_parameters(
|
||||
self,
|
||||
conversation_id: str | None = None,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import io
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.model_manager import ModelManager
|
||||
from core.plugin.entities.parameters import PluginParameterOption
|
||||
@ -12,6 +12,7 @@ from services.model_provider_service import ModelProviderService
|
||||
|
||||
|
||||
class TTSTool(BuiltinTool):
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
@ -66,6 +67,7 @@ class TTSTool(BuiltinTool):
|
||||
items.append((provider, model.model, voices))
|
||||
return items
|
||||
|
||||
@override
|
||||
def get_runtime_parameters(
|
||||
self,
|
||||
conversation_id: str | None = None,
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
|
||||
|
||||
class CodeToolProvider(BuiltinToolProviderController):
|
||||
@override
|
||||
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
|
||||
pass
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.helper.code_executor.code_executor import CodeExecutor, CodeLanguage
|
||||
from core.tools.builtin_tool.tool import BuiltinTool
|
||||
@ -8,6 +8,7 @@ from core.tools.errors import ToolInvokeError
|
||||
|
||||
|
||||
class SimpleCode(BuiltinTool):
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
|
||||
|
||||
class WikiPediaProvider(BuiltinToolProviderController):
|
||||
@override
|
||||
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
|
||||
pass
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from pytz import timezone as pytz_timezone # type: ignore[import-untyped]
|
||||
|
||||
@ -9,6 +9,7 @@ from core.tools.entities.tool_entities import ToolInvokeMessage
|
||||
|
||||
|
||||
class CurrentTimeTool(BuiltinTool):
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
|
||||
@ -10,6 +10,7 @@ from core.tools.errors import ToolInvokeError
|
||||
|
||||
|
||||
class LocaltimeToTimestampTool(BuiltinTool):
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
|
||||
@ -10,6 +10,7 @@ from core.tools.errors import ToolInvokeError
|
||||
|
||||
|
||||
class TimestampToLocaltimeTool(BuiltinTool):
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
|
||||
@ -10,6 +10,7 @@ from core.tools.errors import ToolInvokeError
|
||||
|
||||
|
||||
class TimezoneConversionTool(BuiltinTool):
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import calendar
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.tools.builtin_tool.tool import BuiltinTool
|
||||
from core.tools.entities.tool_entities import ToolInvokeMessage
|
||||
|
||||
|
||||
class WeekdayTool(BuiltinTool):
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.tools.builtin_tool.tool import BuiltinTool
|
||||
from core.tools.entities.tool_entities import ToolInvokeMessage
|
||||
@ -8,6 +8,7 @@ from core.tools.utils.web_reader_tool import get_url
|
||||
|
||||
|
||||
class WebscraperTool(BuiltinTool):
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
|
||||
|
||||
class WebscraperProvider(BuiltinToolProviderController):
|
||||
@override
|
||||
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
|
||||
"""
|
||||
Validate credentials
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import override
|
||||
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
@ -26,6 +28,7 @@ class BuiltinTool(Tool):
|
||||
super().__init__(**kwargs)
|
||||
self.provider = provider
|
||||
|
||||
@override
|
||||
def fork_tool_runtime(self, runtime: ToolRuntime) -> BuiltinTool:
|
||||
"""
|
||||
fork a new tool with metadata
|
||||
@ -56,6 +59,7 @@ class BuiltinTool(Tool):
|
||||
caller_user_id=self.runtime.user_id,
|
||||
)
|
||||
|
||||
@override
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.BUILT_IN
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import override
|
||||
|
||||
from pydantic import Field
|
||||
from sqlalchemy import select
|
||||
|
||||
@ -122,6 +124,7 @@ class ApiToolProviderController(ToolProviderController):
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.API
|
||||
|
||||
@ -194,6 +197,7 @@ class ApiToolProviderController(ToolProviderController):
|
||||
self.tools = tools
|
||||
return tools
|
||||
|
||||
@override
|
||||
def get_tool(self, tool_name: str) -> ApiTool:
|
||||
"""
|
||||
get tool by name
|
||||
|
||||
@ -2,7 +2,7 @@ import json
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from os import getenv
|
||||
from typing import Any, Union
|
||||
from typing import Any, Union, override
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
@ -45,6 +45,7 @@ class ApiTool(Tool):
|
||||
self.api_bundle = api_bundle
|
||||
self.provider_id = provider_id
|
||||
|
||||
@override
|
||||
def fork_tool_runtime(self, runtime: ToolRuntime):
|
||||
"""
|
||||
fork a new tool with metadata
|
||||
@ -77,6 +78,7 @@ class ApiTool(Tool):
|
||||
# For credential validation, always return as string
|
||||
return parsed_response.to_string()
|
||||
|
||||
@override
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.API
|
||||
|
||||
@ -373,6 +375,7 @@ class ApiTool(Tool):
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -54,6 +54,9 @@ class ToolProviderApiEntity(BaseModel):
|
||||
configuration: MCPConfiguration | None = Field(
|
||||
default=None, description="The timeout and sse_read_timeout of the MCP tool"
|
||||
)
|
||||
# M3 — user-identity forwarding selector. Round-tripped through the
|
||||
# console API so the create/edit modal can hydrate the toggle state.
|
||||
identity_mode: str = Field(default="off", description="Identity-forwarding mechanism: 'off' or 'idp_token'")
|
||||
# Workflow
|
||||
workflow_app_id: str | None = Field(default=None, description="The app id of the workflow tool")
|
||||
|
||||
@ -92,6 +95,9 @@ class ToolProviderApiEntity(BaseModel):
|
||||
optional_fields.update(self.optional_field("is_dynamic_registration", self.is_dynamic_registration))
|
||||
optional_fields.update(self.optional_field("masked_headers", self.masked_headers))
|
||||
optional_fields.update(self.optional_field("original_headers", self.original_headers))
|
||||
# M3 — forwarding selector. Always emit ("off" is a valid
|
||||
# value that the UI must hydrate, not skip).
|
||||
optional_fields["identity_mode"] = self.identity_mode
|
||||
case ToolProviderType.WORKFLOW:
|
||||
optional_fields.update(self.optional_field("workflow_app_id", self.workflow_app_id))
|
||||
case _:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from typing import Any, Self
|
||||
from typing import Any, Self, override
|
||||
|
||||
from core.entities.mcp_provider import MCPProviderEntity
|
||||
from core.entities.mcp_provider import IdentityMode, MCPProviderEntity
|
||||
from core.mcp.types import Tool as RemoteMCPTool
|
||||
from core.tools.__base.tool_provider import ToolProviderController
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
@ -28,6 +28,7 @@ class MCPToolProviderController(ToolProviderController):
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
sse_read_timeout: float | None = None,
|
||||
identity_mode: IdentityMode = IdentityMode.OFF,
|
||||
):
|
||||
super().__init__(entity)
|
||||
self.entity: ToolProviderEntityWithPlugin = entity
|
||||
@ -37,8 +38,10 @@ class MCPToolProviderController(ToolProviderController):
|
||||
self.headers = headers or {}
|
||||
self.timeout = timeout
|
||||
self.sse_read_timeout = sse_read_timeout
|
||||
self.identity_mode: IdentityMode = identity_mode
|
||||
|
||||
@property
|
||||
@override
|
||||
def provider_type(self) -> ToolProviderType:
|
||||
"""
|
||||
returns the type of the provider
|
||||
@ -105,6 +108,7 @@ class MCPToolProviderController(ToolProviderController):
|
||||
headers=entity.headers,
|
||||
timeout=entity.timeout,
|
||||
sse_read_timeout=entity.sse_read_timeout,
|
||||
identity_mode=entity.identity_mode,
|
||||
)
|
||||
|
||||
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
|
||||
@ -113,6 +117,7 @@ class MCPToolProviderController(ToolProviderController):
|
||||
"""
|
||||
pass
|
||||
|
||||
@override
|
||||
def get_tool(self, tool_name: str) -> MCPTool:
|
||||
"""
|
||||
return tool with given name
|
||||
@ -134,6 +139,7 @@ class MCPToolProviderController(ToolProviderController):
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
sse_read_timeout=self.sse_read_timeout,
|
||||
identity_mode=self.identity_mode,
|
||||
)
|
||||
|
||||
def get_tools(self) -> list[MCPTool]:
|
||||
@ -151,6 +157,7 @@ class MCPToolProviderController(ToolProviderController):
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
sse_read_timeout=self.sse_read_timeout,
|
||||
identity_mode=self.identity_mode,
|
||||
)
|
||||
for tool_entity in self.entity.tools
|
||||
]
|
||||
|
||||
@ -4,8 +4,10 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Generator, Mapping
|
||||
from typing import Any, cast
|
||||
from typing import Any, cast, override
|
||||
|
||||
from configs import dify_config
|
||||
from core.entities.mcp_provider import IdentityMode
|
||||
from core.mcp.auth_client import MCPClientWithAuthRetry
|
||||
from core.mcp.error import MCPConnectionError
|
||||
from core.mcp.types import (
|
||||
@ -25,6 +27,11 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetada
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Custom header used to carry the forwarded SSO access token. Picked to avoid
|
||||
# stomping on the workspace-scoped Authorization header (provider OAuth /
|
||||
# user-supplied custom credentials), which would silently break those flows.
|
||||
FORWARDED_IDENTITY_HEADER = "X-Dify-SSO-Access-Token"
|
||||
|
||||
|
||||
class MCPTool(Tool):
|
||||
def __init__(
|
||||
@ -38,6 +45,7 @@ class MCPTool(Tool):
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
sse_read_timeout: float | None = None,
|
||||
identity_mode: IdentityMode = IdentityMode.OFF,
|
||||
):
|
||||
super().__init__(entity, runtime)
|
||||
self.tenant_id = tenant_id
|
||||
@ -47,11 +55,14 @@ class MCPTool(Tool):
|
||||
self.headers = headers or {}
|
||||
self.timeout = timeout
|
||||
self.sse_read_timeout = sse_read_timeout
|
||||
self.identity_mode: IdentityMode = identity_mode
|
||||
self._latest_usage = LLMUsage.empty_usage()
|
||||
|
||||
@override
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.MCP
|
||||
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
@ -60,7 +71,7 @@ class MCPTool(Tool):
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> Generator[ToolInvokeMessage, None, None]:
|
||||
result = self.invoke_remote_mcp_tool(tool_parameters)
|
||||
result = self.invoke_remote_mcp_tool(tool_parameters, user_id=user_id, app_id=app_id)
|
||||
|
||||
# Extract usage metadata from MCP protocol's _meta field
|
||||
self._latest_usage = self._derive_usage_from_result(result)
|
||||
@ -223,6 +234,7 @@ class MCPTool(Tool):
|
||||
return found
|
||||
return None
|
||||
|
||||
@override
|
||||
def fork_tool_runtime(self, runtime: ToolRuntime) -> MCPTool:
|
||||
return MCPTool(
|
||||
entity=self.entity,
|
||||
@ -234,6 +246,7 @@ class MCPTool(Tool):
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
sse_read_timeout=self.sse_read_timeout,
|
||||
identity_mode=self.identity_mode,
|
||||
)
|
||||
|
||||
def _handle_none_parameter(self, parameter: dict[str, Any]) -> dict[str, Any]:
|
||||
@ -246,7 +259,26 @@ class MCPTool(Tool):
|
||||
if value is not None and not (isinstance(value, str) and value.strip() == "")
|
||||
}
|
||||
|
||||
def invoke_remote_mcp_tool(self, tool_parameters: dict[str, Any]) -> CallToolResult:
|
||||
@property
|
||||
def _forwarding_requested(self) -> bool:
|
||||
"""True only when the configured identity_mode wants forwarding AND
|
||||
the deployment actually has the enterprise side that can mint tokens.
|
||||
Non-enterprise installs treat the DB value as a no-op — a stale row
|
||||
won't trigger a 5xx against a missing inner-API endpoint."""
|
||||
return self.identity_mode != IdentityMode.OFF and dify_config.ENTERPRISE_ENABLED
|
||||
|
||||
def invoke_remote_mcp_tool(
|
||||
self,
|
||||
tool_parameters: dict[str, Any],
|
||||
user_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
) -> CallToolResult:
|
||||
# Fail closed: forwarding requires user_id (refuse before any DB I/O).
|
||||
if self._forwarding_requested and not user_id:
|
||||
raise ToolInvokeError(
|
||||
"Forward-user-identity is enabled for this MCP provider but no end-user context was supplied."
|
||||
)
|
||||
|
||||
headers = self.headers.copy() if self.headers else {}
|
||||
tool_parameters = self._handle_none_parameter(tool_parameters)
|
||||
|
||||
@ -271,6 +303,15 @@ class MCPTool(Tool):
|
||||
if tokens and tokens.access_token:
|
||||
headers["Authorization"] = f"{tokens.token_type.capitalize()} {tokens.access_token}"
|
||||
|
||||
# Forwarded identity rides in a custom header so workspace-scoped
|
||||
# provider credentials (Authorization / custom Headers) keep working
|
||||
# untouched. The MCP server is expected to read X-Dify-SSO-Access-Token
|
||||
# when identity forwarding is configured.
|
||||
forward_identity_active = False
|
||||
if self._forwarding_requested and user_id:
|
||||
self._inject_forwarded_identity(headers, user_id=user_id, app_id=app_id, audience=server_url)
|
||||
forward_identity_active = True
|
||||
|
||||
# Step 2: Session is now closed, perform network operations without holding database connection
|
||||
# MCPClientWithAuthRetry will create a new session lazily only if auth retry is needed
|
||||
try:
|
||||
@ -280,9 +321,44 @@ class MCPTool(Tool):
|
||||
timeout=self.timeout,
|
||||
sse_read_timeout=self.sse_read_timeout,
|
||||
provider_entity=provider_entity,
|
||||
forward_identity_active=forward_identity_active,
|
||||
) as mcp_client:
|
||||
return mcp_client.invoke_tool(tool_name=self.entity.identity.name, tool_args=tool_parameters)
|
||||
except MCPConnectionError as e:
|
||||
raise ToolInvokeError(f"Failed to connect to MCP server: {e}") from e
|
||||
except Exception as e:
|
||||
raise ToolInvokeError(f"Failed to invoke tool: {e}") from e
|
||||
|
||||
def _inject_forwarded_identity(
|
||||
self,
|
||||
headers: dict[str, str],
|
||||
*,
|
||||
user_id: str,
|
||||
app_id: str | None,
|
||||
audience: str,
|
||||
) -> None:
|
||||
"""Call the enterprise IssueMCPToken endpoint and stamp the issued
|
||||
token into X-Dify-SSO-Access-Token.
|
||||
|
||||
A custom header is used (rather than Authorization) so it composes
|
||||
with workspace-scoped provider credentials — the user may have OAuth
|
||||
tokens or a custom Authorization header configured on the MCP
|
||||
provider, and forwarding must not silently overwrite them.
|
||||
|
||||
Errors are surfaced as ToolInvokeError so the workflow halts with a
|
||||
clear message instead of silently dropping identity and hitting the
|
||||
MCP server unauthenticated.
|
||||
"""
|
||||
from services.enterprise.base import MCPTokenError
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
|
||||
try:
|
||||
token, _expires_at = EnterpriseService.issue_mcp_token(
|
||||
user_id=user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
app_id=app_id,
|
||||
audience=audience,
|
||||
)
|
||||
except MCPTokenError as e:
|
||||
raise ToolInvokeError(f"Failed to obtain forwarded identity token: {e}") from e
|
||||
headers[FORWARDED_IDENTITY_HEADER] = token
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.plugin.impl.tool import PluginToolManager
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
@ -23,6 +23,7 @@ class PluginToolProviderController(BuiltinToolProviderController):
|
||||
self.plugin_unique_identifier = plugin_unique_identifier
|
||||
|
||||
@property
|
||||
@override
|
||||
def provider_type(self) -> ToolProviderType:
|
||||
"""
|
||||
returns the type of the provider
|
||||
@ -31,6 +32,7 @@ class PluginToolProviderController(BuiltinToolProviderController):
|
||||
"""
|
||||
return ToolProviderType.PLUGIN
|
||||
|
||||
@override
|
||||
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
|
||||
"""
|
||||
validate the credentials of the provider
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.plugin.impl.tool import PluginToolManager
|
||||
from core.plugin.utils.converter import convert_parameters_to_plugin_format
|
||||
@ -20,9 +20,11 @@ class PluginTool(Tool):
|
||||
self.plugin_unique_identifier = plugin_unique_identifier
|
||||
self.runtime_parameters: list[ToolParameter] | None = None
|
||||
|
||||
@override
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.PLUGIN
|
||||
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
@ -48,6 +50,7 @@ class PluginTool(Tool):
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
@override
|
||||
def fork_tool_runtime(self, runtime: ToolRuntime) -> PluginTool:
|
||||
return PluginTool(
|
||||
entity=self.entity,
|
||||
@ -57,6 +60,7 @@ class PluginTool(Tool):
|
||||
plugin_unique_identifier=self.plugin_unique_identifier,
|
||||
)
|
||||
|
||||
@override
|
||||
def get_runtime_parameters(
|
||||
self,
|
||||
conversation_id: str | None = None,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import threading
|
||||
from typing import override
|
||||
|
||||
from flask import Flask, current_app
|
||||
from pydantic import BaseModel, Field
|
||||
@ -46,6 +47,7 @@ class DatasetMultiRetrieverTool(DatasetRetrieverBaseTool):
|
||||
name=f"dataset_{tenant_id.replace('-', '_')}", tenant_id=tenant_id, dataset_ids=dataset_ids, **kwargs
|
||||
)
|
||||
|
||||
@override
|
||||
def _run(self, query: str) -> str:
|
||||
threads = []
|
||||
all_documents: list[RagDocument] = []
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import Any, cast
|
||||
from typing import Any, cast, override
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
@ -56,6 +56,7 @@ class DatasetRetrieverTool(DatasetRetrieverBaseTool):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@override
|
||||
def _run(self, query: str) -> str:
|
||||
dataset_stmt = select(Dataset).where(Dataset.tenant_id == self.tenant_id, Dataset.id == self.dataset_id)
|
||||
dataset = db.session.scalar(dataset_stmt)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.app.app_config.entities import DatasetRetrieveConfigEntity
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@ -85,6 +85,7 @@ class DatasetRetrieverTool(Tool):
|
||||
|
||||
return tools
|
||||
|
||||
@override
|
||||
def get_runtime_parameters(
|
||||
self,
|
||||
conversation_id: str | None = None,
|
||||
@ -105,9 +106,11 @@ class DatasetRetrieverTool(Tool):
|
||||
),
|
||||
]
|
||||
|
||||
@override
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.DATASET_RETRIEVAL
|
||||
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import override
|
||||
|
||||
from pydantic import Field
|
||||
from sqlalchemy import select
|
||||
@ -80,6 +81,7 @@ class WorkflowToolProviderController(ToolProviderController):
|
||||
return controller
|
||||
|
||||
@property
|
||||
@override
|
||||
def provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.WORKFLOW
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
from typing import Any, cast, override
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@ -67,6 +67,7 @@ class WorkflowTool(Tool):
|
||||
|
||||
super().__init__(entity=entity, runtime=runtime)
|
||||
|
||||
@override
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
"""
|
||||
get the tool provider type
|
||||
@ -75,6 +76,7 @@ class WorkflowTool(Tool):
|
||||
"""
|
||||
return ToolProviderType.WORKFLOW
|
||||
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
@ -206,6 +208,7 @@ class WorkflowTool(Tool):
|
||||
return found
|
||||
return None
|
||||
|
||||
@override
|
||||
def fork_tool_runtime(self, runtime: ToolRuntime) -> WorkflowTool:
|
||||
"""
|
||||
fork a new tool with metadata
|
||||
|
||||
@ -6,7 +6,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@ -62,6 +62,7 @@ class TriggerDebugEventPoller(ABC):
|
||||
|
||||
|
||||
class PluginTriggerDebugEventPoller(TriggerDebugEventPoller):
|
||||
@override
|
||||
def poll(self) -> TriggerDebugEvent | None:
|
||||
from services.trigger.trigger_service import TriggerService
|
||||
|
||||
@ -103,6 +104,7 @@ class PluginTriggerDebugEventPoller(TriggerDebugEventPoller):
|
||||
|
||||
|
||||
class WebhookTriggerDebugEventPoller(TriggerDebugEventPoller):
|
||||
@override
|
||||
def poll(self) -> TriggerDebugEvent | None:
|
||||
pool_key = build_webhook_pool_key(
|
||||
tenant_id=self.tenant_id,
|
||||
@ -190,6 +192,7 @@ class ScheduleTriggerDebugEventPoller(TriggerDebugEventPoller):
|
||||
inputs={},
|
||||
)
|
||||
|
||||
@override
|
||||
def poll(self) -> TriggerDebugEvent | None:
|
||||
schedule_debug_runtime = self.get_or_create_schedule_debug_runtime()
|
||||
if schedule_debug_runtime.next_run_at > naive_utc_now():
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Union
|
||||
from typing import Union, override
|
||||
|
||||
from core.entities.provider_entities import BasicProviderConfig, ProviderConfig
|
||||
from core.helper.provider_cache import ProviderCredentialsCache
|
||||
@ -16,6 +16,7 @@ class TriggerProviderCredentialsCache(ProviderCredentialsCache):
|
||||
def __init__(self, tenant_id: str, provider_id: str, credential_id: str):
|
||||
super().__init__(tenant_id=tenant_id, provider_id=provider_id, credential_id=credential_id)
|
||||
|
||||
@override
|
||||
def _generate_cache_key(self, **kwargs) -> str:
|
||||
tenant_id = kwargs["tenant_id"]
|
||||
provider_id = kwargs["provider_id"]
|
||||
@ -29,6 +30,7 @@ class TriggerProviderOAuthClientParamsCache(ProviderCredentialsCache):
|
||||
def __init__(self, tenant_id: str, provider_id: str):
|
||||
super().__init__(tenant_id=tenant_id, provider_id=provider_id)
|
||||
|
||||
@override
|
||||
def _generate_cache_key(self, **kwargs) -> str:
|
||||
tenant_id = kwargs["tenant_id"]
|
||||
provider_id = kwargs["provider_id"]
|
||||
@ -41,6 +43,7 @@ class TriggerProviderPropertiesCache(ProviderCredentialsCache):
|
||||
def __init__(self, tenant_id: str, provider_id: str, subscription_id: str):
|
||||
super().__init__(tenant_id=tenant_id, provider_id=provider_id, subscription_id=subscription_id)
|
||||
|
||||
@override
|
||||
def _generate_cache_key(self, **kwargs) -> str:
|
||||
tenant_id = kwargs["tenant_id"]
|
||||
provider_id = kwargs["provider_id"]
|
||||
|
||||
@ -12,6 +12,7 @@ examples accurate or the LLM will invent fields.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
# Per-node-type configuration cheatsheet.
|
||||
@ -22,11 +23,24 @@ from typing import Any
|
||||
# both ``WorkflowService.sync_draft_workflow``'s structural checks and the
|
||||
# runtime entity validation each node performs when the workflow runs.
|
||||
#
|
||||
# The cheatsheet is assembled DYNAMICALLY per request: the planner decides
|
||||
# which node types the workflow needs, and ``build_node_config_cheatsheet``
|
||||
# stitches together only the snippets for those types (plus the always-needed
|
||||
# wrapper / shared-field / edge-handle preamble, and the containers section
|
||||
# when an iteration / loop is planned). This keeps the builder prompt tight —
|
||||
# a 3-node summariser no longer carries the schema for 12 unrelated node
|
||||
# types — and lets each snippet document its FULL schema (e.g. a "file" start
|
||||
# variable's required ``allowed_file_types``) without bloating every prompt.
|
||||
#
|
||||
# The postprocessor in ``runner.py`` fills missing wrapper fields (``type``,
|
||||
# ``positionAbsolute``, ``width``, ``height``, ``sourcePosition`` /
|
||||
# ``targetPosition``, edge ``data.sourceType`` / ``data.targetType``), so the
|
||||
# LLM only needs to emit semantically meaningful fields.
|
||||
NODE_CONFIG_CHEATSHEET = """\
|
||||
|
||||
# Always-included preamble: the node/edge wrapper shape and the shared
|
||||
# ``data`` fields that apply to every node type, plus the "## Per type" header
|
||||
# the per-type snippets slot under.
|
||||
_CHEATSHEET_PREAMBLE = """\
|
||||
## Node wrapper (every node, top-level)
|
||||
|
||||
{"id": "node1" (digits + letters only — see "Node IDs" below),
|
||||
@ -46,14 +60,26 @@ Children of iteration / loop containers additionally need
|
||||
"desc": "<one-liner>",
|
||||
"selected": false}
|
||||
|
||||
## Per type — additional "data" fields
|
||||
## Per type — additional "data" fields (only the node types in your plan are shown)"""
|
||||
|
||||
|
||||
# node_type → its per-type schema snippet. Keyed by the exact ``node_type``
|
||||
# string the planner emits so ``build_node_config_cheatsheet`` can look each
|
||||
# one up directly. Iteration / loop are documented in the Containers section
|
||||
# (they are subgraphs, not leaf nodes) rather than here.
|
||||
_NODE_SNIPPETS: dict[str, str] = {
|
||||
"start": """\
|
||||
- start:
|
||||
{"variables": [
|
||||
{"variable": "url", "label": "URL", "type": "text-input",
|
||||
"required": true, "max_length": 256, "options": []},
|
||||
{"variable": "topic", "label": "Topic", "type": "paragraph",
|
||||
"required": false, "max_length": 4096, "options": []}
|
||||
"required": false, "max_length": 4096, "options": []},
|
||||
{"variable": "doc", "label": "Document", "type": "file",
|
||||
"required": true,
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["local_file", "remote_url"],
|
||||
"allowed_file_extensions": []}
|
||||
]}
|
||||
EVERY user-supplied value referenced by a downstream node
|
||||
(``{{#node-id.var#}}`` in a prompt / answer / template, or
|
||||
@ -62,19 +88,29 @@ Children of iteration / loop containers additionally need
|
||||
If the planner's ``start_inputs`` list is non-empty, use it verbatim
|
||||
(the user prompt section "Start inputs" surfaces it). Types:
|
||||
text-input | paragraph | select | number | file | file-list.
|
||||
For a "file" or "file-list" variable you MUST also set
|
||||
``allowed_file_types`` to a NON-EMPTY subset of
|
||||
["document", "image", "audio", "video", "custom"] — it is a REQUIRED
|
||||
field and the draft fails to load (showing "supported file types is
|
||||
required") without it. Choose by purpose: ["document"] for text
|
||||
extraction (PDF / Word / PPT / Markdown / …), ["image"] for vision,
|
||||
etc. Always set ``allowed_file_upload_methods`` to
|
||||
["local_file", "remote_url"]. Only when you include "custom" must you
|
||||
also set ``allowed_file_extensions`` to a non-empty list like
|
||||
[".epub", ".rtf"]; otherwise leave it [].
|
||||
In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic
|
||||
system variables — downstream nodes may reference them; do NOT add
|
||||
them to ``variables``.
|
||||
|
||||
them to ``variables``.""",
|
||||
"end": """\
|
||||
- end (Workflow mode only):
|
||||
{"outputs": [
|
||||
{"variable": "result", "value_selector": ["<src-node-id>", "<out-var>"]}
|
||||
]}
|
||||
|
||||
]}""",
|
||||
"answer": """\
|
||||
- answer (Advanced Chat mode only):
|
||||
{"variables": [],
|
||||
"answer": "<text with {{#<src>.<var>#}} placeholders>"}
|
||||
|
||||
"answer": "<text with {{#<src>.<var>#}} placeholders>"}""",
|
||||
"llm": """\
|
||||
- llm:
|
||||
{"model": {"provider": "<provider>", "name": "<model>", "mode": "chat",
|
||||
"completion_params": {"temperature": 0.7}},
|
||||
@ -100,26 +136,26 @@ Children of iteration / loop containers additionally need
|
||||
values are the translations.
|
||||
Input: {{#node1.text#}}
|
||||
* Each placeholder only resolves the variable from its source node —
|
||||
it cannot be a Jinja template or call a function.
|
||||
|
||||
it cannot be a Jinja template or call a function.""",
|
||||
"knowledge-retrieval": """\
|
||||
- knowledge-retrieval:
|
||||
{"query_variable_selector": ["<src>", "<var>"],
|
||||
"query_attachment_selector": [],
|
||||
"dataset_ids": [],
|
||||
"retrieval_mode": "multiple",
|
||||
"multiple_retrieval_config": {"top_k": 4, "score_threshold": null,
|
||||
"reranking_enable": false}}
|
||||
|
||||
"reranking_enable": false}}""",
|
||||
"code": """\
|
||||
- code (escape hatch — only if no installed tool fits):
|
||||
{"code_language": "python3",
|
||||
"code": "def main(arg1: str) -> dict:\\n return {'result': arg1}",
|
||||
"variables": [{"variable": "arg1", "value_selector": ["<src>", "<var>"]}],
|
||||
"outputs": {"result": {"type": "string", "children": null}}}
|
||||
|
||||
"outputs": {"result": {"type": "string", "children": null}}}""",
|
||||
"template-transform": """\
|
||||
- template-transform:
|
||||
{"template": "Hello {{ name }}",
|
||||
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}
|
||||
|
||||
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}""",
|
||||
"http-request": """\
|
||||
- http-request (escape hatch — only if no installed tool fits):
|
||||
{"variables": [], "method": "get", "url": "https://example.com",
|
||||
"authorization": {"type": "no-auth", "config": null},
|
||||
@ -129,8 +165,8 @@ Children of iteration / loop containers additionally need
|
||||
"timeout": {"max_connect_timeout": 0, "max_read_timeout": 0,
|
||||
"max_write_timeout": 0},
|
||||
"retry_config": {"retry_enabled": true, "max_retries": 3,
|
||||
"retry_interval": 100}}
|
||||
|
||||
"retry_interval": 100}}""",
|
||||
"tool": """\
|
||||
- tool (PREFERRED for external actions when listed in Available tools):
|
||||
{"provider_id": "<provider>", # provider portion of provider/tool
|
||||
"provider_type": "builtin", # exact value from catalogue
|
||||
@ -144,8 +180,8 @@ Children of iteration / loop containers additionally need
|
||||
Parameter ``type`` is one of:
|
||||
"mixed" — string template referencing variables ({{#...#}})
|
||||
"variable" — direct reference, value is ["<src>", "<var>"]
|
||||
"constant" — literal value
|
||||
|
||||
"constant" — literal value""",
|
||||
"if-else": """\
|
||||
- if-else:
|
||||
{"_targetBranches": [{"id": "true", "name": "IF"},
|
||||
{"id": "false", "name": "ELSE"}],
|
||||
@ -158,8 +194,8 @@ Children of iteration / loop containers additionally need
|
||||
"comparison_operator": "is",
|
||||
"value": "<value>"}]}
|
||||
]}
|
||||
Source handle for downstream edges = the case_id ("true" / "false").
|
||||
|
||||
Source handle for downstream edges = the case_id ("true" / "false").""",
|
||||
"question-classifier": """\
|
||||
- question-classifier:
|
||||
{"query_variable_selector": ["<src>", "<var>"],
|
||||
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
|
||||
@ -169,8 +205,8 @@ Children of iteration / loop containers additionally need
|
||||
"_targetBranches": [{"id": "1", "name": ""}, {"id": "2", "name": ""}],
|
||||
"vision": {"enabled": false},
|
||||
"instruction": ""}
|
||||
Source handle for downstream edges = the class_id ("1" / "2" / ...).
|
||||
|
||||
Source handle for downstream edges = the class_id ("1" / "2" / ...).""",
|
||||
"parameter-extractor": """\
|
||||
- parameter-extractor:
|
||||
{"query": [["<src>", "<var>"]], # array of value_selector arrays
|
||||
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
|
||||
@ -179,8 +215,8 @@ Children of iteration / loop containers additionally need
|
||||
"description": "<purpose>", "required": true}],
|
||||
"reasoning_mode": "prompt",
|
||||
"vision": {"enabled": false},
|
||||
"instruction": ""}
|
||||
|
||||
"instruction": ""}""",
|
||||
"document-extractor": """\
|
||||
- document-extractor:
|
||||
{"variable_selector": ["<src>", "<file-var>"], # a file / file-list input
|
||||
"is_array_file": false} # true when the input is a
|
||||
@ -188,8 +224,9 @@ Children of iteration / loop containers additionally need
|
||||
Single output variable ``text``: a string when ``is_array_file`` is false,
|
||||
an array of strings (one per file) when it is true. ``variable_selector``
|
||||
MUST point at a ``start`` variable declared with type "file" / "file-list"
|
||||
(or ``sys.files`` in Advanced-Chat mode).
|
||||
|
||||
(or ``sys.files`` in Advanced-Chat mode). That start variable MUST set a
|
||||
non-empty ``allowed_file_types`` (use ["document"] for document text).""",
|
||||
"variable-aggregator": """\
|
||||
- variable-aggregator (merge mutually-exclusive branches into one output):
|
||||
{"output_type": "string", # VarType of the merged value — one of
|
||||
# string | number | object | array[string] |
|
||||
@ -200,8 +237,8 @@ Children of iteration / loop containers additionally need
|
||||
Output variable: ``output`` (the first branch that actually ran). Place it
|
||||
after an ``if-else`` / ``question-classifier`` to rejoin paths before the
|
||||
``end`` / ``answer`` node. Each entry of ``variables`` is a value_selector
|
||||
array, NOT a placeholder string.
|
||||
|
||||
array, NOT a placeholder string.""",
|
||||
"list-operator": """\
|
||||
- list-operator (filter / sort / slice an array variable):
|
||||
{"variable": ["<src>", "<array-var>"],
|
||||
"filter_by": {"enabled": false, "conditions": []},
|
||||
@ -210,8 +247,12 @@ Children of iteration / loop containers additionally need
|
||||
"limit": {"enabled": false, "size": 10}}
|
||||
Enable only the sub-features you need; ``conditions`` reuse the if-else
|
||||
condition shape (key / comparison_operator / value). Outputs: ``result``
|
||||
(the processed array), ``first_record``, ``last_record``.
|
||||
(the processed array), ``first_record``, ``last_record``.""",
|
||||
}
|
||||
|
||||
|
||||
# Pulled into the cheatsheet only when an iteration / loop appears in the plan.
|
||||
_CONTAINERS_SECTION = """\
|
||||
## Containers — iteration / loop
|
||||
|
||||
These are SUBGRAPH nodes. To use one you MUST emit, in order:
|
||||
@ -270,16 +311,59 @@ These are SUBGRAPH nodes. To use one you MUST emit, in order:
|
||||
|
||||
5. The container's incoming/outgoing edges connect to the container's id
|
||||
(``nodeK``), NOT to inner nodes. The first inner edge connects from
|
||||
``nodeKstart``.
|
||||
``nodeKstart``."""
|
||||
|
||||
|
||||
# Always-included trailer: edge handle conventions for every graph.
|
||||
_EDGE_HANDLES_SECTION = """\
|
||||
## Edge handles
|
||||
|
||||
- Most nodes: sourceHandle "source", targetHandle "target".
|
||||
- if-else cases: sourceHandle is the case_id ("true" / "false" / ...).
|
||||
- question-classifier: sourceHandle is the class_id ("1" / "2" / ...).
|
||||
- iteration-start / sourceHandle "source"; the edge from the *start node
|
||||
loop-start: is what kicks off the first inner step.
|
||||
"""
|
||||
loop-start: is what kicks off the first inner step."""
|
||||
|
||||
|
||||
# Container node types are described in ``_CONTAINERS_SECTION`` rather than as
|
||||
# leaf snippets; their presence in a plan pulls that section in.
|
||||
_CONTAINER_NODE_TYPES = frozenset({"iteration", "loop"})
|
||||
|
||||
|
||||
def build_node_config_cheatsheet(node_types: Iterable[str] | None = None) -> str:
|
||||
"""
|
||||
Assemble the builder cheatsheet for exactly the node types in the plan.
|
||||
|
||||
``node_types`` is the set of ``node_type`` strings the planner chose. We
|
||||
emit the always-on preamble (wrapper / shared fields), then only the
|
||||
per-type snippets for the requested types (``start`` is always included —
|
||||
every graph has one), the Containers section when an iteration / loop is
|
||||
planned, and the edge-handles trailer. Unknown / unrecognised type strings
|
||||
are ignored (the runtime / structural validator catches genuinely bogus
|
||||
types).
|
||||
|
||||
``None`` returns the FULL cheatsheet (every snippet + containers) — used to
|
||||
build the static back-compat constants below and as a safe fallback.
|
||||
"""
|
||||
if node_types is None:
|
||||
requested: set[str] = set(_NODE_SNIPPETS) | set(_CONTAINER_NODE_TYPES)
|
||||
else:
|
||||
requested = {str(t).strip() for t in node_types if str(t).strip()}
|
||||
requested.add("start") # every workflow has exactly one start node
|
||||
|
||||
parts: list[str] = [_CHEATSHEET_PREAMBLE]
|
||||
# Iterate _NODE_SNIPPETS (not ``requested``) to keep a stable, readable order.
|
||||
parts.extend(snippet for node_type, snippet in _NODE_SNIPPETS.items() if node_type in requested)
|
||||
if requested & _CONTAINER_NODE_TYPES:
|
||||
parts.append(_CONTAINERS_SECTION)
|
||||
parts.append(_EDGE_HANDLES_SECTION)
|
||||
return "\n\n".join(parts) + "\n"
|
||||
|
||||
|
||||
# Full cheatsheet (all node types) — retained as a module constant so callers
|
||||
# and tests that want the complete reference can import it directly. The
|
||||
# dynamic per-request prompt is built by ``get_builder_system_prompt``.
|
||||
NODE_CONFIG_CHEATSHEET = build_node_config_cheatsheet()
|
||||
|
||||
|
||||
_BASE_SYSTEM_PROMPT_HEAD = """You are a Dify workflow builder.
|
||||
@ -402,21 +486,24 @@ _ADVANCED_CHAT_MODE_RULES = """# Mode-specific rules — Advanced Chat (Chatflow
|
||||
"""
|
||||
|
||||
|
||||
BUILDER_SYSTEM_PROMPT_WORKFLOW = (
|
||||
_BASE_SYSTEM_PROMPT_HEAD
|
||||
+ _WORKFLOW_MODE_RULES
|
||||
+ _BASE_SYSTEM_PROMPT_TAIL
|
||||
+ NODE_CONFIG_CHEATSHEET
|
||||
+ _BASE_SYSTEM_PROMPT_FOOTER
|
||||
)
|
||||
def _assemble_builder_system_prompt(mode: str, node_types: Iterable[str] | None) -> str:
|
||||
"""Stitch the builder system prompt for ``mode`` around a cheatsheet built
|
||||
for ``node_types`` (``None`` → full cheatsheet)."""
|
||||
mode_rules = _ADVANCED_CHAT_MODE_RULES if mode == "advanced-chat" else _WORKFLOW_MODE_RULES
|
||||
return (
|
||||
_BASE_SYSTEM_PROMPT_HEAD
|
||||
+ mode_rules
|
||||
+ _BASE_SYSTEM_PROMPT_TAIL
|
||||
+ build_node_config_cheatsheet(node_types)
|
||||
+ _BASE_SYSTEM_PROMPT_FOOTER
|
||||
)
|
||||
|
||||
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = (
|
||||
_BASE_SYSTEM_PROMPT_HEAD
|
||||
+ _ADVANCED_CHAT_MODE_RULES
|
||||
+ _BASE_SYSTEM_PROMPT_TAIL
|
||||
+ NODE_CONFIG_CHEATSHEET
|
||||
+ _BASE_SYSTEM_PROMPT_FOOTER
|
||||
)
|
||||
|
||||
# Static full-cheatsheet prompts — the back-compat default returned by
|
||||
# ``get_builder_system_prompt`` when the caller doesn't pin a node-type set.
|
||||
BUILDER_SYSTEM_PROMPT_WORKFLOW = _assemble_builder_system_prompt("workflow", None)
|
||||
|
||||
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = _assemble_builder_system_prompt("advanced-chat", None)
|
||||
|
||||
|
||||
BUILDER_USER_PROMPT = """# User instruction
|
||||
@ -546,8 +633,16 @@ def format_plan_block(plan_nodes: list[dict[str, Any]]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_builder_system_prompt(mode: str) -> str:
|
||||
"""Pick the system prompt branch for Workflow vs Advanced Chat."""
|
||||
if mode == "advanced-chat":
|
||||
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
|
||||
return BUILDER_SYSTEM_PROMPT_WORKFLOW
|
||||
def get_builder_system_prompt(mode: str, node_types: Iterable[str] | None = None) -> str:
|
||||
"""
|
||||
Build the builder system prompt for ``mode``, with a cheatsheet scoped to
|
||||
``node_types`` (the planner's chosen node types).
|
||||
|
||||
When ``node_types`` is ``None`` we return the cached full-cheatsheet
|
||||
constant (back-compat default). When the runner passes the plan's node-type
|
||||
set we assemble a fresh prompt carrying only the relevant per-type schemas,
|
||||
so the builder isn't handed config for node types the workflow never uses.
|
||||
"""
|
||||
if node_types is None:
|
||||
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT if mode == "advanced-chat" else BUILDER_SYSTEM_PROMPT_WORKFLOW
|
||||
return _assemble_builder_system_prompt(mode, node_types)
|
||||
|
||||
@ -74,6 +74,21 @@ _DEFAULT_VIEWPORT: GraphViewportDict = {"x": 0.0, "y": 0.0, "zoom": 0.7}
|
||||
_DEFAULT_NODE_WIDTH = 244
|
||||
_DEFAULT_NODE_HEIGHT = 100
|
||||
|
||||
# Start-node input variable types that carry file uploads. Mirrors
|
||||
# ``graphon.variables.input_entities.VariableEntityType.FILE / FILE_LIST``.
|
||||
_FILE_VARIABLE_TYPES = frozenset({"file", "file-list"})
|
||||
|
||||
# Backstop defaults for a file / file-list start variable when the builder
|
||||
# omits the required upload config. ``allowed_file_types`` is a REQUIRED field
|
||||
# (Studio rejects the draft with "supported file types is required" when it's
|
||||
# empty — see ``config-var/config-modal/utils.ts``); we default to every
|
||||
# standard type so no valid upload is rejected. ``custom`` is intentionally
|
||||
# excluded because it would in turn require a non-empty
|
||||
# ``allowed_file_extensions``. The real fix is the builder now documenting and
|
||||
# emitting these fields; this is the safety net that guarantees a loadable draft.
|
||||
_DEFAULT_ALLOWED_FILE_TYPES = ("document", "image", "audio", "video")
|
||||
_DEFAULT_FILE_UPLOAD_METHODS = ("local_file", "remote_url")
|
||||
|
||||
# Token ceiling for the planner call when the caller didn't pin one. The plan
|
||||
# is a short JSON node list (a handful of nodes with labels/purposes), so this
|
||||
# is generous headroom while still bounding a runaway response. The builder is
|
||||
@ -512,8 +527,15 @@ class WorkflowGenerator:
|
||||
tool_catalogue_section=format_builder_tool_catalogue_section(tool_catalogue_text),
|
||||
start_inputs_section=format_start_inputs_section(start_inputs or []),
|
||||
)
|
||||
# Scope the builder cheatsheet to exactly the node types the planner
|
||||
# chose, so the prompt carries each type's FULL schema (e.g. a file
|
||||
# start variable's required ``allowed_file_types``) without dragging in
|
||||
# config for unrelated node types.
|
||||
plan_node_types = {
|
||||
str(node.get("node_type") or "").strip() for node in plan_nodes if str(node.get("node_type") or "").strip()
|
||||
}
|
||||
messages = [
|
||||
SystemPromptMessage(content=get_builder_system_prompt(mode)),
|
||||
SystemPromptMessage(content=get_builder_system_prompt(mode, plan_node_types)),
|
||||
UserPromptMessage(content=user_prompt),
|
||||
]
|
||||
parsed = cls._invoke_and_parse_json(
|
||||
@ -658,6 +680,13 @@ class WorkflowGenerator:
|
||||
# variables before we surface them as errors.
|
||||
cls._reconcile_variable_references(nodes=nodes, mode=mode)
|
||||
|
||||
# Schema backstop: a "file" / "file-list" start variable MUST carry a
|
||||
# non-empty ``allowed_file_types`` or Studio refuses to load the draft
|
||||
# ("supported file types is required"). The builder is now told to set
|
||||
# it, but we fill safe defaults for any variable that still lacks it so
|
||||
# the generated workflow always loads and runs.
|
||||
cls._normalize_start_file_variables(nodes=nodes)
|
||||
|
||||
return cast(GraphDict, {"nodes": nodes, "edges": deduped_edges, "viewport": viewport})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -693,6 +722,21 @@ class WorkflowGenerator:
|
||||
# remapping when we defensively strip hyphens out of LLM-emitted ids.
|
||||
_ID_FIELDS: ClassVar = frozenset({"start_node_id", "iteration_id", "loop_id", "parentId"})
|
||||
|
||||
# ``data`` keys whose value is a plain string list, never a
|
||||
# ``[node_id, var]`` value-selector — so the reference walker must not read
|
||||
# a 2-element one as a selector. ``default`` holds an input's default value;
|
||||
# ``options`` holds select choices; the ``allowed_file_*`` keys hold a file
|
||||
# variable's upload config (types / extensions / methods).
|
||||
_NON_SELECTOR_LIST_KEYS: ClassVar = frozenset(
|
||||
{
|
||||
"default",
|
||||
"options",
|
||||
"allowed_file_types",
|
||||
"allowed_file_extensions",
|
||||
"allowed_file_upload_methods",
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _reconcile_variable_references(cls, *, nodes: list[dict[str, Any]], mode: WorkflowGenerationMode) -> None:
|
||||
"""
|
||||
@ -747,12 +791,16 @@ class WorkflowGenerator:
|
||||
# Known selector shapes: 2-element [node_id, var] lists.
|
||||
for k, v in value.items():
|
||||
# ``value_selector`` / ``query_variable_selector`` / etc.: a
|
||||
# flat 2-element list of strings.
|
||||
# flat 2-element list of strings. Skip keys whose value is a
|
||||
# plain string list that merely HAPPENS to have two entries —
|
||||
# a 2-option ``select`` or a file variable's two allowed upload
|
||||
# methods are NOT ``[node_id, var]`` selectors and must not be
|
||||
# mistaken for references.
|
||||
if (
|
||||
isinstance(v, list)
|
||||
and len(v) == 2
|
||||
and all(isinstance(x, str) for x in v)
|
||||
and k != "default" # default values for input variables are not selectors
|
||||
and k not in cls._NON_SELECTOR_LIST_KEYS
|
||||
):
|
||||
node_id, var = v[0].strip(), v[1].strip()
|
||||
if node_id and var:
|
||||
@ -923,6 +971,96 @@ class WorkflowGenerator:
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _normalize_start_file_variables(cls, *, nodes: list[dict[str, Any]]) -> None:
|
||||
"""
|
||||
Fill the required upload config on every file / file-list start variable.
|
||||
|
||||
A start variable of type ``file`` / ``file-list`` is invalid without a
|
||||
non-empty ``allowed_file_types`` — Studio rejects the draft with
|
||||
"supported file types is required" (see the front-end validator in
|
||||
``config-var/config-modal/utils.ts``) and the workflow never runs. The
|
||||
builder prompt now documents these fields, but LLMs still drop them, so
|
||||
we backfill safe defaults here:
|
||||
|
||||
* a start variable a ``document-extractor`` consumes but that wasn't
|
||||
declared as a file type → promoted to ``file`` (or ``file-list``
|
||||
when the extractor's ``is_array_file`` is set), defaulting its
|
||||
allowed types to ``["document"]`` (what extraction needs);
|
||||
* empty / missing ``allowed_file_types`` → every standard file type;
|
||||
* ``custom`` present without ``allowed_file_extensions`` → drop
|
||||
``custom`` (it would otherwise require a non-empty extension list);
|
||||
* empty / missing ``allowed_file_upload_methods`` → local + remote;
|
||||
* ensure ``allowed_file_extensions`` is at least an empty list.
|
||||
|
||||
Idempotent: a variable that already declares valid file config is left
|
||||
untouched.
|
||||
"""
|
||||
start_node = next(
|
||||
(n for n in nodes if (n.get("data") or {}).get("type") == BuiltinNodeTypes.START),
|
||||
None,
|
||||
)
|
||||
if start_node is None:
|
||||
return
|
||||
variables = (start_node.get("data") or {}).get("variables")
|
||||
if not isinstance(variables, list):
|
||||
return
|
||||
|
||||
# Start variables a document-extractor reads → whether it wants an
|
||||
# array (file-list). These MUST be file inputs even if the builder
|
||||
# mistyped them (e.g. declared "paragraph"), or the extractor fails at
|
||||
# run time. ``["document"]`` is the right default for text extraction.
|
||||
extractor_file_vars = cls._document_extractor_start_vars(nodes=nodes, start_id=start_node.get("id", ""))
|
||||
|
||||
for var in variables:
|
||||
if not isinstance(var, dict):
|
||||
continue
|
||||
name = var.get("variable")
|
||||
if name in extractor_file_vars and var.get("type") not in _FILE_VARIABLE_TYPES:
|
||||
var["type"] = "file-list" if extractor_file_vars[name] else "file"
|
||||
var.setdefault("allowed_file_types", ["document"])
|
||||
if var.get("type") not in _FILE_VARIABLE_TYPES:
|
||||
continue
|
||||
allowed_types = var.get("allowed_file_types")
|
||||
if not isinstance(allowed_types, list) or not allowed_types:
|
||||
allowed_types = list(_DEFAULT_ALLOWED_FILE_TYPES)
|
||||
var["allowed_file_types"] = allowed_types
|
||||
# ``custom`` demands a non-empty extension list; without one, drop it
|
||||
# so the variable doesn't trip the "file extensions required" check.
|
||||
extensions = var.get("allowed_file_extensions")
|
||||
has_extensions = isinstance(extensions, list) and bool(extensions)
|
||||
if "custom" in allowed_types and not has_extensions:
|
||||
pruned = [t for t in allowed_types if t != "custom"]
|
||||
var["allowed_file_types"] = pruned or list(_DEFAULT_ALLOWED_FILE_TYPES)
|
||||
methods = var.get("allowed_file_upload_methods")
|
||||
if not isinstance(methods, list) or not methods:
|
||||
var["allowed_file_upload_methods"] = list(_DEFAULT_FILE_UPLOAD_METHODS)
|
||||
if not isinstance(var.get("allowed_file_extensions"), list):
|
||||
var["allowed_file_extensions"] = []
|
||||
|
||||
@classmethod
|
||||
def _document_extractor_start_vars(cls, *, nodes: list[dict[str, Any]], start_id: str) -> dict[str, bool]:
|
||||
"""
|
||||
Map start-variable name → ``is_array_file`` for every start variable a
|
||||
``document-extractor`` node reads via its ``variable_selector``.
|
||||
|
||||
When two extractors read the same variable we keep ``True`` (file-list)
|
||||
if any of them wants an array, since a file-list also satisfies a
|
||||
single-file read.
|
||||
"""
|
||||
out: dict[str, bool] = {}
|
||||
if not start_id:
|
||||
return out
|
||||
for node in nodes:
|
||||
data = node.get("data") or {}
|
||||
if data.get("type") != BuiltinNodeTypes.DOCUMENT_EXTRACTOR:
|
||||
continue
|
||||
selector = data.get("variable_selector")
|
||||
if isinstance(selector, list) and len(selector) == 2 and selector[0] == start_id:
|
||||
var_name = selector[1]
|
||||
out[var_name] = out.get(var_name, False) or bool(data.get("is_array_file"))
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def _fill_node_defaults(cls, node: dict[str, Any]) -> None:
|
||||
"""Ensure every node has the wrapper-level fields the Studio canvas needs."""
|
||||
|
||||
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import enum
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Annotated, Any, ClassVar, Literal
|
||||
from typing import Annotated, Any, ClassVar, Literal, override
|
||||
|
||||
import bleach
|
||||
import markdown
|
||||
@ -158,6 +158,7 @@ class EmailDeliveryMethod(_DeliveryMethodBase):
|
||||
type: Literal[DeliveryMethodType.EMAIL] = DeliveryMethodType.EMAIL
|
||||
config: EmailDeliveryConfig
|
||||
|
||||
@override
|
||||
def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
|
||||
variable_template_parser = VariableTemplateParser(template=self.config.body)
|
||||
selectors: list[Sequence[str]] = []
|
||||
|
||||
@ -195,13 +195,16 @@ class _LazyNodeTypeClassesMapping(MutableMapping[NodeType, Mapping[str, type[Nod
|
||||
snapshot.update(self._overrides)
|
||||
return snapshot
|
||||
|
||||
@override
|
||||
def __getitem__(self, key: NodeType) -> Mapping[str, type[Node]]:
|
||||
return self._snapshot()[key]
|
||||
|
||||
@override
|
||||
def __setitem__(self, key: NodeType, value: Mapping[str, type[Node]]) -> None:
|
||||
self._deleted.discard(key)
|
||||
self._overrides[key] = value
|
||||
|
||||
@override
|
||||
def __delitem__(self, key: NodeType) -> None:
|
||||
if key in self._overrides:
|
||||
del self._overrides[key]
|
||||
@ -211,9 +214,11 @@ class _LazyNodeTypeClassesMapping(MutableMapping[NodeType, Mapping[str, type[Nod
|
||||
return
|
||||
raise KeyError(key)
|
||||
|
||||
@override
|
||||
def __iter__(self) -> Iterator[NodeType]:
|
||||
return iter(self._snapshot())
|
||||
|
||||
@override
|
||||
def __len__(self) -> int:
|
||||
return len(self._snapshot())
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Generator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, overload
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, overload, override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@ -136,6 +136,7 @@ class DifyFileReferenceFactory(FileReferenceFactoryProtocol):
|
||||
def __init__(self, run_context: Mapping[str, Any] | DifyRunContext) -> None:
|
||||
self._run_context = resolve_dify_run_context(run_context)
|
||||
|
||||
@override
|
||||
def build_from_mapping(self, *, mapping: Mapping[str, Any]):
|
||||
return file_factory.build_from_mapping(
|
||||
mapping=mapping,
|
||||
@ -151,25 +152,31 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
self._model_instance = model_instance
|
||||
|
||||
@property
|
||||
@override
|
||||
def provider(self) -> str:
|
||||
return self._model_instance.provider
|
||||
|
||||
@property
|
||||
@override
|
||||
def model_name(self) -> str:
|
||||
return self._model_instance.model_name
|
||||
|
||||
@property
|
||||
@override
|
||||
def parameters(self) -> Mapping[str, Any]:
|
||||
return self._model_instance.parameters
|
||||
|
||||
@parameters.setter
|
||||
@override
|
||||
def parameters(self, value: Mapping[str, Any]) -> None:
|
||||
self._model_instance.parameters = value
|
||||
|
||||
@property
|
||||
@override
|
||||
def stop(self) -> Sequence[str] | None:
|
||||
return self._model_instance.stop
|
||||
|
||||
@override
|
||||
def get_model_schema(self) -> AIModelEntity:
|
||||
model_schema = cast(LargeLanguageModel, self._model_instance.model_type_instance).get_model_schema(
|
||||
self._model_instance.model_name,
|
||||
@ -179,6 +186,7 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
raise ValueError(f"Model schema not found for {self._model_instance.model_name}")
|
||||
return model_schema
|
||||
|
||||
@override
|
||||
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
|
||||
return self._model_instance.get_llm_num_tokens(prompt_messages)
|
||||
|
||||
@ -204,6 +212,7 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
stream: Literal[True],
|
||||
) -> Generator[LLMResultChunk, None, None]: ...
|
||||
|
||||
@override
|
||||
def invoke_llm(
|
||||
self,
|
||||
*,
|
||||
@ -243,6 +252,7 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
stream: Literal[True],
|
||||
) -> Generator[LLMResultChunkWithStructuredOutput, None, None]: ...
|
||||
|
||||
@override
|
||||
def invoke_llm_with_structured_output(
|
||||
self,
|
||||
*,
|
||||
@ -263,11 +273,13 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
@override
|
||||
def is_structured_output_parse_error(self, error: Exception) -> bool:
|
||||
return isinstance(error, OutputParserError)
|
||||
|
||||
|
||||
class DifyPromptMessageSerializer(PromptMessageSerializerProtocol):
|
||||
@override
|
||||
def serialize(
|
||||
self,
|
||||
*,
|
||||
@ -294,6 +306,7 @@ class DifyRetrieverAttachmentLoader(RetrieverAttachmentLoaderProtocol):
|
||||
self._file_reference_factory = file_reference_factory
|
||||
self._segment_access_checker = segment_access_checker
|
||||
|
||||
@override
|
||||
def load(self, *, segment_id: str) -> Sequence[File]:
|
||||
if not is_retriever_segment_access_granted(segment_id):
|
||||
return []
|
||||
@ -341,6 +354,7 @@ class DifyToolFileManager(ToolFileManagerProtocol):
|
||||
self._manager = ToolFileManager()
|
||||
self._conversation_id_getter = conversation_id_getter
|
||||
|
||||
@override
|
||||
def create_file_by_raw(
|
||||
self,
|
||||
*,
|
||||
@ -358,6 +372,7 @@ class DifyToolFileManager(ToolFileManagerProtocol):
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
@override
|
||||
def get_file_generator_by_tool_file_id(self, tool_file_id: str):
|
||||
return self._manager.get_file_generator_by_tool_file_id(tool_file_id)
|
||||
|
||||
@ -394,9 +409,11 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
|
||||
def file_reference_factory(self) -> FileReferenceFactoryProtocol:
|
||||
return self._file_reference_factory
|
||||
|
||||
@override
|
||||
def build_file_reference(self, *, mapping: Mapping[str, Any]):
|
||||
return self._file_reference_factory.build_from_mapping(mapping=mapping)
|
||||
|
||||
@override
|
||||
def get_runtime(
|
||||
self,
|
||||
*,
|
||||
@ -447,6 +464,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def get_runtime_parameters(
|
||||
self,
|
||||
*,
|
||||
@ -458,6 +476,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
|
||||
for parameter in (tool.get_merged_runtime_parameters() or [])
|
||||
]
|
||||
|
||||
@override
|
||||
def invoke(
|
||||
self,
|
||||
*,
|
||||
@ -503,6 +522,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
|
||||
|
||||
return self._adapt_messages(transformed_messages, provider_name=provider_name)
|
||||
|
||||
@override
|
||||
def get_usage(
|
||||
self,
|
||||
*,
|
||||
@ -745,6 +765,7 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol):
|
||||
form_repository=form_repository,
|
||||
)
|
||||
|
||||
@override
|
||||
def get_form(self, *, node_id: str) -> HumanInputFormStateProtocol | None:
|
||||
repo = self.build_form_repository()
|
||||
return repo.get_form(node_id)
|
||||
@ -766,6 +787,7 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol):
|
||||
)
|
||||
return restored_data
|
||||
|
||||
@override
|
||||
def create_form(
|
||||
self,
|
||||
*,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
@ -56,9 +56,11 @@ class AgentNode(Node[AgentNodeData]):
|
||||
self._message_transformer = message_transformer
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def version(cls) -> str:
|
||||
return "1"
|
||||
|
||||
@override
|
||||
def populate_start_event(self, event) -> None:
|
||||
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
|
||||
event.extras["agent_strategy"] = {
|
||||
@ -69,6 +71,7 @@ class AgentNode(Node[AgentNodeData]):
|
||||
),
|
||||
}
|
||||
|
||||
@override
|
||||
def _run(self) -> Generator[NodeEventBase, None, None]:
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
|
||||
@ -167,6 +170,7 @@ class AgentNode(Node[AgentNodeData]):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
cls,
|
||||
*,
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import override
|
||||
|
||||
from factories.agent_factory import get_plugin_agent_strategy
|
||||
|
||||
from .strategy_protocols import AgentStrategyPresentationProvider, AgentStrategyResolver, ResolvedAgentStrategy
|
||||
|
||||
|
||||
class PluginAgentStrategyResolver(AgentStrategyResolver):
|
||||
@override
|
||||
def resolve(
|
||||
self,
|
||||
*,
|
||||
@ -21,6 +24,7 @@ class PluginAgentStrategyResolver(AgentStrategyResolver):
|
||||
|
||||
|
||||
class PluginAgentStrategyPresentationProvider(AgentStrategyPresentationProvider):
|
||||
@override
|
||||
def get_icon(self, *, tenant_id: str, agent_strategy_provider_name: str) -> str | None:
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
|
||||
@ -101,12 +101,15 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
self._session_store = session_store
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def version(cls) -> str:
|
||||
return "2"
|
||||
|
||||
@override
|
||||
def populate_start_event(self, event) -> None:
|
||||
event.extras["agent_node"] = {"version": "2", "agent_node_kind": self.node_data.agent_node_kind}
|
||||
|
||||
@override
|
||||
def _run(self) -> Generator[NodeEventBase, None, None]:
|
||||
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
|
||||
workflow_id = self.graph_init_params.workflow_id
|
||||
@ -577,6 +580,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
metadata["agent_backend"] = agent_backend
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
cls,
|
||||
*,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
|
||||
from core.datasource.datasource_manager import DatasourceManager
|
||||
@ -49,10 +49,12 @@ class DatasourceNode(Node[DatasourceNodeData]):
|
||||
)
|
||||
self.datasource_manager = DatasourceManager
|
||||
|
||||
@override
|
||||
def populate_start_event(self, event) -> None:
|
||||
event.provider_id = f"{self.node_data.plugin_id}/{self.node_data.provider_name}"
|
||||
event.provider_type = self.node_data.provider_type
|
||||
|
||||
@override
|
||||
def _run(self) -> Generator:
|
||||
"""
|
||||
Run the datasource node
|
||||
@ -183,6 +185,7 @@ class DatasourceNode(Node[DatasourceNodeData]):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
cls,
|
||||
*,
|
||||
@ -219,5 +222,6 @@ class DatasourceNode(Node[DatasourceNodeData]):
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def version(cls) -> str:
|
||||
return "1"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from core.rag.index_processor.index_processor import IndexProcessor
|
||||
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
|
||||
@ -46,6 +46,7 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
|
||||
self.index_processor = IndexProcessor()
|
||||
self.summary_index_service = SummaryIndex()
|
||||
|
||||
@override
|
||||
def _run(self) -> NodeRunResult:
|
||||
node_data = self.node_data
|
||||
variable_pool = self.graph_runtime_state.variable_pool
|
||||
@ -145,6 +146,7 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
|
||||
return rst
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def version(cls) -> str:
|
||||
return "1"
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ the workflow node registry.
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal, override
|
||||
|
||||
from core.app.app_config.entities import DatasetRetrieveConfigEntity
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
|
||||
@ -87,9 +87,11 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
self._rag_retrieval = DatasetRetrieval()
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def version(cls):
|
||||
return "1"
|
||||
|
||||
@override
|
||||
def _run(self) -> NodeRunResult:
|
||||
usage = LLMUsage.empty_usage()
|
||||
if not self._node_data.query_variable_selector and not self._node_data.query_attachment_selector:
|
||||
@ -327,6 +329,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
cls,
|
||||
*,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
|
||||
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
|
||||
@ -15,6 +15,7 @@ class TriggerEventNode(Node[TriggerEventNodeData]):
|
||||
execution_type = NodeExecutionType.ROOT
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
|
||||
return {
|
||||
"type": "plugin",
|
||||
@ -30,12 +31,15 @@ class TriggerEventNode(Node[TriggerEventNodeData]):
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def version(cls) -> str:
|
||||
return "1"
|
||||
|
||||
@override
|
||||
def populate_start_event(self, event) -> None:
|
||||
event.provider_id = self.node_data.provider_id
|
||||
|
||||
@override
|
||||
def _run(self) -> NodeRunResult:
|
||||
"""
|
||||
Run the plugin trigger node.
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import override
|
||||
|
||||
from core.trigger.constants import TRIGGER_SCHEDULE_NODE_TYPE
|
||||
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
|
||||
@ -14,10 +15,12 @@ class TriggerScheduleNode(Node[TriggerScheduleNodeData]):
|
||||
execution_type = NodeExecutionType.ROOT
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def version(cls) -> str:
|
||||
return "1"
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
|
||||
return {
|
||||
"type": TRIGGER_SCHEDULE_NODE_TYPE,
|
||||
@ -29,6 +32,7 @@ class TriggerScheduleNode(Node[TriggerScheduleNodeData]):
|
||||
},
|
||||
}
|
||||
|
||||
@override
|
||||
def _run(self) -> NodeRunResult:
|
||||
node_inputs = dict(self.graph_runtime_state.variable_pool.get_by_prefix(self.id))
|
||||
system_inputs = self.graph_runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE
|
||||
from core.workflow.file_reference import resolve_file_record_id
|
||||
@ -25,12 +25,14 @@ class TriggerWebhookNode(Node[WebhookData]):
|
||||
|
||||
_file_reference_factory: FileReferenceFactoryProtocol
|
||||
|
||||
@override
|
||||
def post_init(self) -> None:
|
||||
from core.workflow.node_runtime import DifyFileReferenceFactory
|
||||
|
||||
self._file_reference_factory = DifyFileReferenceFactory(self.run_context)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
|
||||
return {
|
||||
"type": "webhook",
|
||||
@ -48,9 +50,11 @@ class TriggerWebhookNode(Node[WebhookData]):
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def version(cls) -> str:
|
||||
return "1"
|
||||
|
||||
@override
|
||||
def _run(self) -> NodeRunResult:
|
||||
"""
|
||||
Run the webhook node.
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from core.helper.code_executor.code_executor import CodeExecutionError, CodeExecutor
|
||||
from graphon.nodes.code.entities import CodeLanguage
|
||||
@ -11,6 +11,7 @@ from graphon.template_rendering import Jinja2TemplateRenderer, TemplateRenderErr
|
||||
class CodeExecutorJinja2TemplateRenderer(Jinja2TemplateRenderer):
|
||||
"""Sandbox-backed Jinja2 renderer for workflow-owned node composition."""
|
||||
|
||||
@override
|
||||
def render_template(self, template: str, variables: Mapping[str, Any]) -> str:
|
||||
try:
|
||||
result = CodeExecutor.execute_workflow_code_template(
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from typing import override
|
||||
|
||||
"""Custom OTEL ID Generator for correlation-based trace/span ID derivation.
|
||||
|
||||
Uses contextvars for thread-safe correlation_id -> trace_id mapping.
|
||||
@ -52,6 +54,7 @@ class CorrelationIdGenerator(IdGenerator):
|
||||
parent-child linking), otherwise random
|
||||
"""
|
||||
|
||||
@override
|
||||
def generate_trace_id(self) -> int:
|
||||
correlation_id = _correlation_id_context.get()
|
||||
if correlation_id:
|
||||
@ -61,6 +64,7 @@ class CorrelationIdGenerator(IdGenerator):
|
||||
pass
|
||||
return random.getrandbits(128)
|
||||
|
||||
@override
|
||||
def generate_span_id(self) -> int:
|
||||
source = _span_id_source_context.get()
|
||||
if source:
|
||||
|
||||
@ -5,6 +5,7 @@ def init_app(app: DifyApp):
|
||||
from commands import (
|
||||
add_qdrant_index,
|
||||
archive_workflow_runs,
|
||||
backfill_plugin_auto_upgrade,
|
||||
clean_expired_messages,
|
||||
clean_workflow_runs,
|
||||
cleanup_orphaned_draft_variables,
|
||||
@ -53,6 +54,7 @@ def init_app(app: DifyApp):
|
||||
upgrade_db,
|
||||
fix_app_site_missing,
|
||||
migrate_data_for_plugin,
|
||||
backfill_plugin_auto_upgrade,
|
||||
extract_plugins,
|
||||
extract_unique_plugins,
|
||||
install_plugins,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import json
|
||||
from typing import override
|
||||
|
||||
from flask_restx import fields
|
||||
|
||||
@ -7,6 +8,7 @@ from libs.helper import AppIconUrlField, TimestampField
|
||||
|
||||
|
||||
class JsonStringField(fields.Raw):
|
||||
@override
|
||||
def format(self, value):
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
from typing import override
|
||||
|
||||
from flask_restx import fields
|
||||
|
||||
from graphon.file import File
|
||||
|
||||
|
||||
class FilesContainedField(fields.Raw):
|
||||
@override
|
||||
def format(self, value):
|
||||
return self._format_file_object(value)
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from typing import override
|
||||
|
||||
from flask_restx import fields
|
||||
|
||||
from core.helper import encrypter
|
||||
@ -11,6 +13,7 @@ ENVIRONMENT_VARIABLE_SUPPORTED_TYPES = (SegmentType.STRING, SegmentType.NUMBER,
|
||||
|
||||
|
||||
class EnvironmentVariableField(fields.Raw):
|
||||
@override
|
||||
def format(self, value):
|
||||
# Mask secret variables values in environment_variables
|
||||
if isinstance(value, SecretVariable):
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
"""add plugin auto upgrade category
|
||||
|
||||
Revision ID: f6a7b8c9d012
|
||||
Revises: 8d4c2a1b9f03
|
||||
Create Date: 2026-05-15 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f6a7b8c9d012"
|
||||
down_revision = "8d4c2a1b9f03"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
LEGACY_CATEGORY = "tool"
|
||||
UNIQUE_CONSTRAINT_NAME = "unique_tenant_plugin_auto_upgrade_strategy"
|
||||
UPGRADE_TIME_INDEX_NAME = "idx_tenant_plugin_auto_upgrade_strategy_time"
|
||||
STRATEGY_TABLE_NAME = "tenant_plugin_auto_upgrade_strategies"
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("category", sa.String(length=32), server_default=LEGACY_CATEGORY, nullable=False)
|
||||
)
|
||||
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
|
||||
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id", "category"])
|
||||
batch_op.create_index(UPGRADE_TIME_INDEX_NAME, ["upgrade_time_of_day"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute(sa.text(f"DELETE FROM {STRATEGY_TABLE_NAME} WHERE category != '{LEGACY_CATEGORY}'"))
|
||||
|
||||
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
|
||||
batch_op.drop_index(UPGRADE_TIME_INDEX_NAME)
|
||||
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
|
||||
batch_op.drop_column("category")
|
||||
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id"])
|
||||
@ -0,0 +1,26 @@
|
||||
"""add learn dify flag to recommended apps
|
||||
|
||||
Revision ID: f5e8a9c0d2b3
|
||||
Revises: f6a7b8c9d012
|
||||
Create Date: 2026-05-18 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f5e8a9c0d2b3"
|
||||
down_revision = "f6a7b8c9d012"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("is_learn_dify", sa.Boolean(), server_default=sa.text("false"), nullable=False))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
|
||||
batch_op.drop_column("is_learn_dify")
|
||||
@ -0,0 +1,44 @@
|
||||
"""add identity mode to mcp tool provider
|
||||
|
||||
Revision ID: 3df4dbcc1e21
|
||||
Revises: 2b3c4d5e6f70
|
||||
Create Date: 2026-05-29 15:00:00.000000
|
||||
|
||||
Adds the `identity_mode` column to `tool_mcp_providers` to drive the M2 MCP
|
||||
user-identity forwarding feature. Reserved values:
|
||||
|
||||
"off" — no header forwarded (default; pre-M2 behaviour).
|
||||
"idp_token" — call dify-enterprise /inner/api/mcp/issue-token, stamp the
|
||||
returned SSO access token on the outbound MCP request as
|
||||
`X-Dify-SSO-Access-Token: <token>`.
|
||||
|
||||
The column is filled with the safe default "off" for existing rows so older
|
||||
providers keep their current behaviour until an admin opts in.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models as models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "3df4dbcc1e21"
|
||||
down_revision = "2b3c4d5e6f70"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"tool_mcp_providers",
|
||||
sa.Column(
|
||||
"identity_mode",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'off'"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("tool_mcp_providers", "identity_mode")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user