Compare commits
279 Commits
linw/snp-5
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4929a67885 | ||
|
|
e036075e22 | ||
|
|
5a84cde198 | ||
|
|
4e18ab0ad4 | ||
|
|
2a742486c5 | ||
|
|
d362bdbb4e | ||
|
|
60d6be5b13 | ||
|
|
cc1b55aaf7 | ||
|
|
d1fa17032e | ||
|
|
916d7e1c96 | ||
|
|
c094117ca2 | ||
|
|
c6b1311aa3 | ||
|
|
515c3d0c62 | ||
|
|
fe94ddaaeb | ||
|
|
3e8c0fdcef | ||
|
|
055462fe6e | ||
|
|
06e229e8b2 | ||
|
|
fa498ad7a2 | ||
|
|
a279be91a8 | ||
|
|
12300f3833 | ||
|
|
b439b6a800 | ||
|
|
7a7d8fe19e | ||
|
|
2635d1b3df | ||
|
|
a952487789 | ||
|
|
c9e6db5ef2 | ||
|
|
b31e66107c | ||
|
|
42d6589ce2 | ||
|
|
f00d0329d4 | ||
|
|
af9d2d00a3 | ||
|
|
ae8cb996c7 | ||
|
|
adb89a8926 | ||
|
|
3a4920c595 | ||
|
|
276c3b6e02 | ||
|
|
7ff5c85796 | ||
|
|
b8330df96e | ||
|
|
7422052759 | ||
|
|
ab0e131e87 | ||
|
|
1375dc3864 | ||
|
|
6530f2bcdb | ||
|
|
7039217c99 | ||
|
|
924d787e41 | ||
|
|
baba36d47d | ||
|
|
4b2c5ac4ae | ||
|
|
73197e4354 | ||
|
|
5dfd83e9ab | ||
|
|
6f2e4d72d3 | ||
|
|
b76a14d71d | ||
|
|
1983e842ca | ||
|
|
90d6046345 | ||
|
|
05c7386013 | ||
|
|
4d0bc32dd3 | ||
|
|
d6336c5d49 | ||
|
|
7073baa737 | ||
|
|
78129cf6c3 | ||
|
|
1a69435fff | ||
|
|
33b238343b | ||
|
|
4b8a2c7e6c | ||
|
|
06d8301a90 | ||
|
|
dc309ced59 | ||
|
|
cf88aef710 | ||
|
|
0277b9eb30 | ||
|
|
8fbbfbc243 | ||
|
|
5bceac05eb | ||
|
|
febcc4e054 | ||
|
|
36c990a0cf | ||
|
|
658d5e0dcf | ||
|
|
cfef48c408 | ||
|
|
1973a2e96f | ||
|
|
b105f21763 | ||
|
|
e5a07e97a4 | ||
|
|
046df1260a | ||
|
|
9e95e1302e | ||
|
|
59cbba3b37 | ||
|
|
20b31de791 | ||
|
|
5e036e5489 | ||
|
|
e6ede05fe9 | ||
|
|
7edd05100a | ||
|
|
46024b6ad8 | ||
|
|
123f128ccc | ||
|
|
6474059eb1 | ||
|
|
479a5613a8 | ||
|
|
d51900974f | ||
|
|
f08cc41546 | ||
|
|
2443df2e42 | ||
|
|
2e65ab9d1e | ||
|
|
6add64a44d | ||
|
|
8170a2a5f3 | ||
|
|
70051950ab | ||
|
|
f7881bb635 | ||
|
|
b6b3e747f3 | ||
|
|
18f740402f | ||
|
|
15a5317bd3 | ||
|
|
1369a2cdfc | ||
|
|
ac0c909d11 | ||
|
|
945503523b | ||
|
|
fe7e2dc62f | ||
|
|
3bb21a9a88 | ||
|
|
dc09384b1d | ||
|
|
890e5aa294 | ||
|
|
0bf3416431 | ||
|
|
685e291e65 | ||
|
|
c15dd6d994 | ||
|
|
72b30ff313 | ||
|
|
7dd7b3dfa8 | ||
|
|
d033124552 | ||
|
|
2fd8f98f2d | ||
|
|
7d6d173b01 | ||
|
|
2dc6b08044 | ||
|
|
896e5e20c0 | ||
|
|
143bc121da | ||
|
|
4213e6b150 | ||
|
|
b462cb041d | ||
|
|
9d81f0da10 | ||
|
|
482ec5e712 | ||
|
|
36a4c98732 | ||
|
|
dec1cb3798 | ||
|
|
3ada29bbe0 | ||
|
|
8f817644e9 | ||
|
|
0271ca6e5f | ||
|
|
aab31292d8 | ||
|
|
545bdecff2 | ||
|
|
7d92097526 | ||
|
|
edcd3fc2e3 | ||
|
|
be413650ba | ||
|
|
2d2cc7cab1 | ||
|
|
2838957004 | ||
|
|
f6852205d2 | ||
|
|
9802329b91 | ||
|
|
dc5192ba11 | ||
|
|
21dbc6c60c | ||
|
|
a1769d0b3d | ||
|
|
be0e2c03e4 | ||
|
|
1b7a132afa | ||
|
|
f2d0001424 | ||
|
|
9ea21e4fd1 | ||
|
|
5456d4d56e | ||
|
|
b4f360127d | ||
|
|
f15a514878 | ||
|
|
ca1cec6011 | ||
|
|
0a05328eb2 | ||
|
|
6e9a3f8654 | ||
|
|
730794426a | ||
|
|
a77236e564 | ||
|
|
984045190c | ||
|
|
612ad161bc | ||
|
|
e2bc8f2c17 | ||
|
|
dc8dd09450 | ||
|
|
982c0667e1 | ||
|
|
66a69c30f5 | ||
|
|
a5f5acbe0c | ||
|
|
2f52ac94fe | ||
|
|
5d151ec214 | ||
|
|
bb68a05f9e | ||
|
|
61583e3f0f | ||
|
|
351577bdb0 | ||
|
|
8dbac96621 | ||
|
|
26b4798e88 | ||
|
|
802fe70423 | ||
|
|
02a9c51a3e | ||
|
|
83b3a8fd88 | ||
|
|
8a2cf65ec5 | ||
|
|
a42d486bc0 | ||
|
|
b6824334cf | ||
|
|
eec0e2e44e | ||
|
|
f6832fddee | ||
|
|
e7380865d8 | ||
|
|
c4d9919d30 | ||
|
|
4557f127ff | ||
|
|
97e9d8be5a | ||
|
|
074811106d | ||
|
|
33518407c9 | ||
|
|
f6bbb94112 | ||
|
|
917207414b | ||
|
|
753583c06c | ||
|
|
8c4c1dc656 | ||
|
|
bab3db7f77 | ||
|
|
9ee7750411 | ||
|
|
5800338911 | ||
|
|
5e71fdde0f | ||
|
|
7e30aba12a | ||
|
|
c62ab071eb | ||
|
|
b4d93b02fd | ||
|
|
37bf80dcf6 | ||
|
|
f9966c2ee4 | ||
|
|
c562f78d4f | ||
|
|
0d8c02ca62 | ||
|
|
4b415e7d6b | ||
|
|
676dff04c8 | ||
|
|
908dc703ec | ||
|
|
bea2f78e09 | ||
|
|
5e6154fdd1 | ||
|
|
69a16d8319 | ||
|
|
563cf2602f | ||
|
|
3aa937def4 | ||
|
|
f057a7d32f | ||
|
|
8cca3959ae | ||
|
|
48bb46a72b | ||
|
|
6a52069872 | ||
|
|
72afcc1326 | ||
|
|
e834fa212d | ||
|
|
b32c900761 | ||
|
|
e8b17345c3 | ||
|
|
95166806ae | ||
|
|
e4947811a5 | ||
|
|
0bb1b4d6d2 | ||
|
|
01ff1b5128 | ||
|
|
71ae1d5511 | ||
|
|
3d01e8389a | ||
|
|
44a3ea7c0c | ||
|
|
1fd9ab14f9 | ||
|
|
0aefcfa373 | ||
|
|
09d5aa47bb | ||
|
|
b852252076 | ||
|
|
814fee5550 | ||
|
|
754c380e2d | ||
|
|
9f17e1ca95 | ||
|
|
61a5b4d205 | ||
|
|
7b33622cdd | ||
|
|
942a16b2a5 | ||
|
|
97a13b6c36 | ||
|
|
7fd2f4f4e8 | ||
|
|
1593b7cc60 | ||
|
|
3f1036e55a | ||
|
|
7c9a0ce93b | ||
|
|
9e8733413a | ||
|
|
3453fdce44 | ||
|
|
6804dd474d | ||
|
|
edd6a6e77f | ||
|
|
19b0a8ce08 | ||
|
|
e12e86a308 | ||
|
|
b855d71adb | ||
|
|
87aa982185 | ||
|
|
bea82fb57f | ||
|
|
a21d2b1201 | ||
|
|
fdaa073d53 | ||
|
|
89ceba027e | ||
|
|
832f42a996 | ||
|
|
72c20daa61 | ||
|
|
81fd1da3ff | ||
|
|
fc2f1f4f75 | ||
|
|
080a1d42f2 | ||
|
|
0441d27cac | ||
|
|
10621b5a9e | ||
|
|
c1fdd6fb78 | ||
|
|
fd804dfcc0 | ||
|
|
155e4fbdc5 | ||
|
|
20b9278ca3 | ||
|
|
2844948aa0 | ||
|
|
62791579ab | ||
|
|
dbabddf1ab | ||
|
|
3ad925a66a | ||
|
|
54aced9e20 | ||
|
|
0d6b2fc40c | ||
|
|
14a938c5c5 | ||
|
|
d4eab4ae88 | ||
|
|
8629b91517 | ||
|
|
0290c8d525 | ||
|
|
c5b0772ab0 | ||
|
|
8e74d7d715 | ||
|
|
103c864b35 | ||
|
|
133e974dff | ||
|
|
958d8a7997 | ||
|
|
b6bfae7e3c | ||
|
|
8946d7fbf4 | ||
|
|
e604fad630 | ||
|
|
f07cc3dfb4 | ||
|
|
6506aaae83 | ||
|
|
f6683c2c6b | ||
|
|
7eba562f7d | ||
|
|
fd412a82e9 | ||
|
|
1419c7c6db | ||
|
|
7083a953e1 | ||
|
|
93e7d50845 | ||
|
|
a048f35099 | ||
|
|
22cef67804 | ||
|
|
3bc8c69def | ||
|
|
798e5ed7a7 | ||
|
|
eef709e475 | ||
|
|
04158ac8ea |
8
.github/DISCUSSION_TEMPLATE/general.yml
vendored
8
.github/DISCUSSION_TEMPLATE/general.yml
vendored
@@ -1,17 +1,17 @@
|
||||
title: "General Discussion"
|
||||
title: 'General Discussion'
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
|
||||
required: true
|
||||
- label: I confirm that I am using English to submit this report (我已阅读并同意 [Language Policy](https://github.com/langgenius/dify/issues/1542)).
|
||||
required: true
|
||||
- label: "[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)"
|
||||
- label: '[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)'
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
8
.github/DISCUSSION_TEMPLATE/help.yml
vendored
8
.github/DISCUSSION_TEMPLATE/help.yml
vendored
@@ -1,17 +1,17 @@
|
||||
title: "Help"
|
||||
title: 'Help'
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
|
||||
required: true
|
||||
- label: I confirm that I am using English to submit this report (我已阅读并同意 [Language Policy](https://github.com/langgenius/dify/issues/1542)).
|
||||
required: true
|
||||
- label: "[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)"
|
||||
- label: '[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)'
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
6
.github/DISCUSSION_TEMPLATE/suggestion.yml
vendored
6
.github/DISCUSSION_TEMPLATE/suggestion.yml
vendored
@@ -3,15 +3,15 @@ body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
|
||||
required: true
|
||||
- label: I confirm that I am using English to submit this report (我已阅读并同意 [Language Policy](https://github.com/langgenius/dify/issues/1542)).
|
||||
required: true
|
||||
- label: "[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)"
|
||||
- label: '[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)'
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: "🕷️ Bug report"
|
||||
name: '🕷️ Bug report'
|
||||
description: Report errors or unexpected behavior
|
||||
labels:
|
||||
- bug
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
|
||||
required: true
|
||||
@@ -18,7 +18,7 @@ body:
|
||||
required: true
|
||||
- label: 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,13 +1,13 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "\U0001F510 Security Vulnerabilities"
|
||||
url: "https://github.com/langgenius/dify/security/advisories/new"
|
||||
url: 'https://github.com/langgenius/dify/security/advisories/new'
|
||||
about: Report security vulnerabilities through GitHub Security Advisories to ensure responsible disclosure. 💡 Please do not report security vulnerabilities in public issues.
|
||||
- name: "\U0001F4A1 Model Providers & Plugins"
|
||||
url: "https://github.com/langgenius/dify-official-plugins/issues/new/choose"
|
||||
url: 'https://github.com/langgenius/dify-official-plugins/issues/new/choose'
|
||||
about: Report issues with official plugins or model providers, you will need to provide the plugin version and other relevant details.
|
||||
- name: "\U0001F4AC Documentation Issues"
|
||||
url: "https://github.com/langgenius/dify-docs/issues/new"
|
||||
url: 'https://github.com/langgenius/dify-docs/issues/new'
|
||||
about: Report issues with the documentation, such as typos, outdated information, or missing content. Please provide the specific section and details of the issue.
|
||||
- name: "\U0001F4E7 Discussions"
|
||||
url: https://github.com/langgenius/dify/discussions/categories/general
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: "⭐ Feature or enhancement request"
|
||||
name: '⭐ Feature or enhancement request'
|
||||
description: Propose something new.
|
||||
labels:
|
||||
- enhancement
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
|
||||
required: true
|
||||
@@ -14,7 +14,7 @@ body:
|
||||
required: true
|
||||
- label: I confirm that I am using English to submit this report, otherwise it will be closed.
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/refactor.yml
vendored
14
.github/ISSUE_TEMPLATE/refactor.yml
vendored
@@ -1,11 +1,11 @@
|
||||
name: "✨ Refactor or Chore"
|
||||
name: '✨ Refactor or Chore'
|
||||
description: Refactor existing code or perform maintenance chores to improve readability and reliability.
|
||||
title: "[Refactor/Chore] "
|
||||
title: '[Refactor/Chore] '
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
|
||||
required: true
|
||||
@@ -17,26 +17,26 @@ body:
|
||||
required: true
|
||||
- label: 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
placeholder: "Describe the refactor or chore you are proposing."
|
||||
placeholder: 'Describe the refactor or chore you are proposing.'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation
|
||||
placeholder: "Explain why this refactor or chore is necessary."
|
||||
placeholder: 'Explain why this refactor or chore is necessary.'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
placeholder: "Add any other context or screenshots about the request here."
|
||||
placeholder: 'Add any other context or screenshots about the request here.'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
4
.github/actions/setup-web/action.yml
vendored
4
.github/actions/setup-web/action.yml
vendored
@@ -5,11 +5,11 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0
|
||||
uses: voidzero-dev/setup-vp@143f5f385f39b1b753ffed1a01ad443811855c8b # v1.16.1
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: true
|
||||
|
||||
328
.github/dependabot.yml
vendored
328
.github/dependabot.yml
vendored
@@ -1,223 +1,223 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/api"
|
||||
- package-ecosystem: 'uv'
|
||||
directory: '/api'
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: 'weekly'
|
||||
groups:
|
||||
flask:
|
||||
patterns:
|
||||
- "flask"
|
||||
- "flask-*"
|
||||
- "werkzeug"
|
||||
- "gunicorn"
|
||||
- 'flask'
|
||||
- 'flask-*'
|
||||
- 'werkzeug'
|
||||
- 'gunicorn'
|
||||
google:
|
||||
patterns:
|
||||
- "google-*"
|
||||
- "googleapis-*"
|
||||
- 'google-*'
|
||||
- 'googleapis-*'
|
||||
opentelemetry:
|
||||
patterns:
|
||||
- "opentelemetry-*"
|
||||
- 'opentelemetry-*'
|
||||
pydantic:
|
||||
patterns:
|
||||
- "pydantic"
|
||||
- "pydantic-*"
|
||||
- 'pydantic'
|
||||
- 'pydantic-*'
|
||||
llm:
|
||||
patterns:
|
||||
- "langfuse"
|
||||
- "langsmith"
|
||||
- "litellm"
|
||||
- "mlflow*"
|
||||
- "opik"
|
||||
- "weave*"
|
||||
- "arize*"
|
||||
- "tiktoken"
|
||||
- "transformers"
|
||||
- 'langfuse'
|
||||
- 'langsmith'
|
||||
- 'litellm'
|
||||
- 'mlflow*'
|
||||
- 'opik'
|
||||
- 'weave*'
|
||||
- 'arize*'
|
||||
- 'tiktoken'
|
||||
- 'transformers'
|
||||
database:
|
||||
patterns:
|
||||
- "sqlalchemy"
|
||||
- "psycopg2*"
|
||||
- "psycogreen"
|
||||
- "redis*"
|
||||
- "alembic*"
|
||||
- 'sqlalchemy'
|
||||
- 'psycopg2*'
|
||||
- 'psycogreen'
|
||||
- 'redis*'
|
||||
- 'alembic*'
|
||||
storage:
|
||||
patterns:
|
||||
- "boto3*"
|
||||
- "botocore*"
|
||||
- "azure-*"
|
||||
- "bce-*"
|
||||
- "cos-python-*"
|
||||
- "esdk-obs-*"
|
||||
- "google-cloud-storage"
|
||||
- "opendal"
|
||||
- "oss2"
|
||||
- "supabase*"
|
||||
- "tos*"
|
||||
- 'boto3*'
|
||||
- 'botocore*'
|
||||
- 'azure-*'
|
||||
- 'bce-*'
|
||||
- 'cos-python-*'
|
||||
- 'esdk-obs-*'
|
||||
- 'google-cloud-storage'
|
||||
- 'opendal'
|
||||
- 'oss2'
|
||||
- 'supabase*'
|
||||
- 'tos*'
|
||||
vdb:
|
||||
patterns:
|
||||
- "alibabacloud*"
|
||||
- "chromadb"
|
||||
- "clickhouse-*"
|
||||
- "clickzetta-*"
|
||||
- "couchbase"
|
||||
- "elasticsearch"
|
||||
- "opensearch-py"
|
||||
- "oracledb"
|
||||
- "pgvect*"
|
||||
- "pymilvus"
|
||||
- "pymochow"
|
||||
- "pyobvector"
|
||||
- "qdrant-client"
|
||||
- "intersystems-*"
|
||||
- "tablestore"
|
||||
- "tcvectordb"
|
||||
- "tidb-vector"
|
||||
- "upstash-*"
|
||||
- "volcengine-*"
|
||||
- "weaviate-*"
|
||||
- "xinference-*"
|
||||
- "mo-vector"
|
||||
- "mysql-connector-*"
|
||||
- 'alibabacloud*'
|
||||
- 'chromadb'
|
||||
- 'clickhouse-*'
|
||||
- 'clickzetta-*'
|
||||
- 'couchbase'
|
||||
- 'elasticsearch'
|
||||
- 'opensearch-py'
|
||||
- 'oracledb'
|
||||
- 'pgvect*'
|
||||
- 'pymilvus'
|
||||
- 'pymochow'
|
||||
- 'pyobvector'
|
||||
- 'qdrant-client'
|
||||
- 'intersystems-*'
|
||||
- 'tablestore'
|
||||
- 'tcvectordb'
|
||||
- 'tidb-vector'
|
||||
- 'upstash-*'
|
||||
- 'volcengine-*'
|
||||
- 'weaviate-*'
|
||||
- 'xinference-*'
|
||||
- 'mo-vector'
|
||||
- 'mysql-connector-*'
|
||||
dev:
|
||||
patterns:
|
||||
- "coverage"
|
||||
- "dotenv-linter"
|
||||
- "faker"
|
||||
- "lxml-stubs"
|
||||
- "basedpyright"
|
||||
- "ruff"
|
||||
- "pytest*"
|
||||
- "types-*"
|
||||
- "boto3-stubs"
|
||||
- "hypothesis"
|
||||
- "pandas-stubs"
|
||||
- "scipy-stubs"
|
||||
- "import-linter"
|
||||
- "celery-types"
|
||||
- "mypy*"
|
||||
- "pyrefly"
|
||||
- 'coverage'
|
||||
- 'dotenv-linter'
|
||||
- 'faker'
|
||||
- 'lxml-stubs'
|
||||
- 'basedpyright'
|
||||
- 'ruff'
|
||||
- 'pytest*'
|
||||
- 'types-*'
|
||||
- 'boto3-stubs'
|
||||
- 'hypothesis'
|
||||
- 'pandas-stubs'
|
||||
- 'scipy-stubs'
|
||||
- 'import-linter'
|
||||
- 'celery-types'
|
||||
- 'mypy*'
|
||||
- 'pyrefly'
|
||||
python-packages:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
- '*'
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
open-pull-requests-limit: 5
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: 'weekly'
|
||||
groups:
|
||||
github-actions-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/api"
|
||||
target-branch: "lts/1.13.x"
|
||||
- '*'
|
||||
- package-ecosystem: 'uv'
|
||||
directory: '/api'
|
||||
target-branch: 'lts/1.13.x'
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: 'weekly'
|
||||
groups:
|
||||
flask:
|
||||
patterns:
|
||||
- "flask"
|
||||
- "flask-*"
|
||||
- "werkzeug"
|
||||
- "gunicorn"
|
||||
- 'flask'
|
||||
- 'flask-*'
|
||||
- 'werkzeug'
|
||||
- 'gunicorn'
|
||||
google:
|
||||
patterns:
|
||||
- "google-*"
|
||||
- "googleapis-*"
|
||||
- 'google-*'
|
||||
- 'googleapis-*'
|
||||
opentelemetry:
|
||||
patterns:
|
||||
- "opentelemetry-*"
|
||||
- 'opentelemetry-*'
|
||||
pydantic:
|
||||
patterns:
|
||||
- "pydantic"
|
||||
- "pydantic-*"
|
||||
- 'pydantic'
|
||||
- 'pydantic-*'
|
||||
llm:
|
||||
patterns:
|
||||
- "langfuse"
|
||||
- "langsmith"
|
||||
- "litellm"
|
||||
- "mlflow*"
|
||||
- "opik"
|
||||
- "weave*"
|
||||
- "arize*"
|
||||
- "tiktoken"
|
||||
- "transformers"
|
||||
- 'langfuse'
|
||||
- 'langsmith'
|
||||
- 'litellm'
|
||||
- 'mlflow*'
|
||||
- 'opik'
|
||||
- 'weave*'
|
||||
- 'arize*'
|
||||
- 'tiktoken'
|
||||
- 'transformers'
|
||||
database:
|
||||
patterns:
|
||||
- "sqlalchemy"
|
||||
- "psycopg2*"
|
||||
- "psycogreen"
|
||||
- "redis*"
|
||||
- "alembic*"
|
||||
- 'sqlalchemy'
|
||||
- 'psycopg2*'
|
||||
- 'psycogreen'
|
||||
- 'redis*'
|
||||
- 'alembic*'
|
||||
storage:
|
||||
patterns:
|
||||
- "boto3*"
|
||||
- "botocore*"
|
||||
- "azure-*"
|
||||
- "bce-*"
|
||||
- "cos-python-*"
|
||||
- "esdk-obs-*"
|
||||
- "google-cloud-storage"
|
||||
- "opendal"
|
||||
- "oss2"
|
||||
- "supabase*"
|
||||
- "tos*"
|
||||
- 'boto3*'
|
||||
- 'botocore*'
|
||||
- 'azure-*'
|
||||
- 'bce-*'
|
||||
- 'cos-python-*'
|
||||
- 'esdk-obs-*'
|
||||
- 'google-cloud-storage'
|
||||
- 'opendal'
|
||||
- 'oss2'
|
||||
- 'supabase*'
|
||||
- 'tos*'
|
||||
vdb:
|
||||
patterns:
|
||||
- "alibabacloud*"
|
||||
- "chromadb"
|
||||
- "clickhouse-*"
|
||||
- "clickzetta-*"
|
||||
- "couchbase"
|
||||
- "elasticsearch"
|
||||
- "opensearch-py"
|
||||
- "oracledb"
|
||||
- "pgvect*"
|
||||
- "pymilvus"
|
||||
- "pymochow"
|
||||
- "pyobvector"
|
||||
- "qdrant-client"
|
||||
- "intersystems-*"
|
||||
- "tablestore"
|
||||
- "tcvectordb"
|
||||
- "tidb-vector"
|
||||
- "upstash-*"
|
||||
- "volcengine-*"
|
||||
- "weaviate-*"
|
||||
- "xinference-*"
|
||||
- "mo-vector"
|
||||
- "mysql-connector-*"
|
||||
- 'alibabacloud*'
|
||||
- 'chromadb'
|
||||
- 'clickhouse-*'
|
||||
- 'clickzetta-*'
|
||||
- 'couchbase'
|
||||
- 'elasticsearch'
|
||||
- 'opensearch-py'
|
||||
- 'oracledb'
|
||||
- 'pgvect*'
|
||||
- 'pymilvus'
|
||||
- 'pymochow'
|
||||
- 'pyobvector'
|
||||
- 'qdrant-client'
|
||||
- 'intersystems-*'
|
||||
- 'tablestore'
|
||||
- 'tcvectordb'
|
||||
- 'tidb-vector'
|
||||
- 'upstash-*'
|
||||
- 'volcengine-*'
|
||||
- 'weaviate-*'
|
||||
- 'xinference-*'
|
||||
- 'mo-vector'
|
||||
- 'mysql-connector-*'
|
||||
dev:
|
||||
patterns:
|
||||
- "coverage"
|
||||
- "dotenv-linter"
|
||||
- "faker"
|
||||
- "lxml-stubs"
|
||||
- "basedpyright"
|
||||
- "ruff"
|
||||
- "pytest*"
|
||||
- "types-*"
|
||||
- "boto3-stubs"
|
||||
- "hypothesis"
|
||||
- "pandas-stubs"
|
||||
- "scipy-stubs"
|
||||
- "import-linter"
|
||||
- "celery-types"
|
||||
- "mypy*"
|
||||
- "pyrefly"
|
||||
- 'coverage'
|
||||
- 'dotenv-linter'
|
||||
- 'faker'
|
||||
- 'lxml-stubs'
|
||||
- 'basedpyright'
|
||||
- 'ruff'
|
||||
- 'pytest*'
|
||||
- 'types-*'
|
||||
- 'boto3-stubs'
|
||||
- 'hypothesis'
|
||||
- 'pandas-stubs'
|
||||
- 'scipy-stubs'
|
||||
- 'import-linter'
|
||||
- 'celery-types'
|
||||
- 'mypy*'
|
||||
- 'pyrefly'
|
||||
python-packages:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
target-branch: "lts/1.13.x"
|
||||
- '*'
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
target-branch: 'lts/1.13.x'
|
||||
open-pull-requests-limit: 5
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: 'weekly'
|
||||
groups:
|
||||
github-actions-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
- '*'
|
||||
|
||||
2
.github/linters/.hadolint.yaml
vendored
2
.github/linters/.hadolint.yaml
vendored
@@ -1 +1 @@
|
||||
failure-threshold: "error"
|
||||
failure-threshold: 'error'
|
||||
|
||||
1
.github/linters/.yaml-lint.yml
vendored
1
.github/linters/.yaml-lint.yml
vendored
@@ -1,5 +1,4 @@
|
||||
---
|
||||
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
|
||||
40
.github/linters/editorconfig-checker.json
vendored
40
.github/linters/editorconfig-checker.json
vendored
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"Verbose": false,
|
||||
"Debug": false,
|
||||
"IgnoreDefaults": false,
|
||||
"SpacesAfterTabs": false,
|
||||
"NoColor": false,
|
||||
"Exclude": [
|
||||
"^web/public/vs/",
|
||||
"^web/public/pdf.worker.min.mjs$",
|
||||
"web/app/components/base/icons/src/vender/"
|
||||
],
|
||||
"AllowedContentTypes": [],
|
||||
"PassedFiles": [],
|
||||
"Disable": {
|
||||
"EndOfLine": false,
|
||||
"Indentation": false,
|
||||
"IndentSize": true,
|
||||
"InsertFinalNewline": false,
|
||||
"TrimTrailingWhitespace": false,
|
||||
"MaxLineLength": false
|
||||
}
|
||||
"Verbose": false,
|
||||
"Debug": false,
|
||||
"IgnoreDefaults": false,
|
||||
"SpacesAfterTabs": false,
|
||||
"NoColor": false,
|
||||
"Exclude": [
|
||||
"^web/public/vs/",
|
||||
"^web/public/pdf.worker.min.mjs$",
|
||||
"web/app/components/base/icons/src/vender/"
|
||||
],
|
||||
"AllowedContentTypes": [],
|
||||
"PassedFiles": [],
|
||||
"Disable": {
|
||||
"EndOfLine": false,
|
||||
"Indentation": false,
|
||||
"IndentSize": true,
|
||||
"InsertFinalNewline": false,
|
||||
"TrimTrailingWhitespace": false,
|
||||
"MaxLineLength": false
|
||||
}
|
||||
}
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -12,8 +12,8 @@
|
||||
## Screenshots
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| ... | ... |
|
||||
| ------ | ----- |
|
||||
| ... | ... |
|
||||
|
||||
## Checklist
|
||||
|
||||
|
||||
23
.github/scripts/generate-i18n-changes.mjs
vendored
23
.github/scripts/generate-i18n-changes.mjs
vendored
@@ -8,31 +8,31 @@ const headSha = process.env.HEAD_SHA || ''
|
||||
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
|
||||
const outputPath = process.env.I18N_CHANGES_OUTPUT_PATH || '/tmp/i18n-changes.json'
|
||||
|
||||
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
|
||||
const englishPath = (fileStem) => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
|
||||
|
||||
const readCurrentJson = (fileStem) => {
|
||||
const filePath = englishPath(fileStem)
|
||||
if (!fs.existsSync(filePath))
|
||||
return null
|
||||
if (!fs.existsSync(filePath)) return null
|
||||
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
}
|
||||
|
||||
const readBaseJson = (fileStem) => {
|
||||
if (!baseSha)
|
||||
return null
|
||||
if (!baseSha) return null
|
||||
|
||||
try {
|
||||
const relativePath = `web/i18n/en-US/${fileStem}.json`
|
||||
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
|
||||
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
return JSON.parse(content)
|
||||
}
|
||||
catch {
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
|
||||
const compareJson = (beforeValue, afterValue) =>
|
||||
JSON.stringify(beforeValue) === JSON.stringify(afterValue)
|
||||
|
||||
const changes = {}
|
||||
|
||||
@@ -59,8 +59,7 @@ for (const fileStem of files) {
|
||||
}
|
||||
|
||||
for (const key of Object.keys(beforeJson)) {
|
||||
if (!(key in afterJson))
|
||||
deleted.push(key)
|
||||
if (!(key in afterJson)) deleted.push(key)
|
||||
}
|
||||
|
||||
changes[fileStem] = {
|
||||
@@ -78,5 +77,5 @@ fs.writeFileSync(
|
||||
headSha,
|
||||
files,
|
||||
changes,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
101
.github/workflows/accessibility-e2e.yml
vendored
Normal file
101
.github/workflows/accessibility-e2e.yml
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
name: Manual WCAG Accessibility Audit
|
||||
run-name: WCAG ${{ inputs.level }} · ${{ inputs.page }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
page:
|
||||
description: Page to scan
|
||||
required: true
|
||||
default: home
|
||||
type: choice
|
||||
options:
|
||||
- home
|
||||
- sign-in
|
||||
- studio
|
||||
- agents
|
||||
- knowledge
|
||||
- integrations
|
||||
- all
|
||||
level:
|
||||
description: WCAG level to scan
|
||||
required: true
|
||||
default: aa
|
||||
type: choice
|
||||
options:
|
||||
- aa
|
||||
- a
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: accessibility-e2e-${{ github.ref }}-${{ inputs.page }}-${{ inputs.level }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
accessibility:
|
||||
name: WCAG Level ${{ inputs.level == 'a' && 'A' || 'AA' }} · ${{ inputs.page }}
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 120
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: |
|
||||
api/uv.lock
|
||||
|
||||
- name: Install API dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Install Chromium for accessibility E2E
|
||||
timeout-minutes: 15
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install:ci:chromium
|
||||
|
||||
- name: Run automated WCAG Level ${{ inputs.level == 'a' && 'A' || 'AA' }} checks
|
||||
working-directory: ./e2e
|
||||
env:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_FORCE_WEB_BUILD: '1'
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
WCAG_PAGE: ${{ inputs.page }}
|
||||
run: |
|
||||
if [[ "$WCAG_PAGE" == "all" ]]; then
|
||||
vp run e2e:accessibility:${{ inputs.level }}
|
||||
else
|
||||
pnpm exec tsx ./scripts/run-cucumber.ts --full -- --tags "@axe and @wcag-${{ inputs.level }} and @wcag-page-$WCAG_PAGE"
|
||||
fi
|
||||
|
||||
- name: Upload accessibility Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cucumber-report-accessibility-${{ inputs.level }}-${{ inputs.page }}
|
||||
path: e2e/cucumber-report
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload accessibility E2E logs
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-logs-accessibility-${{ inputs.level }}-${{ inputs.page }}
|
||||
path: e2e/.logs/*.log
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
6
.github/workflows/api-tests.yml
vendored
6
.github/workflows/api-tests.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
11
.github/workflows/autofix.yml
vendored
11
.github/workflows/autofix.yml
vendored
@@ -1,12 +1,12 @@
|
||||
name: autofix.ci
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
merge_group:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -53,8 +53,7 @@ jobs:
|
||||
oxlint-suppressions.json
|
||||
eslint-suppressions.json
|
||||
.vscode/**
|
||||
.github/workflows/autofix.yml
|
||||
.github/workflows/style.yml
|
||||
.github/**
|
||||
- name: Check api inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
id: api-changes
|
||||
@@ -86,7 +85,7 @@ jobs:
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: '3.11'
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
144
.github/workflows/build-push.yml
vendored
144
.github/workflows/build-push.yml
vendored
@@ -3,13 +3,13 @@ name: Build and Push API & Web
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "deploy/**"
|
||||
- "build/**"
|
||||
- "release/e-*"
|
||||
- "hotfix/**"
|
||||
- 'main'
|
||||
- 'deploy/**'
|
||||
- 'build/**'
|
||||
- 'release/e-*'
|
||||
- 'hotfix/**'
|
||||
tags:
|
||||
- "*"
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: build-push-${{ github.head_ref || github.run_id }}
|
||||
@@ -33,60 +33,60 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service_name: "build-api-amd64"
|
||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||
artifact_context: "api"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: 'build-api-amd64'
|
||||
image_name_env: 'DIFY_API_IMAGE_NAME'
|
||||
artifact_context: 'api'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-api-arm64"
|
||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||
artifact_context: "api"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: 'build-api-arm64'
|
||||
image_name_env: 'DIFY_API_IMAGE_NAME'
|
||||
artifact_context: 'api'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-web-amd64"
|
||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||
artifact_context: "web"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: 'build-web-amd64'
|
||||
image_name_env: 'DIFY_WEB_IMAGE_NAME'
|
||||
artifact_context: 'web'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-web-arm64"
|
||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||
artifact_context: "web"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: 'build-web-arm64'
|
||||
image_name_env: 'DIFY_WEB_IMAGE_NAME'
|
||||
artifact_context: 'web'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-agent-amd64"
|
||||
image_name_env: "DIFY_AGENT_IMAGE_NAME"
|
||||
artifact_context: "agent"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "dify-agent/Dockerfile"
|
||||
- service_name: 'build-agent-amd64'
|
||||
image_name_env: 'DIFY_AGENT_IMAGE_NAME'
|
||||
artifact_context: 'agent'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'dify-agent/Dockerfile'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-agent-arm64"
|
||||
image_name_env: "DIFY_AGENT_IMAGE_NAME"
|
||||
artifact_context: "agent"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "dify-agent/Dockerfile"
|
||||
- service_name: 'build-agent-arm64'
|
||||
image_name_env: 'DIFY_AGENT_IMAGE_NAME'
|
||||
artifact_context: 'agent'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'dify-agent/Dockerfile'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-agent-local-sandbox-amd64"
|
||||
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
|
||||
artifact_context: "local-sandbox"
|
||||
build_context: "{{defaultContext}}:dify-agent-runtime"
|
||||
file: "docker/Dockerfile"
|
||||
- service_name: 'build-agent-local-sandbox-amd64'
|
||||
image_name_env: 'DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME'
|
||||
artifact_context: 'local-sandbox'
|
||||
build_context: '{{defaultContext}}:dify-agent-runtime'
|
||||
file: 'docker/Dockerfile'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-agent-local-sandbox-arm64"
|
||||
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
|
||||
artifact_context: "local-sandbox"
|
||||
build_context: "{{defaultContext}}:dify-agent-runtime"
|
||||
file: "docker/Dockerfile"
|
||||
- service_name: 'build-agent-local-sandbox-arm64'
|
||||
image_name_env: 'DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME'
|
||||
artifact_context: 'local-sandbox'
|
||||
build_context: '{{defaultContext}}:dify-agent-runtime'
|
||||
file: 'docker/Dockerfile'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
@@ -146,18 +146,18 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service_name: "validate-api-amd64"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: "validate-web-amd64"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: "validate-agent-amd64"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "dify-agent/Dockerfile"
|
||||
- service_name: "validate-agent-local-sandbox-amd64"
|
||||
build_context: "{{defaultContext}}:dify-agent-runtime"
|
||||
file: "docker/Dockerfile"
|
||||
- service_name: 'validate-api-amd64'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
- service_name: 'validate-web-amd64'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
- service_name: 'validate-agent-amd64'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'dify-agent/Dockerfile'
|
||||
- service_name: 'validate-agent-local-sandbox-amd64'
|
||||
build_context: '{{defaultContext}}:dify-agent-runtime'
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
@@ -178,18 +178,18 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service_name: "merge-api-images"
|
||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||
context: "api"
|
||||
- service_name: "merge-web-images"
|
||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||
context: "web"
|
||||
- service_name: "merge-agent-images"
|
||||
image_name_env: "DIFY_AGENT_IMAGE_NAME"
|
||||
context: "agent"
|
||||
- service_name: "merge-agent-local-sandbox-images"
|
||||
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
|
||||
context: "local-sandbox"
|
||||
- service_name: 'merge-api-images'
|
||||
image_name_env: 'DIFY_API_IMAGE_NAME'
|
||||
context: 'api'
|
||||
- service_name: 'merge-web-images'
|
||||
image_name_env: 'DIFY_WEB_IMAGE_NAME'
|
||||
context: 'web'
|
||||
- service_name: 'merge-agent-images'
|
||||
image_name_env: 'DIFY_AGENT_IMAGE_NAME'
|
||||
context: 'agent'
|
||||
- service_name: 'merge-agent-local-sandbox-images'
|
||||
image_name_env: 'DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME'
|
||||
context: 'local-sandbox'
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
229
.github/workflows/cli-e2e.yml
vendored
229
.github/workflows/cli-e2e.yml
vendored
@@ -4,19 +4,19 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cli_ref:
|
||||
description: "Git ref (default: current branch)"
|
||||
description: 'Git ref (default: current branch)'
|
||||
type: string
|
||||
required: false
|
||||
|
||||
edition:
|
||||
description: "Dify edition"
|
||||
description: 'Dify edition'
|
||||
type: choice
|
||||
required: false
|
||||
default: ee
|
||||
options: [ee, ce]
|
||||
|
||||
test_scope:
|
||||
description: "smoke = [P0] only / full = all cases"
|
||||
description: 'smoke = [P0] only / full = all cases'
|
||||
type: choice
|
||||
required: false
|
||||
default: full
|
||||
@@ -24,23 +24,23 @@ on:
|
||||
|
||||
# ── Suite on/off ────────────────────────────────────────────────────────
|
||||
suite_framework_output_error:
|
||||
description: "framework + output + error-handling suites"
|
||||
description: 'framework + output + error-handling suites'
|
||||
type: boolean
|
||||
default: true
|
||||
suite_discovery:
|
||||
description: "discovery suite (get app / describe app)"
|
||||
description: 'discovery suite (get app / describe app)'
|
||||
type: boolean
|
||||
default: true
|
||||
suite_run:
|
||||
description: "run suite (basic / streaming / conversation / file / hitl)"
|
||||
description: 'run suite (basic / streaming / conversation / file / hitl)'
|
||||
type: boolean
|
||||
default: true
|
||||
suite_auth:
|
||||
description: "auth suite (login / status / whoami / use / devices / logout)"
|
||||
description: 'auth suite (login / status / whoami / use / devices / logout)'
|
||||
type: boolean
|
||||
default: true
|
||||
suite_agent:
|
||||
description: "agent suite"
|
||||
description: 'agent suite'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
@@ -51,32 +51,31 @@ permissions:
|
||||
# Each job reads DIFY_E2E_TOKEN + app IDs from the provision job outputs,
|
||||
# so global-setup skips minting and finds existing apps in < 10 s.
|
||||
env:
|
||||
DIFY_E2E_NO_KEYRING: "1" # Linux CI has no keychain; skip probe
|
||||
VITEST_RETRY: "2" # Retry flaky staging responses
|
||||
DIFY_E2E_NO_KEYRING: '1' # Linux CI has no keychain; skip probe
|
||||
VITEST_RETRY: '2' # Retry flaky staging responses
|
||||
|
||||
jobs:
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 0. PROVISION — mint token + import DSL fixtures (runs once, outputs IDs)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 0. PROVISION — mint token + import DSL fixtures (runs once, outputs IDs)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
provision:
|
||||
name: "Provision: mint token + DSL apps"
|
||||
name: 'Provision: mint token + DSL apps'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
token: ${{ steps.out.outputs.DIFY_E2E_TOKEN }}
|
||||
workspace_id: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_ID }}
|
||||
workspace_name: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_NAME }}
|
||||
ws2_id: ${{ steps.out.outputs.DIFY_E2E_WS2_ID }}
|
||||
chat_app_id: ${{ steps.out.outputs.DIFY_E2E_CHAT_APP_ID }}
|
||||
workflow_app_id: ${{ steps.out.outputs.DIFY_E2E_WORKFLOW_APP_ID }}
|
||||
file_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_APP_ID }}
|
||||
file_chat_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_CHAT_APP_ID }}
|
||||
hitl_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_APP_ID }}
|
||||
hitl_external_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_EXTERNAL_APP_ID }}
|
||||
hitl_single_action_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID }}
|
||||
hitl_multi_node_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_MULTI_NODE_APP_ID }}
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
token: ${{ steps.out.outputs.DIFY_E2E_TOKEN }}
|
||||
workspace_id: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_ID }}
|
||||
workspace_name: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_NAME }}
|
||||
ws2_id: ${{ steps.out.outputs.DIFY_E2E_WS2_ID }}
|
||||
chat_app_id: ${{ steps.out.outputs.DIFY_E2E_CHAT_APP_ID }}
|
||||
workflow_app_id: ${{ steps.out.outputs.DIFY_E2E_WORKFLOW_APP_ID }}
|
||||
file_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_APP_ID }}
|
||||
file_chat_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_CHAT_APP_ID }}
|
||||
hitl_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_APP_ID }}
|
||||
hitl_external_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_EXTERNAL_APP_ID }}
|
||||
hitl_single_action_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID }}
|
||||
hitl_multi_node_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_MULTI_NODE_APP_ID }}
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
@@ -88,7 +87,7 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
@@ -101,18 +100,18 @@ jobs:
|
||||
id: out
|
||||
working-directory: cli
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
run: bun scripts/e2e-provision.ts
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-B. framework + output + error-handling (parallel with run/discovery)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-B. framework + output + error-handling (parallel with run/discovery)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-framework-output-error:
|
||||
name: "Suite: framework + output + error-handling"
|
||||
name: 'Suite: framework + output + error-handling'
|
||||
if: ${{ inputs.suite_framework_output_error != 'false' }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
@@ -131,23 +130,23 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run framework + output + error-handling
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/framework/**/*.e2e.ts,test/e2e/suites/output/**/*.e2e.ts,test/e2e/suites/error-handling/**/*.e2e.ts"
|
||||
DIFY_E2E_INCLUDE: 'test/e2e/suites/framework/**/*.e2e.ts,test/e2e/suites/output/**/*.e2e.ts,test/e2e/suites/error-handling/**/*.e2e.ts'
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
@@ -155,11 +154,11 @@ jobs:
|
||||
pnpm test:e2e
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-C. Discovery (parallel)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-C. Discovery (parallel)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-discovery:
|
||||
name: "Suite: discovery"
|
||||
name: 'Suite: discovery'
|
||||
if: ${{ inputs.suite_discovery != 'false' }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
@@ -178,24 +177,24 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run discovery suite
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/discovery/**/*.e2e.ts"
|
||||
DIFY_E2E_INCLUDE: 'test/e2e/suites/discovery/**/*.e2e.ts'
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
@@ -203,11 +202,11 @@ jobs:
|
||||
pnpm test:e2e
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-D. Run suite — 5 files in matrix (parallel)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-D. Run suite — 5 files in matrix (parallel)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-run:
|
||||
name: "Suite: run / ${{ matrix.name }}"
|
||||
name: 'Suite: run / ${{ matrix.name }}'
|
||||
if: ${{ inputs.suite_run != 'false' }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
@@ -241,30 +240,30 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: "Run run/${{ matrix.name }}"
|
||||
- name: 'Run run/${{ matrix.name }}'
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_SSO_TOKEN: ${{ secrets.DIFY_E2E_SSO_TOKEN }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_FILE_APP_ID: ${{ needs.provision.outputs.file_app_id }}
|
||||
DIFY_E2E_FILE_CHAT_APP_ID: ${{ needs.provision.outputs.file_chat_app_id }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
|
||||
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_SSO_TOKEN: ${{ secrets.DIFY_E2E_SSO_TOKEN }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_FILE_APP_ID: ${{ needs.provision.outputs.file_app_id }}
|
||||
DIFY_E2E_FILE_CHAT_APP_ID: ${{ needs.provision.outputs.file_chat_app_id }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
|
||||
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
|
||||
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID: ${{ needs.provision.outputs.hitl_single_action_app_id }}
|
||||
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/run/${{ matrix.file }}"
|
||||
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
|
||||
DIFY_E2E_INCLUDE: 'test/e2e/suites/run/${{ matrix.file }}'
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
@@ -280,11 +279,11 @@ jobs:
|
||||
path: cli/test-results/
|
||||
retention-days: 3
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-E. auth/login + status + whoami (parallel, read-only, safe)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-E. auth/login + status + whoami (parallel, read-only, safe)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-auth-safe:
|
||||
name: "Suite: auth (login / status / whoami)"
|
||||
name: 'Suite: auth (login / status / whoami)'
|
||||
if: ${{ inputs.suite_auth != 'false' }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
@@ -303,22 +302,22 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run auth/login + status + whoami
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/auth/login.e2e.ts,test/e2e/suites/auth/status.e2e.ts,test/e2e/suites/auth/whoami.e2e.ts"
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_INCLUDE: 'test/e2e/suites/auth/login.e2e.ts,test/e2e/suites/auth/status.e2e.ts,test/e2e/suites/auth/whoami.e2e.ts'
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
@@ -326,13 +325,13 @@ jobs:
|
||||
pnpm test:e2e
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 2. DESTRUCTIVE — auth/use + devices + logout + agent (serial, runs LAST)
|
||||
# Must wait for ALL parallel suites to finish to avoid token revocation
|
||||
# invalidating other in-flight requests.
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 2. DESTRUCTIVE — auth/use + devices + logout + agent (serial, runs LAST)
|
||||
# Must wait for ALL parallel suites to finish to avoid token revocation
|
||||
# invalidating other in-flight requests.
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-last:
|
||||
name: "Suite: auth-use + devices + logout + agent (last, serial)"
|
||||
name: 'Suite: auth-use + devices + logout + agent (last, serial)'
|
||||
# Runs when auth is selected; also runs after all parallel jobs finish
|
||||
if: ${{ inputs.suite_auth != 'false' || inputs.suite_agent != 'false' }}
|
||||
needs:
|
||||
@@ -359,27 +358,27 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run use / devices / logout / agent (serial)
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
|
||||
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
|
||||
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
|
||||
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID: ${{ needs.provision.outputs.hitl_single_action_app_id }}
|
||||
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
|
||||
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
|
||||
run: |
|
||||
# Collect files in safe order: use → devices → logout (revokes last) → agent
|
||||
FILES=()
|
||||
|
||||
2
.github/workflows/cli-edge.yml
vendored
2
.github/workflows/cli-edge.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: cli/.bun-version
|
||||
|
||||
|
||||
2
.github/workflows/cli-release.yml
vendored
2
.github/workflows/cli-release.yml
vendored
@@ -114,7 +114,7 @@ jobs:
|
||||
run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: cli/.bun-version
|
||||
|
||||
|
||||
4
.github/workflows/cli-smoke.yml
vendored
4
.github/workflows/cli-smoke.yml
vendored
@@ -4,11 +4,11 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dify_version:
|
||||
description: "Dify image tag to test against (e.g. 1.7.0)"
|
||||
description: 'Dify image tag to test against (e.g. 1.7.0)'
|
||||
type: string
|
||||
required: true
|
||||
cli_ref:
|
||||
description: "Git ref to build the cli from (default: current branch)"
|
||||
description: 'Git ref to build the cli from (default: current branch)'
|
||||
type: string
|
||||
required: false
|
||||
|
||||
|
||||
4
.github/workflows/db-migration-test.yml
vendored
4
.github/workflows/db-migration-test.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
4
.github/workflows/deploy-agent.yml
vendored
4
.github/workflows/deploy-agent.yml
vendored
@@ -5,9 +5,9 @@ permissions:
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- "deploy/agent"
|
||||
- 'deploy/agent'
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
4
.github/workflows/deploy-dev.yml
vendored
4
.github/workflows/deploy-dev.yml
vendored
@@ -2,9 +2,9 @@ name: Deploy Dev
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- "deploy/dev"
|
||||
- 'deploy/dev'
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
4
.github/workflows/deploy-enterprise.yml
vendored
4
.github/workflows/deploy-enterprise.yml
vendored
@@ -5,9 +5,9 @@ permissions:
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- "deploy/enterprise"
|
||||
- 'deploy/enterprise'
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
4
.github/workflows/deploy-knowledge.yml
vendored
4
.github/workflows/deploy-knowledge.yml
vendored
@@ -6,9 +6,9 @@ permissions:
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- "deploy/konwledge"
|
||||
- 'deploy/konwledge'
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
4
.github/workflows/deploy-saas.yml
vendored
4
.github/workflows/deploy-saas.yml
vendored
@@ -5,9 +5,9 @@ permissions:
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- "deploy/saas"
|
||||
- 'deploy/saas'
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
56
.github/workflows/docker-build.yml
vendored
56
.github/workflows/docker-build.yml
vendored
@@ -3,7 +3,7 @@ name: Build docker image
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
- 'main'
|
||||
paths:
|
||||
- api/Dockerfile
|
||||
- api/Dockerfile.dockerignore
|
||||
@@ -33,36 +33,36 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service_name: "api-amd64"
|
||||
- service_name: 'api-amd64'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: "api-arm64"
|
||||
context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
- service_name: 'api-arm64'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: "web-amd64"
|
||||
context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
- service_name: 'web-amd64'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: "web-arm64"
|
||||
context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
- service_name: 'web-arm64'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: "local-sandbox-amd64"
|
||||
context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
- service_name: 'local-sandbox-amd64'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
context: "{{defaultContext}}:dify-agent-runtime"
|
||||
file: "docker/Dockerfile"
|
||||
- service_name: "local-sandbox-arm64"
|
||||
context: '{{defaultContext}}:dify-agent-runtime'
|
||||
file: 'docker/Dockerfile'
|
||||
- service_name: 'local-sandbox-arm64'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
context: "{{defaultContext}}:dify-agent-runtime"
|
||||
file: "docker/Dockerfile"
|
||||
context: '{{defaultContext}}:dify-agent-runtime'
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
@@ -85,15 +85,15 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service_name: "api-amd64"
|
||||
context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: "web-amd64"
|
||||
context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: "local-sandbox-amd64"
|
||||
context: "{{defaultContext}}:dify-agent-runtime"
|
||||
file: "docker/Dockerfile"
|
||||
- service_name: 'api-amd64'
|
||||
context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
- service_name: 'web-amd64'
|
||||
context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
- service_name: 'local-sandbox-amd64'
|
||||
context: '{{defaultContext}}:dify-agent-runtime'
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: "Pull Request Labeler"
|
||||
name: 'Pull Request Labeler'
|
||||
on:
|
||||
pull_request_target:
|
||||
|
||||
|
||||
9
.github/workflows/main-ci.yml
vendored
9
.github/workflows/main-ci.yml
vendored
@@ -2,9 +2,9 @@ name: Main CI Pipeline
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
merge_group:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
types: [checks_requested]
|
||||
|
||||
permissions:
|
||||
@@ -15,7 +15,7 @@ permissions:
|
||||
statuses: write
|
||||
|
||||
concurrency:
|
||||
group: main-ci-${{ github.head_ref || github.run_id }}
|
||||
group: main-ci-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
sandbox-runtime-changed: ${{ steps.changes.outputs.sandbox-runtime }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
@@ -89,6 +89,7 @@ jobs:
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.nvmrc'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
- '.github/workflows/web-tests.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
e2e:
|
||||
|
||||
4
.github/workflows/post-merge.yml
vendored
4
.github/workflows/post-merge.yml
vendored
@@ -2,7 +2,7 @@ name: Post-Merge Checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
|
||||
2
.github/workflows/sandbox-runtime-tests.yml
vendored
2
.github/workflows/sandbox-runtime-tests.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
cache-dependency-path: dify-agent-runtime/go.sum
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v6.5.0
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
working-directory: dify-agent-runtime
|
||||
version: latest
|
||||
|
||||
2
.github/workflows/semantic-pull-request.yml
vendored
2
.github/workflows/semantic-pull-request.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
- reopened
|
||||
- synchronize
|
||||
merge_group:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
types: [checks_requested]
|
||||
|
||||
jobs:
|
||||
|
||||
7
.github/workflows/stale.yml
vendored
7
.github/workflows/stale.yml
vendored
@@ -11,20 +11,19 @@ on:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
days-before-issue-stale: 15
|
||||
days-before-issue-close: 3
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "Closed due to inactivity. If you have any questions, you can reopen it."
|
||||
stale-pr-message: "Closed due to inactivity. If you have any questions, you can reopen it."
|
||||
stale-issue-message: 'Closed due to inactivity. If you have any questions, you can reopen it.'
|
||||
stale-pr-message: 'Closed due to inactivity. If you have any questions, you can reopen it.'
|
||||
stale-issue-label: 'no-issue-activity'
|
||||
stale-pr-label: 'no-pr-activity'
|
||||
any-of-labels: '🌚 invalid,🙋♂️ question,wont-fix,no-issue-activity,no-pr-activity,💪 enhancement,🤔 cant-reproduce,🙏 help wanted'
|
||||
|
||||
15
.github/workflows/style.yml
vendored
15
.github/workflows/style.yml
vendored
@@ -7,10 +7,6 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: style-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
checks: write
|
||||
statuses: write
|
||||
@@ -48,7 +44,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.12"
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -109,6 +105,8 @@ jobs:
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
knip.config.ts
|
||||
scripts/check-web-production-unused-after-knip-fix.mjs
|
||||
.nvmrc
|
||||
.github/workflows/style.yml
|
||||
.github/actions/setup-web/**
|
||||
@@ -125,14 +123,17 @@ jobs:
|
||||
|
||||
- name: Web dead code check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: .
|
||||
run: vp run knip
|
||||
|
||||
- name: Web dead code check production
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: .
|
||||
run: vp run knip:production
|
||||
|
||||
- name: Web production unused declarations check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: .
|
||||
run: vp run knip:production-unused-check
|
||||
|
||||
ts-common-style:
|
||||
@@ -168,9 +169,7 @@ jobs:
|
||||
oxlint-suppressions.json
|
||||
eslint-suppressions.json
|
||||
.vscode/**
|
||||
.github/workflows/autofix.yml
|
||||
.github/workflows/style.yml
|
||||
.github/actions/setup-web/**
|
||||
.github/**
|
||||
|
||||
- name: Setup web environment
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183
|
||||
uses: anthropics/claude-code-action@c038e4dcdedfbbca18dfb17df35a17e40ded4ddc # v1.0.186
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
20
.github/workflows/vdb-tests-full.yml
vendored
20
.github/workflows/vdb-tests-full.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -48,16 +48,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
# - name: Set up Vector Store (TiDB)
|
||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||
# with:
|
||||
# compose-file: docker/tidb/docker-compose.yaml
|
||||
# services: |
|
||||
# tidb
|
||||
# tiflash
|
||||
# - name: Set up Vector Store (TiDB)
|
||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||
# with:
|
||||
# compose-file: docker/tidb/docker-compose.yaml
|
||||
# services: |
|
||||
# tidb
|
||||
# tiflash
|
||||
|
||||
# - name: Check VDB Ready (TiDB)
|
||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||
# - name: Check VDB Ready (TiDB)
|
||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||
|
||||
- name: Test Vector Stores
|
||||
run: |
|
||||
|
||||
20
.github/workflows/vdb-tests.yml
vendored
20
.github/workflows/vdb-tests.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -45,16 +45,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
# - name: Set up Vector Store (TiDB)
|
||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||
# with:
|
||||
# compose-file: docker/tidb/docker-compose.yaml
|
||||
# services: |
|
||||
# tidb
|
||||
# tiflash
|
||||
# - name: Set up Vector Store (TiDB)
|
||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||
# with:
|
||||
# compose-file: docker/tidb/docker-compose.yaml
|
||||
# services: |
|
||||
# tidb
|
||||
# tiflash
|
||||
|
||||
# - name: Check VDB Ready (TiDB)
|
||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||
# - name: Check VDB Ready (TiDB)
|
||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||
|
||||
- name: Test Vector Stores
|
||||
run: |
|
||||
|
||||
42
.github/workflows/web-e2e.yml
vendored
42
.github/workflows/web-e2e.yml
vendored
@@ -11,10 +11,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: web-e2e-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Web Full-Stack E2E
|
||||
@@ -30,6 +26,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Switch Ubuntu mirror
|
||||
run: |
|
||||
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo apt-get update
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: |
|
||||
api/uv.lock
|
||||
dify-agent/uv.lock
|
||||
@@ -50,9 +51,17 @@ jobs:
|
||||
working-directory: ./e2e
|
||||
run: vp run test:unit
|
||||
|
||||
- name: Install Playwright browser
|
||||
- name: Install Playwright browsers for core E2E
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
timeout-minutes: 15
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install
|
||||
run: vp run e2e:install:ci
|
||||
|
||||
- name: Install Chromium for external runtime E2E
|
||||
if: ${{ inputs.run-external-runtime }}
|
||||
timeout-minutes: 15
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install:ci:chromium
|
||||
|
||||
- name: Run isolated source-api and built-web Cucumber E2E tests
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
@@ -61,7 +70,7 @@ jobs:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_FORCE_WEB_BUILD: "1"
|
||||
E2E_FORCE_WEB_BUILD: '1'
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
run: vp run e2e:full
|
||||
|
||||
@@ -121,7 +130,7 @@ jobs:
|
||||
E2E_AGENT_DECISION_MODEL_NAME: ${{ vars.E2E_AGENT_DECISION_MODEL_NAME || 'gpt-5.5' }}
|
||||
E2E_AGENT_DECISION_MODEL_PROVIDER: ${{ vars.E2E_AGENT_DECISION_MODEL_PROVIDER || 'openai' }}
|
||||
E2E_AGENT_DECISION_MODEL_TYPE: ${{ vars.E2E_AGENT_DECISION_MODEL_TYPE || 'llm' }}
|
||||
E2E_FORCE_WEB_BUILD: "1"
|
||||
E2E_FORCE_WEB_BUILD: '1'
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
E2E_MARKETPLACE_API_URL: ${{ vars.E2E_MARKETPLACE_API_URL }}
|
||||
E2E_MARKETPLACE_PLUGIN_IDS: ${{ vars.E2E_MARKETPLACE_PLUGIN_IDS }}
|
||||
@@ -138,21 +147,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
teardown_external_runtime() {
|
||||
local run_status=$?
|
||||
trap - EXIT
|
||||
if ! vp run e2e:middleware:down; then
|
||||
echo "::error title=E2E teardown failed::External runtime middleware did not shut down cleanly."
|
||||
if [[ "$run_status" -eq 0 ]]; then
|
||||
run_status=1
|
||||
fi
|
||||
fi
|
||||
exit "$run_status"
|
||||
}
|
||||
|
||||
trap teardown_external_runtime EXIT
|
||||
vp run e2e:middleware:up
|
||||
vp run e2e:post-merge:prepare
|
||||
vp run e2e:post-merge
|
||||
|
||||
- name: Upload Cucumber report
|
||||
|
||||
22
.github/workflows/web-tests.yml
vendored
22
.github/workflows/web-tests.yml
vendored
@@ -9,10 +9,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: web-tests-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
@@ -91,6 +87,7 @@ jobs:
|
||||
dify-ui-test:
|
||||
name: dify-ui Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
defaults:
|
||||
@@ -104,11 +101,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Switch Ubuntu mirror
|
||||
run: |
|
||||
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo apt-get update
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Install Chromium for Browser Mode
|
||||
run: vp exec playwright install --with-deps chromium
|
||||
timeout-minutes: 15
|
||||
run: vp exec playwright install --with-deps --only-shell chromium
|
||||
|
||||
- name: Run dify-ui tests
|
||||
run: vp test run --project unit --coverage --silent=passed-only
|
||||
@@ -125,6 +128,7 @@ jobs:
|
||||
dify-ui-storybook-test:
|
||||
name: dify-ui Storybook Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -136,11 +140,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Switch Ubuntu mirror
|
||||
run: |
|
||||
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo apt-get update
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Install Chromium for Browser Mode
|
||||
run: vp exec playwright install --with-deps chromium
|
||||
timeout-minutes: 15
|
||||
run: vp exec playwright install --with-deps --only-shell chromium
|
||||
|
||||
- name: Run dify-ui Storybook tests
|
||||
run: vp run test:storybook
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -140,6 +140,9 @@ dmypy.json
|
||||
pyrightconfig.json
|
||||
!api/pyrightconfig.json
|
||||
|
||||
# import-linter
|
||||
.import_linter_cache/
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
.idea/'
|
||||
|
||||
@@ -6,3 +6,4 @@ Dify is an open-source platform for building LLM applications, agentic workflows
|
||||
|
||||
- Run backend commands through `uv run --project api <command>`.
|
||||
- Backend integration tests are CI-only and are not expected to run locally.
|
||||
- Keep `docker/.env.example` limited to variables required for a default Docker Compose deployment to start. Put optional and provider-specific settings in the matching `docker/envs/*.env.example` file; `docker/.env` overrides those service-specific env files.
|
||||
|
||||
@@ -209,12 +209,11 @@ At the same time, please consider supporting Dify by sharing it on social media
|
||||
|
||||
## Star History
|
||||
|
||||
<!-- GitHub token name: star-history -->
|
||||
<a href="https://www.star-history.com/?repos=langgenius%2Fdify&type=date&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#langgenius/dify&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=langgenius/dify&type=date&theme=dark&legend=top-left&sealed_token=p-SWD-UXZEDc5a2d0EMfgMmyCwVyRlSof0Qox68v7k4PvQPKRlCx0jDIlNztw7mbA6DEn96R50DojO9pCi5LQUlDBAoIhRswt8-GuC8K3rZQ3naJUXbuHqR_oItIW2F0NNM-7npevzw5SXp7L5mwixgqJncAvAGzuGq5zmhQfiDRSW_Jd6y8TQCZMJbt" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=langgenius/dify&type=date&legend=top-left&sealed_token=p-SWD-UXZEDc5a2d0EMfgMmyCwVyRlSof0Qox68v7k4PvQPKRlCx0jDIlNztw7mbA6DEn96R50DojO9pCi5LQUlDBAoIhRswt8-GuC8K3rZQ3naJUXbuHqR_oItIW2F0NNM-7npevzw5SXp7L5mwixgqJncAvAGzuGq5zmhQfiDRSW_Jd6y8TQCZMJbt" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=langgenius/dify&type=date&legend=top-left&sealed_token=p-SWD-UXZEDc5a2d0EMfgMmyCwVyRlSof0Qox68v7k4PvQPKRlCx0jDIlNztw7mbA6DEn96R50DojO9pCi5LQUlDBAoIhRswt8-GuC8K3rZQ3naJUXbuHqR_oItIW2F0NNM-7npevzw5SXp7L5mwixgqJncAvAGzuGq5zmhQfiDRSW_Jd6y8TQCZMJbt" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=langgenius/dify&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=langgenius/dify&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=langgenius/dify&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ ENABLE_COLLABORATION_MODE=true
|
||||
# Learn app feature toggle
|
||||
ENABLE_LEARN_APP=true
|
||||
|
||||
# Show the license expiry countdown badge in the console (enterprise only)
|
||||
ENABLE_LICENSE_EXPIRY_NOTICE=true
|
||||
|
||||
# Access token expiration time in minutes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
@@ -117,6 +120,19 @@ SQLALCHEMY_POOL_RESET_ON_RETURN=rollback
|
||||
# storage type: opendal, s3, aliyun-oss, azure-blob, baidu-obs, google-storage, huawei-obs, oci-storage, tencent-cos, volcengine-tos, supabase
|
||||
STORAGE_TYPE=opendal
|
||||
|
||||
# Key provider configuration, used to encrypt/decrypt tenant credentials (LLM/tool provider secrets)
|
||||
# key provider type: local, azure-keyvault
|
||||
KEY_PROVIDER_TYPE=local
|
||||
|
||||
# Azure Key Vault configuration, required when KEY_PROVIDER_TYPE=azure-keyvault
|
||||
# authentication uses DefaultAzureCredential (managed identity, environment variables, or Azure CLI login)
|
||||
AZURE_KEYVAULT_VAULT_URL=https://<your-vault-name>.vault.azure.net
|
||||
AZURE_KEYVAULT_KEY_SIZE=2048
|
||||
# optional: auto-rotate each tenant's key every N days. Leave empty to manage rotation manually.
|
||||
# rotation is safe because old key versions are never given an expiry and stay usable forever --
|
||||
# do NOT set a rotation policy on this key in the Azure portal that expires old versions.
|
||||
AZURE_KEYVAULT_ROTATION_INTERVAL_DAYS=
|
||||
|
||||
# Apache OpenDAL storage configuration, refer to https://github.com/apache/opendal
|
||||
OPENDAL_SCHEME=fs
|
||||
OPENDAL_FS_ROOT=storage
|
||||
@@ -326,6 +342,7 @@ TIDB_VECTOR_PORT=4000
|
||||
TIDB_VECTOR_USER=xxx.root
|
||||
TIDB_VECTOR_PASSWORD=xxxxxx
|
||||
TIDB_VECTOR_DATABASE=dify
|
||||
TIDB_VECTOR_ENABLE_FULLTEXT_SEARCH=false
|
||||
|
||||
# Tidb on qdrant configuration
|
||||
TIDB_ON_QDRANT_URL=http://127.0.0.1
|
||||
@@ -333,6 +350,7 @@ TIDB_ON_QDRANT_API_KEY=dify
|
||||
TIDB_ON_QDRANT_CLIENT_TIMEOUT=20
|
||||
TIDB_ON_QDRANT_GRPC_ENABLED=false
|
||||
TIDB_ON_QDRANT_GRPC_PORT=6334
|
||||
TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB=sandbox:60,professional:6400,team:25600
|
||||
TIDB_PUBLIC_KEY=dify
|
||||
TIDB_PRIVATE_KEY=dify
|
||||
TIDB_API_URL=http://127.0.0.1
|
||||
@@ -432,10 +450,12 @@ OPENGAUSS_MAX_CONNECTION=5
|
||||
|
||||
# Upload configuration
|
||||
UPLOAD_FILE_SIZE_LIMIT=15
|
||||
KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN=15
|
||||
UPLOAD_FILE_BATCH_LIMIT=5
|
||||
UPLOAD_IMAGE_FILE_SIZE_LIMIT=10
|
||||
UPLOAD_VIDEO_FILE_SIZE_LIMIT=100
|
||||
UPLOAD_AUDIO_FILE_SIZE_LIMIT=50
|
||||
UPLOAD_SKILL_FILE_SIZE_LIMIT=50
|
||||
|
||||
# Comma-separated list of file extensions blocked from upload for security reasons.
|
||||
# Extensions should be lowercase without dots (e.g., exe,bat,sh,dll).
|
||||
@@ -569,8 +589,8 @@ WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS=6
|
||||
GRAPH_ENGINE_MIN_WORKERS=3
|
||||
# Maximum number of workers per GraphEngine instance (default: 10)
|
||||
GRAPH_ENGINE_MAX_WORKERS=10
|
||||
# Queue depth threshold that triggers worker scale up (default: 3)
|
||||
GRAPH_ENGINE_SCALE_UP_THRESHOLD=3
|
||||
# Pending task threshold that triggers worker scale up (default: 0)
|
||||
GRAPH_ENGINE_SCALE_UP_THRESHOLD=0
|
||||
# Seconds of idle time before scaling down workers (default: 5.0)
|
||||
GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME=5.0
|
||||
|
||||
|
||||
@@ -8,7 +8,120 @@ root_packages =
|
||||
extensions
|
||||
factories
|
||||
libs
|
||||
machinery
|
||||
models
|
||||
repositories
|
||||
tasks
|
||||
services
|
||||
include_external_packages = True
|
||||
|
||||
[importlinter:contract:no-direct-rsa-imports]
|
||||
# Note: `libs` itself is deliberately excluded from source_modules -- import-linter's
|
||||
# `forbidden` contract cannot check a package against its own descendant (libs.rsa lives
|
||||
# inside libs). Sibling modules under libs/ importing libs.rsa are not covered by this
|
||||
# contract; the realistic risk this guards against is application/service code reaching
|
||||
# past the key provider abstraction, which is fully covered below.
|
||||
name = Only the key provider abstraction may import libs.rsa directly
|
||||
type = forbidden
|
||||
source_modules =
|
||||
core
|
||||
constants
|
||||
context
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
factories
|
||||
models
|
||||
tasks
|
||||
services
|
||||
forbidden_modules =
|
||||
libs.rsa
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:machinery-framework-boundary]
|
||||
name = API machinery is framework neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
machinery
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:workspace-query-service-boundary]
|
||||
name = Workspace query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.workspace_query_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:feature-query-service-boundary]
|
||||
name = Feature query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.feature_query_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:workspace-member-query-service-boundary]
|
||||
name = Workspace member query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.workspace_member_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.enterprise
|
||||
services.workspace_member_role_resolver
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:setup-service-boundary]
|
||||
name = Setup application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.setup_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:schema-definition-service-boundary]
|
||||
name = Schema definition application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.schema_definition_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@@ -16,7 +16,7 @@ from core.logging.context import init_request_context
|
||||
from dify_app import DifyApp
|
||||
from extensions.ext_socketio import sio
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import LicenseStatus
|
||||
from services.entities.feature_entities import LicenseStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -172,6 +172,7 @@ def initialize_extensions(app: DifyApp):
|
||||
from context.flask_app_context import init_flask_context
|
||||
from extensions import (
|
||||
ext_app_metrics,
|
||||
ext_application_services,
|
||||
ext_blueprints,
|
||||
ext_celery,
|
||||
ext_code_based_extension,
|
||||
@@ -183,6 +184,7 @@ def initialize_extensions(app: DifyApp):
|
||||
ext_forward_refs,
|
||||
ext_hosting_provider,
|
||||
ext_import_modules,
|
||||
ext_key_provider,
|
||||
ext_logging,
|
||||
ext_login,
|
||||
ext_logstore,
|
||||
@@ -218,6 +220,7 @@ def initialize_extensions(app: DifyApp):
|
||||
ext_migrate,
|
||||
ext_redis,
|
||||
ext_storage,
|
||||
ext_key_provider, # Initialize after storage, since RSAKeyProvider reads private keys from it
|
||||
ext_set_secretkey,
|
||||
ext_logstore, # Initialize logstore after storage, before celery
|
||||
ext_celery,
|
||||
@@ -233,6 +236,7 @@ def initialize_extensions(app: DifyApp):
|
||||
ext_enterprise_telemetry,
|
||||
ext_request_logging,
|
||||
ext_session_factory,
|
||||
ext_application_services,
|
||||
ext_oauth_bearer,
|
||||
]
|
||||
for ext in extensions:
|
||||
|
||||
@@ -5,8 +5,6 @@ API adapters: request building from Dify product concepts, a thin client wrapper
|
||||
event adaptation for future workflow integration, and deterministic fakes.
|
||||
"""
|
||||
|
||||
from dify_agent.protocol import RuntimeLayerSpec, extract_runtime_layer_specs
|
||||
|
||||
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
|
||||
from clients.agent_backend.errors import (
|
||||
AgentBackendError,
|
||||
@@ -47,11 +45,6 @@ from clients.agent_backend.request_builder import (
|
||||
AgentBackendWorkflowNodeRunInput,
|
||||
redact_for_agent_backend_log,
|
||||
)
|
||||
from clients.agent_backend.session_cleanup import (
|
||||
AgentBackendSessionCleanupPayload,
|
||||
AgentBackendSessionCleanupResult,
|
||||
cleanup_agent_backend_session,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AGENT_SOUL_PROMPT_LAYER_ID",
|
||||
@@ -80,8 +73,6 @@ __all__ = [
|
||||
"AgentBackendRunRequestBuilder",
|
||||
"AgentBackendRunStartedInternalEvent",
|
||||
"AgentBackendRunSucceededInternalEvent",
|
||||
"AgentBackendSessionCleanupPayload",
|
||||
"AgentBackendSessionCleanupResult",
|
||||
"AgentBackendStreamError",
|
||||
"AgentBackendStreamInternalEvent",
|
||||
"AgentBackendTransportError",
|
||||
@@ -90,9 +81,6 @@ __all__ = [
|
||||
"DifyAgentBackendRunClient",
|
||||
"FakeAgentBackendRunClient",
|
||||
"FakeAgentBackendScenario",
|
||||
"RuntimeLayerSpec",
|
||||
"cleanup_agent_backend_session",
|
||||
"create_agent_backend_run_client",
|
||||
"extract_runtime_layer_specs",
|
||||
"redact_for_agent_backend_log",
|
||||
]
|
||||
|
||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from dify_agent.protocol import RunFailureType
|
||||
|
||||
|
||||
class AgentBackendError(Exception):
|
||||
"""Base error for API-side Agent backend integration failures."""
|
||||
@@ -54,6 +56,7 @@ class AgentBackendRunFailedError(AgentBackendError):
|
||||
|
||||
run_id: str
|
||||
detail: Any
|
||||
error_type: RunFailureType | None
|
||||
reason: str | None
|
||||
source_event_id: str | None
|
||||
|
||||
@@ -63,11 +66,13 @@ class AgentBackendRunFailedError(AgentBackendError):
|
||||
detail: Any,
|
||||
*,
|
||||
message: str | None = None,
|
||||
error_type: RunFailureType | None = None,
|
||||
reason: str | None = None,
|
||||
source_event_id: str | None = None,
|
||||
) -> None:
|
||||
self.run_id = run_id
|
||||
self.detail = detail
|
||||
self.error_type = error_type
|
||||
self.reason = reason
|
||||
self.source_event_id = source_event_id
|
||||
display_message = message or f"Agent backend run failed: {run_id}"
|
||||
|
||||
@@ -22,6 +22,7 @@ from dify_agent.protocol import (
|
||||
RunCancelledEvent,
|
||||
RunEvent,
|
||||
RunFailedEvent,
|
||||
RunFailureType,
|
||||
RunStartedEvent,
|
||||
RunSucceededEvent,
|
||||
)
|
||||
@@ -96,6 +97,7 @@ class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
|
||||
|
||||
type: Literal[AgentBackendInternalEventType.RUN_FAILED] = AgentBackendInternalEventType.RUN_FAILED
|
||||
error: str
|
||||
error_type: RunFailureType | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@@ -180,6 +182,7 @@ class AgentBackendRunEventAdapter:
|
||||
run_id=event.run_id,
|
||||
source_event_id=event.id,
|
||||
error=event.data.error,
|
||||
error_type=event.data.error_type,
|
||||
reason=event.data.reason,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -16,7 +16,6 @@ from collections.abc import Mapping
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||
from agenton.layers import ExitIntent
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
|
||||
@@ -37,6 +36,7 @@ from dify_agent.layers.execution_context import (
|
||||
)
|
||||
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
||||
from dify_agent.layers.runtime import DIFY_RUNTIME_LAYER_TYPE_ID, DifyRuntimeLayerConfig
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.protocol import (
|
||||
DIFY_AGENT_HISTORY_LAYER_ID,
|
||||
@@ -47,7 +47,6 @@ from dify_agent.protocol import (
|
||||
LayerExitSignals,
|
||||
RunComposition,
|
||||
RunLayerSpec,
|
||||
RuntimeLayerSpec,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
||||
|
||||
@@ -56,6 +55,7 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
|
||||
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_RUNTIME_LAYER_ID = "runtime"
|
||||
DIFY_CONFIG_LAYER_ID = "config"
|
||||
DIFY_DRIVE_LAYER_ID = "drive"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
@@ -66,25 +66,11 @@ DIFY_SHELL_LAYER_ID = "shell"
|
||||
type AgentConfigVersionKind = Literal["snapshot", "draft", "build_draft"]
|
||||
|
||||
|
||||
def _filter_snapshot_to_specs(
|
||||
snapshot: CompositorSessionSnapshot,
|
||||
specs: list[RuntimeLayerSpec],
|
||||
) -> CompositorSessionSnapshot:
|
||||
"""Keep only snapshot layers whose names appear in the cleanup spec list.
|
||||
|
||||
The agenton compositor rejects a snapshot whose layer-name sequence does
|
||||
not match the active composition exactly. Cleanup-replay drops plugin
|
||||
layers, so we must drop the matching snapshot entries here.
|
||||
"""
|
||||
kept_names = {spec.name for spec in specs}
|
||||
filtered_layers: list[LayerSessionSnapshot] = [layer for layer in snapshot.layers if layer.name in kept_names]
|
||||
if len(filtered_layers) == len(snapshot.layers):
|
||||
return snapshot
|
||||
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
|
||||
|
||||
|
||||
def _shell_layer_deps() -> dict[str, str]:
|
||||
return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
return {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"runtime": DIFY_RUNTIME_LAYER_ID,
|
||||
}
|
||||
|
||||
|
||||
def _drive_layer_deps() -> dict[str, str]:
|
||||
@@ -214,6 +200,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
|
||||
model: AgentBackendModelConfig
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
backend_binding_ref: str = Field(min_length=1)
|
||||
workflow_node_job_prompt: str
|
||||
user_prompt: str
|
||||
agent_soul_prompt: str | None = None
|
||||
@@ -231,8 +218,8 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
# the Agent Soul configures human involvement; a deferred call ends the run and
|
||||
# the workflow pauses via the existing HITL form mechanism (ENG-635).
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None
|
||||
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
|
||||
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
|
||||
# Inject the sandboxed shell graph. Requires a deployment-selected runtime
|
||||
# backend plus the product-resolved persistent Binding.
|
||||
include_shell: bool = False
|
||||
shell_config: DifyShellLayerConfig | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
@@ -240,7 +227,6 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
# (ENG-638). Keyed by the original deferred tool_call_id.
|
||||
deferred_tool_results: DeferredToolResultsPayload | None = None
|
||||
include_history: bool = True
|
||||
suspend_on_exit: bool = True
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
@@ -264,6 +250,7 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
|
||||
model: AgentBackendModelConfig
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
backend_binding_ref: str = Field(min_length=1)
|
||||
user_prompt: str
|
||||
agent_soul_prompt: str | None = None
|
||||
agent_config_version_kind: AgentConfigVersionKind = "snapshot"
|
||||
@@ -279,8 +266,8 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement (ENG-635).
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None
|
||||
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
|
||||
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
|
||||
# Inject the sandboxed shell graph. Requires a deployment-selected runtime
|
||||
# backend plus the product-resolved persistent Binding.
|
||||
include_shell: bool = False
|
||||
shell_config: DifyShellLayerConfig | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
@@ -288,7 +275,6 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
# (ENG-638). Keyed by the original deferred tool_call_id.
|
||||
deferred_tool_results: DeferredToolResultsPayload | None = None
|
||||
include_history: bool = True
|
||||
suspend_on_exit: bool = True
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
@@ -350,6 +336,14 @@ class AgentBackendRunRequestBuilder:
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_RUNTIME_LAYER_ID,
|
||||
type=DIFY_RUNTIME_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config/drive
|
||||
# so eager pulls materialize content in the same filesystem used by
|
||||
# model commands.
|
||||
@@ -481,53 +475,7 @@ class AgentBackendRunRequestBuilder:
|
||||
metadata=run_input.metadata,
|
||||
session_snapshot=run_input.session_snapshot,
|
||||
deferred_tool_results=run_input.deferred_tool_results,
|
||||
on_exit=LayerExitSignals(
|
||||
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
|
||||
),
|
||||
)
|
||||
|
||||
def build_cleanup_request(
|
||||
self,
|
||||
*,
|
||||
session_snapshot: CompositorSessionSnapshot,
|
||||
runtime_layer_specs: list[RuntimeLayerSpec],
|
||||
idempotency_key: str | None = None,
|
||||
metadata: dict[str, JsonValue] | None = None,
|
||||
) -> CreateRunRequest:
|
||||
"""Build a lifecycle-only cleanup request that replays the prior layers.
|
||||
|
||||
The agenton compositor enforces that the session snapshot's layer names
|
||||
match the active composition in order, so cleanup must replay the same
|
||||
non-plugin layer graph that produced the snapshot. Plugin layers
|
||||
(``dify.plugin.llm``, ``dify.plugin.tools``) are excluded from both the
|
||||
composition and the snapshot before submission because their configs
|
||||
may carry credentials or runtime-only declarations that are not
|
||||
persisted between runs.
|
||||
"""
|
||||
if not runtime_layer_specs:
|
||||
raise ValueError(
|
||||
"build_cleanup_request requires runtime_layer_specs; an empty "
|
||||
"composition would fail the agent backend's snapshot validation."
|
||||
)
|
||||
request_metadata = dict(metadata or {})
|
||||
request_metadata["agent_backend_lifecycle"] = "session_cleanup"
|
||||
layers = [
|
||||
RunLayerSpec(
|
||||
name=spec.name,
|
||||
type=spec.type,
|
||||
deps=dict(spec.deps),
|
||||
metadata=dict(spec.metadata),
|
||||
config=spec.config,
|
||||
)
|
||||
for spec in runtime_layer_specs
|
||||
]
|
||||
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, runtime_layer_specs)
|
||||
return CreateRunRequest(
|
||||
composition=RunComposition(layers=layers),
|
||||
idempotency_key=idempotency_key,
|
||||
metadata=request_metadata,
|
||||
session_snapshot=filtered_snapshot,
|
||||
on_exit=LayerExitSignals(default=ExitIntent.DELETE),
|
||||
on_exit=LayerExitSignals(default=ExitIntent.SUSPEND),
|
||||
)
|
||||
|
||||
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
|
||||
@@ -580,6 +528,14 @@ class AgentBackendRunRequestBuilder:
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_RUNTIME_LAYER_ID,
|
||||
type=DIFY_RUNTIME_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
@@ -713,9 +669,7 @@ class AgentBackendRunRequestBuilder:
|
||||
metadata=run_input.metadata,
|
||||
session_snapshot=run_input.session_snapshot,
|
||||
deferred_tool_results=run_input.deferred_tool_results,
|
||||
on_exit=LayerExitSignals(
|
||||
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
|
||||
),
|
||||
on_exit=LayerExitSignals(default=ExitIntent.SUSPEND),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Shared API-side helper for Agent backend lifecycle-only session cleanup.
|
||||
|
||||
Product code owns local row retirement and background-task dispatch. This module
|
||||
only adapts persisted cleanup inputs into the public ``dify-agent`` run
|
||||
protocol, performs the synchronous ``create_run + wait_run`` loop used by Celery
|
||||
workers, and reports whether the backend cleanup succeeded, was skipped, or
|
||||
failed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.protocol import RuntimeLayerSpec
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
||||
|
||||
from clients.agent_backend.client import AgentBackendRunClient
|
||||
from clients.agent_backend.errors import AgentBackendError
|
||||
from clients.agent_backend.request_builder import AgentBackendRunRequestBuilder
|
||||
|
||||
|
||||
class AgentBackendSessionCleanupPayload(BaseModel):
|
||||
"""Serialized cleanup inputs preserved across API and Celery boundaries."""
|
||||
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
runtime_layer_specs: list[RuntimeLayerSpec] = Field(default_factory=list)
|
||||
idempotency_key: str | None = None
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
timeout_seconds: float = 30.0
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentBackendSessionCleanupResult:
|
||||
"""Terminal outcome of one backend cleanup attempt."""
|
||||
|
||||
status: Literal["succeeded", "skipped", "failed"]
|
||||
reason: str | None = None
|
||||
cleanup_run_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def succeeded(cls, cleanup_run_id: str) -> AgentBackendSessionCleanupResult:
|
||||
return cls(status="succeeded", cleanup_run_id=cleanup_run_id)
|
||||
|
||||
@classmethod
|
||||
def skipped(cls, reason: str) -> AgentBackendSessionCleanupResult:
|
||||
return cls(status="skipped", reason=reason)
|
||||
|
||||
@classmethod
|
||||
def failed(cls, reason: str, cleanup_run_id: str | None = None) -> AgentBackendSessionCleanupResult:
|
||||
return cls(status="failed", reason=reason, cleanup_run_id=cleanup_run_id)
|
||||
|
||||
|
||||
def cleanup_agent_backend_session(
|
||||
*,
|
||||
payload: AgentBackendSessionCleanupPayload,
|
||||
client: AgentBackendRunClient | None,
|
||||
request_builder: AgentBackendRunRequestBuilder | None = None,
|
||||
) -> AgentBackendSessionCleanupResult:
|
||||
"""Run lifecycle-only cleanup against the Agent backend and report status."""
|
||||
if client is None:
|
||||
return AgentBackendSessionCleanupResult.skipped("no_agent_backend_client")
|
||||
if payload.session_snapshot is None:
|
||||
return AgentBackendSessionCleanupResult.skipped("missing_session_snapshot")
|
||||
if not payload.runtime_layer_specs:
|
||||
return AgentBackendSessionCleanupResult.skipped("missing_runtime_layer_specs")
|
||||
|
||||
builder = request_builder or AgentBackendRunRequestBuilder()
|
||||
request = builder.build_cleanup_request(
|
||||
session_snapshot=payload.session_snapshot,
|
||||
runtime_layer_specs=payload.runtime_layer_specs,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
metadata=payload.metadata,
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.create_run(request)
|
||||
except AgentBackendError as exc:
|
||||
return AgentBackendSessionCleanupResult.failed(str(exc))
|
||||
|
||||
try:
|
||||
status_response = client.wait_run(response.run_id, timeout_seconds=payload.timeout_seconds)
|
||||
except AgentBackendError as exc:
|
||||
return AgentBackendSessionCleanupResult.failed(str(exc), cleanup_run_id=response.run_id)
|
||||
|
||||
if status_response.status != "succeeded":
|
||||
reason = status_response.error or f"cleanup run ended with status {status_response.status}"
|
||||
return AgentBackendSessionCleanupResult.failed(reason, cleanup_run_id=response.run_id)
|
||||
|
||||
return AgentBackendSessionCleanupResult.succeeded(response.run_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentBackendSessionCleanupPayload",
|
||||
"AgentBackendSessionCleanupResult",
|
||||
"cleanup_agent_backend_session",
|
||||
]
|
||||
@@ -11,7 +11,7 @@ from events.app_event import app_was_created
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.db_migration_lock import DbMigrationAutoRenewLock
|
||||
from libs.rsa import generate_key_pair
|
||||
from libs.key_providers import generate_key_pair
|
||||
from models import Tenant
|
||||
from models.model import App, AppMode, Conversation
|
||||
from models.provider import Provider, ProviderModel
|
||||
|
||||
@@ -25,6 +25,12 @@ class EnterpriseFeatureConfig(BaseSettings):
|
||||
default=False,
|
||||
)
|
||||
|
||||
ENABLE_LICENSE_EXPIRY_NOTICE: bool = Field(
|
||||
description="Show the license expiry countdown badge in the console when the license is expiring. "
|
||||
"Disable to hide the badge; license status and all enforcement remain unaffected.",
|
||||
default=True,
|
||||
)
|
||||
|
||||
ENTERPRISE_REQUEST_TIMEOUT: int = Field(
|
||||
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
|
||||
)
|
||||
|
||||
@@ -44,9 +44,8 @@ class AgentBackendConfig(BaseSettings):
|
||||
|
||||
AGENT_SHELL_ENABLED: bool = Field(
|
||||
description=(
|
||||
"Inject the dify.shell layer (sandboxed bash workspace) into Agent runs. "
|
||||
"Requires the agent backend to be wired with a shellctl entrypoint before "
|
||||
"shell-using Agent runs are executed."
|
||||
"Inject the Home, Workspace, Sandbox, and Shell runtime layers into Agent runs. "
|
||||
"Requires Dify Agent to have a deployment-selected runtime backend."
|
||||
),
|
||||
default=True,
|
||||
)
|
||||
|
||||
@@ -450,6 +450,11 @@ class FileUploadConfig(BaseSettings):
|
||||
default=15,
|
||||
)
|
||||
|
||||
KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN: NonNegativeInt = Field(
|
||||
description="Maximum allowed file size for knowledge uploads on paid cloud plans in megabytes",
|
||||
default=15,
|
||||
)
|
||||
|
||||
UPLOAD_FILE_BATCH_LIMIT: NonNegativeInt = Field(
|
||||
description="Maximum number of files allowed in a single upload batch",
|
||||
default=5,
|
||||
@@ -470,6 +475,11 @@ class FileUploadConfig(BaseSettings):
|
||||
default=50,
|
||||
)
|
||||
|
||||
UPLOAD_SKILL_FILE_SIZE_LIMIT: NonNegativeInt = Field(
|
||||
description="Maximum allowed Skill package size for uploads in megabytes",
|
||||
default=50,
|
||||
)
|
||||
|
||||
BATCH_UPLOAD_LIMIT: NonNegativeInt = Field(
|
||||
description="Maximum number of files allowed in a batch upload operation",
|
||||
default=20,
|
||||
@@ -865,7 +875,7 @@ class WorkflowVariableTruncationConfig(BaseSettings):
|
||||
)
|
||||
WORKFLOW_VARIABLE_TRUNCATION_STRING_LENGTH: PositiveInt = Field(
|
||||
100000,
|
||||
description="maximum length for string to trigger tuncation, measure in number of characters",
|
||||
description="maximum length for string to trigger truncation, measure in number of characters",
|
||||
)
|
||||
WORKFLOW_VARIABLE_TRUNCATION_ARRAY_LENGTH: PositiveInt = Field(
|
||||
1000,
|
||||
@@ -919,9 +929,9 @@ class WorkflowConfig(BaseSettings):
|
||||
default=10,
|
||||
)
|
||||
|
||||
GRAPH_ENGINE_SCALE_UP_THRESHOLD: PositiveInt = Field(
|
||||
description="Queue depth threshold that triggers worker scale up",
|
||||
default=3,
|
||||
GRAPH_ENGINE_SCALE_UP_THRESHOLD: NonNegativeInt = Field(
|
||||
description="Pending task threshold that triggers worker scale up",
|
||||
default=0,
|
||||
)
|
||||
|
||||
GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME: float = Field(
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic_settings import BaseSettings
|
||||
|
||||
from .cache.redis_config import RedisConfig
|
||||
from .cache.redis_pubsub_config import RedisPubSubConfig
|
||||
from .key_provider.azure_keyvault_config import AzureKeyVaultConfig
|
||||
from .storage.aliyun_oss_storage_config import AliyunOSSStorageConfig
|
||||
from .storage.amazon_s3_storage_config import S3StorageConfig
|
||||
from .storage.azure_blob_storage_config import AzureBlobStorageConfig
|
||||
@@ -83,6 +84,22 @@ class StorageConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
_VALID_KEY_PROVIDER_TYPE = Literal[
|
||||
"local",
|
||||
"azure-keyvault",
|
||||
]
|
||||
|
||||
|
||||
class KeyProviderConfig(BaseSettings):
|
||||
KEY_PROVIDER_TYPE: _VALID_KEY_PROVIDER_TYPE = Field(
|
||||
description="Key provider used to encrypt/decrypt tenant credentials (LLM/tool provider secrets)."
|
||||
" Options: 'local' (per-tenant RSA key pair, private key kept in the STORAGE_TYPE backend),"
|
||||
" 'azure-keyvault' (per-tenant RSA key kept in Azure Key Vault, private key never leaves the vault)."
|
||||
" Default is 'local'.",
|
||||
default=cast(_VALID_KEY_PROVIDER_TYPE, "local"),
|
||||
)
|
||||
|
||||
|
||||
class VectorStoreConfig(BaseSettings):
|
||||
VECTOR_STORE: str | None = Field(
|
||||
description="Type of vector store to use for efficient similarity search."
|
||||
@@ -359,6 +376,9 @@ class MiddlewareConfig(
|
||||
KeywordStoreConfig,
|
||||
RedisConfig,
|
||||
RedisPubSubConfig,
|
||||
# configs of the tenant credential encryption key provider
|
||||
KeyProviderConfig,
|
||||
AzureKeyVaultConfig,
|
||||
# configs of storage and storage providers
|
||||
StorageConfig,
|
||||
AliyunOSSStorageConfig,
|
||||
|
||||
38
api/configs/middleware/key_provider/azure_keyvault_config.py
Normal file
38
api/configs/middleware/key_provider/azure_keyvault_config.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class AzureKeyVaultConfig(BaseSettings):
|
||||
"""
|
||||
Configuration settings for Azure Key Vault, used as a tenant credential encryption key provider
|
||||
"""
|
||||
|
||||
AZURE_KEYVAULT_VAULT_URL: str | None = Field(
|
||||
description="URL of the Azure Key Vault instance (e.g., 'https://<your-vault-name>.vault.azure.net')."
|
||||
" Required when KEY_PROVIDER_TYPE is set to 'azure-keyvault'. Authentication uses"
|
||||
" DefaultAzureCredential (managed identity, environment variables, or Azure CLI login).",
|
||||
default=None,
|
||||
)
|
||||
|
||||
AZURE_KEYVAULT_KEY_SIZE: int = Field(
|
||||
description="RSA key size (in bits) used when Dify provisions a new per-tenant key in Azure Key Vault.",
|
||||
default=2048,
|
||||
)
|
||||
|
||||
AZURE_KEYVAULT_ROTATION_INTERVAL_DAYS: int | None = Field(
|
||||
description="If set, Dify configures each newly-created per-tenant Key Vault key to auto-rotate every"
|
||||
" N days (using a 'time after create' trigger, with no expiry set on generated versions)."
|
||||
" Old key versions are kept forever and remain usable for decrypting credentials encrypted"
|
||||
" before the rotation, so rotation requires no manual re-encryption. Leave unset (default) to"
|
||||
" not configure a rotation policy and manage rotation manually in Azure. Must be at least 7 days.",
|
||||
default=None,
|
||||
ge=7,
|
||||
)
|
||||
|
||||
@field_validator("AZURE_KEYVAULT_ROTATION_INTERVAL_DAYS", mode="before")
|
||||
@classmethod
|
||||
def _empty_string_to_none_for_rotation_interval(cls, v):
|
||||
"""Allow empty string in env/.env (e.g. an unfilled template value) to mean 'unset'."""
|
||||
if isinstance(v, str) and v.strip() == "":
|
||||
return None
|
||||
return v
|
||||
@@ -32,6 +32,11 @@ class TidbOnQdrantConfig(BaseSettings):
|
||||
default=6334,
|
||||
)
|
||||
|
||||
TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB: str = Field(
|
||||
description="Cloud pre-write thresholds for projected TiDB vector storage usage, in plan:MB pairs.",
|
||||
default="sandbox:60,professional:6400,team:25600",
|
||||
)
|
||||
|
||||
TIDB_PUBLIC_KEY: str | None = Field(
|
||||
description="Tidb account public key",
|
||||
default=None,
|
||||
|
||||
@@ -31,3 +31,8 @@ class TiDBVectorConfig(BaseSettings):
|
||||
description="Name of the TiDB Vector database to connect to",
|
||||
default=None,
|
||||
)
|
||||
|
||||
TIDB_VECTOR_ENABLE_FULLTEXT_SEARCH: bool = Field(
|
||||
description="Enable TiDB Vector full-text and hybrid search features",
|
||||
default=False,
|
||||
)
|
||||
|
||||
@@ -88,8 +88,9 @@ default_app_templates: Mapping[AppMode, Mapping] = {
|
||||
AppMode.AGENT: {
|
||||
"app": {
|
||||
"mode": AppMode.AGENT,
|
||||
"enable_site": True,
|
||||
"enable_api": True,
|
||||
# Public access is enabled atomically by the first successful publish.
|
||||
"enable_site": False,
|
||||
"enable_api": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
@@ -34,9 +35,7 @@ def get_published_agent_app_feature_dict_and_user_input_form(
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
# active_config_is_published means the draft has no unpublished edits; the public app
|
||||
# can still read parameters from the active snapshot while a newer draft is pending.
|
||||
if not agent.active_config_snapshot_id:
|
||||
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
|
||||
snapshot = session.scalar(
|
||||
|
||||
@@ -52,7 +52,7 @@ def with_session[T, **P, R](
|
||||
session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
session.rollback() # noqa: no-new-controller-sqlalchemy decorator owns transaction rollback
|
||||
session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback
|
||||
raise
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
|
||||
@@ -43,6 +43,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from fields.agent_fields import (
|
||||
AgentConfigDraftSummaryResponse,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
@@ -62,7 +63,7 @@ from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.agent import Agent, AgentConfigDraftType, AgentStatus
|
||||
from models.agent import Agent, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, IconType
|
||||
@@ -139,6 +140,7 @@ class AgentApiStatusPayload(BaseModel):
|
||||
|
||||
|
||||
class AgentApiAccessResponse(BaseModel):
|
||||
access_ready: bool
|
||||
enabled: bool
|
||||
service_api_base_url: str
|
||||
streaming_only: bool = True
|
||||
@@ -257,6 +259,7 @@ class AgentAppDetailWithSite(GenericAppDetailWithSite):
|
||||
debug_conversation_has_messages: bool = False
|
||||
debug_conversation_message_count: int = 0
|
||||
role: str | None = None
|
||||
access_ready: bool = False
|
||||
|
||||
|
||||
class AgentDebugConversationRefreshResponse(BaseModel):
|
||||
@@ -265,13 +268,6 @@ class AgentDebugConversationRefreshResponse(BaseModel):
|
||||
debug_conversation_message_count: int = 0
|
||||
|
||||
|
||||
class AgentDebugConversationRefreshPayload(BaseModel):
|
||||
draft_type: AgentConfigDraftType = Field(
|
||||
default=AgentConfigDraftType.DEBUG_BUILD,
|
||||
description="Agent draft surface whose conversation should be refreshed",
|
||||
)
|
||||
|
||||
|
||||
class AgentPublishPayload(BaseModel):
|
||||
version_note: str | None = Field(default=None, description="Optional note for this published Agent version")
|
||||
|
||||
@@ -315,7 +311,6 @@ register_schema_models(
|
||||
AgentAppCopyPayload,
|
||||
AgentPublishPayload,
|
||||
AgentBuildDraftCheckoutPayload,
|
||||
AgentDebugConversationRefreshPayload,
|
||||
ComposerSavePayload,
|
||||
AgentApiStatusPayload,
|
||||
AgentInviteOptionsQuery,
|
||||
@@ -395,11 +390,10 @@ def _serialize_agent_app_detail(
|
||||
payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent)
|
||||
payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id)
|
||||
payload["id"] = agent.id
|
||||
debug_conversation_id = roster_service.get_or_create_agent_app_debug_conversation_id(
|
||||
debug_conversation_id = roster_service.get_or_create_build_conversation(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent.id,
|
||||
account_id=current_user.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
commit=False,
|
||||
)
|
||||
message_count = roster_service.count_agent_app_debug_conversation_messages(
|
||||
@@ -409,6 +403,7 @@ def _serialize_agent_app_detail(
|
||||
payload["debug_conversation_has_messages"] = message_count > 0
|
||||
payload["debug_conversation_message_count"] = message_count
|
||||
payload["role"] = agent.role or ""
|
||||
payload["access_ready"] = agent_has_workflow_callable_active_snapshot(session=session, agent=agent)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -439,11 +434,10 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=[agent.id for agent in agents_by_app_id.values()],
|
||||
)
|
||||
debug_conversation_ids_by_agent_id = roster_service.load_or_create_agent_app_debug_conversation_ids_by_agent_id(
|
||||
debug_conversation_ids_by_agent_id = roster_service.load_or_create_build_conversation_ids_by_agent_id(
|
||||
tenant_id=tenant_id,
|
||||
agents=list(agents_by_app_id.values()),
|
||||
account_id=current_user.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
)
|
||||
payload = AgentAppPagination.model_validate(
|
||||
app_pagination,
|
||||
@@ -501,10 +495,20 @@ def _agent_api_key_count(session: Session, app_id: str) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _agent_app_access_ready(session: Session, app_model: App) -> bool:
|
||||
agent = _agent_roster_service(session).get_app_backing_agent(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=str(app_model.id),
|
||||
)
|
||||
return bool(agent and agent_has_workflow_callable_active_snapshot(session=session, agent=agent))
|
||||
|
||||
|
||||
def _serialize_agent_api_access(session: Session, app_model: App) -> dict:
|
||||
base_url = app_model.api_base_url
|
||||
access_ready = _agent_app_access_ready(session, app_model)
|
||||
response = AgentApiAccessResponse(
|
||||
enabled=bool(app_model.enable_api),
|
||||
access_ready=access_ready,
|
||||
enabled=bool(app_model.enable_api and access_ready),
|
||||
service_api_base_url=base_url,
|
||||
chat_endpoint=f"{base_url}/chat-messages",
|
||||
stop_endpoint=f"{base_url}/chat-messages/{{task_id}}/stop",
|
||||
@@ -678,16 +682,6 @@ class AgentAppApi(Resource):
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/debug-conversation/refresh")
|
||||
class AgentDebugConversationRefreshApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentDebugConversationRefreshPayload.__name__])
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"payload": {
|
||||
"in": "body",
|
||||
"required": False,
|
||||
"schema": {"$ref": f"#/components/schemas/{AgentDebugConversationRefreshPayload.__name__}"},
|
||||
}
|
||||
}
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Agent debug conversation refreshed",
|
||||
@@ -702,12 +696,10 @@ class AgentDebugConversationRefreshApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentDebugConversationRefreshPayload.model_validate(request.get_json(silent=True) or {})
|
||||
debug_conversation_id = _agent_roster_service(session).refresh_agent_app_debug_conversation_id(
|
||||
debug_conversation_id = _agent_roster_service(session).reset_build_conversation(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
draft_type=args.draft_type,
|
||||
)
|
||||
return AgentDebugConversationRefreshResponse(
|
||||
debug_conversation_id=debug_conversation_id,
|
||||
@@ -751,7 +743,7 @@ class AgentBuildDraftCheckoutApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.checkout_agent_app_build_draft(
|
||||
@@ -808,7 +800,7 @@ class AgentBuildDraftApi(Resource):
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@with_session(write=False)
|
||||
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.discard_agent_app_build_draft(
|
||||
session=session,
|
||||
@@ -828,7 +820,7 @@ class AgentBuildDraftApplyApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.apply_agent_app_build_draft(
|
||||
session=session,
|
||||
|
||||
@@ -21,6 +21,7 @@ from models.dataset import Dataset
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.app_service import AppService
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import (
|
||||
@@ -103,7 +104,9 @@ class BaseApiKeyListResource(Resource):
|
||||
|
||||
def _create_api_key(self, resource_id: str, current_tenant_id: str, *, session: Session) -> ApiToken:
|
||||
assert self.resource_id_field is not None, "resource_id_field must be set"
|
||||
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
|
||||
resource = _get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
|
||||
if isinstance(resource, App):
|
||||
AppService.ensure_agent_app_access_ready(resource, session=session)
|
||||
current_key_count: int = (
|
||||
session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Console routes for Agent App and workflow Agent sandbox file access.
|
||||
|
||||
The API keeps product-facing locators (conversation or workflow node identity)
|
||||
on this public boundary and proxies list/read/upload to the agent backend's new
|
||||
``/sandbox`` contract.
|
||||
The API accepts product-facing Conversation, Build Draft, or Workflow Node
|
||||
Execution locators and proxies list/read/download to the agent backend's
|
||||
``/execution-bindings/files`` contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,10 +11,8 @@ from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
@@ -22,14 +20,20 @@ from controllers.common.schema import (
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import App, AppMode
|
||||
from services.agent_app_sandbox_service import (
|
||||
AgentAppSandboxService,
|
||||
@@ -37,52 +41,49 @@ from services.agent_app_sandbox_service import (
|
||||
WorkflowAgentSandboxService,
|
||||
)
|
||||
|
||||
_NODE_EXECUTION_ID_DESCRIPTION = (
|
||||
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
|
||||
_BINDING_PATH_DESCRIPTION = (
|
||||
"Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; "
|
||||
"`~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside "
|
||||
"Workspace are governed by backend isolation, not a Workspace-root restriction"
|
||||
)
|
||||
|
||||
|
||||
class AgentSandboxListQuery(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
|
||||
caller_type: Literal["conversation", "build_draft"]
|
||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||
path: str = Field(default=".", description=_BINDING_PATH_DESCRIPTION)
|
||||
|
||||
|
||||
class AgentSandboxInfoQuery(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
caller_type: Literal["conversation", "build_draft"]
|
||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||
|
||||
|
||||
class AgentSandboxFileQuery(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
caller_type: Literal["conversation", "build_draft"]
|
||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
|
||||
|
||||
|
||||
class AgentSandboxUploadPayload(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
class AgentSandboxDownloadPayload(BaseModel):
|
||||
caller_type: Literal["conversation", "build_draft"]
|
||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
|
||||
|
||||
|
||||
class WorkflowAgentSandboxListQuery(BaseModel):
|
||||
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=_NODE_EXECUTION_ID_DESCRIPTION,
|
||||
)
|
||||
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
||||
path: str = Field(default=".", description=_BINDING_PATH_DESCRIPTION)
|
||||
|
||||
|
||||
class WorkflowAgentSandboxFileQuery(BaseModel):
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=_NODE_EXECUTION_ID_DESCRIPTION,
|
||||
)
|
||||
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
||||
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
|
||||
|
||||
|
||||
class WorkflowAgentSandboxUploadPayload(BaseModel):
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=_NODE_EXECUTION_ID_DESCRIPTION,
|
||||
)
|
||||
class WorkflowAgentSandboxDownloadPayload(BaseModel):
|
||||
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
||||
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
|
||||
|
||||
|
||||
class SandboxFileEntryResponse(ResponseModel):
|
||||
@@ -99,7 +100,6 @@ class SandboxListResponse(ResponseModel):
|
||||
|
||||
|
||||
class SandboxInfoResponse(ResponseModel):
|
||||
session_id: str
|
||||
workspace_cwd: str
|
||||
|
||||
|
||||
@@ -111,21 +111,21 @@ class SandboxReadResponse(ResponseModel):
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class SandboxUploadResponse(ResponseModel):
|
||||
class SandboxDownloadResponse(ResponseModel):
|
||||
url: str
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
AgentSandboxUploadPayload,
|
||||
WorkflowAgentSandboxUploadPayload,
|
||||
AgentSandboxDownloadPayload,
|
||||
WorkflowAgentSandboxDownloadPayload,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
SandboxInfoResponse,
|
||||
SandboxListResponse,
|
||||
SandboxReadResponse,
|
||||
SandboxUploadResponse,
|
||||
SandboxDownloadResponse,
|
||||
)
|
||||
|
||||
|
||||
@@ -155,15 +155,19 @@ class AgentAppSandboxInfoResource(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
service = AgentAppSandboxService()
|
||||
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
query = query_params_from_request(AgentSandboxInfoQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().get_info(
|
||||
result = service.get_info(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
app_id=app_id,
|
||||
agent_id=str(agent_id),
|
||||
caller_type=query.caller_type,
|
||||
caller_id=query.caller_id,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _handle(exc)
|
||||
@@ -180,15 +184,19 @@ class AgentAppSandboxListResource(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
service = AgentAppSandboxService()
|
||||
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
query = query_params_from_request(AgentSandboxListQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().list_files(
|
||||
result = service.list_files(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
app_id=app_id,
|
||||
agent_id=str(agent_id),
|
||||
caller_type=query.caller_type,
|
||||
caller_id=query.caller_id,
|
||||
account_id=current_user.id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -206,15 +214,19 @@ class AgentAppSandboxReadResource(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
service = AgentAppSandboxService()
|
||||
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
query = query_params_from_request(AgentSandboxFileQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().read_file(
|
||||
result = service.read_file(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
app_id=app_id,
|
||||
agent_id=str(agent_id),
|
||||
caller_type=query.caller_type,
|
||||
caller_id=query.caller_id,
|
||||
account_id=current_user.id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -222,26 +234,36 @@ class AgentAppSandboxReadResource(Resource):
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
|
||||
class AgentAppSandboxUploadResource(Resource):
|
||||
@console_ns.doc("upload_agent_app_sandbox_file")
|
||||
@console_ns.doc(description="Upload one Agent App sandbox file and return a signed download URL")
|
||||
@console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__])
|
||||
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
|
||||
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/download")
|
||||
class AgentAppSandboxDownloadResource(Resource):
|
||||
@console_ns.doc("download_agent_app_sandbox_file")
|
||||
@console_ns.doc(description="Create a ToolFile from one Agent App Binding file and return its download URL")
|
||||
@console_ns.expect(console_ns.models[AgentSandboxDownloadPayload.__name__])
|
||||
@console_ns.response(200, "Download URL returned", console_ns.models[SandboxDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
|
||||
@with_current_user
|
||||
@model_validate(AgentSandboxDownloadPayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: AgentSandboxDownloadPayload,
|
||||
current_user: Account,
|
||||
tenant_id: str,
|
||||
agent_id: UUID,
|
||||
):
|
||||
service = AgentAppSandboxService()
|
||||
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
try:
|
||||
result = AgentAppSandboxService().upload_file(
|
||||
result = service.download_file(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=payload.conversation_id,
|
||||
path=payload.path,
|
||||
app_id=app_id,
|
||||
agent_id=str(agent_id),
|
||||
caller_type=req_data.caller_type,
|
||||
caller_id=req_data.caller_id,
|
||||
account_id=current_user.id,
|
||||
path=req_data.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _handle(exc)
|
||||
@@ -321,29 +343,41 @@ class WorkflowAgentSandboxReadResource(Resource):
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/upload"
|
||||
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/download"
|
||||
)
|
||||
class WorkflowAgentSandboxUploadResource(Resource):
|
||||
@console_ns.doc("upload_workflow_agent_sandbox_file")
|
||||
@console_ns.doc(description="Upload one workflow Agent sandbox file and return a signed download URL")
|
||||
@console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__])
|
||||
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
|
||||
class WorkflowAgentSandboxDownloadResource(Resource):
|
||||
@console_ns.doc("download_workflow_agent_sandbox_file")
|
||||
@console_ns.doc(description="Create a ToolFile from one workflow Agent Binding file and return its download URL")
|
||||
@console_ns.expect(console_ns.models[WorkflowAgentSandboxDownloadPayload.__name__])
|
||||
@console_ns.response(200, "Download URL returned", console_ns.models[SandboxDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
payload = WorkflowAgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
|
||||
@model_validate(WorkflowAgentSandboxDownloadPayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: WorkflowAgentSandboxDownloadPayload,
|
||||
tenant_id: str,
|
||||
current_user: Account,
|
||||
app_id: UUID,
|
||||
workflow_run_id: UUID,
|
||||
node_id: str,
|
||||
):
|
||||
service = WorkflowAgentSandboxService()
|
||||
resolved_app_id = service.resolve_app_id(tenant_id=tenant_id, app_id=str(app_id))
|
||||
if resolved_app_id is None:
|
||||
raise AppNotFoundError()
|
||||
try:
|
||||
result = WorkflowAgentSandboxService().upload_file(
|
||||
result = service.download_file(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
app_id=resolved_app_id,
|
||||
workflow_run_id=str(workflow_run_id),
|
||||
node_id=node_id,
|
||||
node_execution_id=payload.node_execution_id,
|
||||
path=payload.path,
|
||||
session=db.session(),
|
||||
node_execution_id=req_data.node_execution_id,
|
||||
account_id=current_user.id,
|
||||
path=req_data.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _handle(exc)
|
||||
|
||||
@@ -30,6 +30,7 @@ from controllers.console.wraps import (
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
@@ -810,14 +811,21 @@ class AgentConfigFilesByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
payload = AgentConfigFileUploadPayload.model_validate(console_ns.payload or {})
|
||||
@model_validate(AgentConfigFileUploadPayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: AgentConfigFileUploadPayload,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
current_user: Account,
|
||||
agent_id: UUID,
|
||||
):
|
||||
return _with_agent_route_target(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
current_user=current_user,
|
||||
action=lambda target: _file_upload_response(target, payload),
|
||||
action=lambda target: _file_upload_response(target, req_data),
|
||||
)
|
||||
|
||||
|
||||
@@ -849,13 +857,13 @@ class AgentConfigFilesApi(Resource):
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
payload = AgentConfigFileUploadPayload.model_validate(console_ns.payload or {})
|
||||
@model_validate(AgentConfigFileUploadPayload)
|
||||
def post(self, req_data: AgentConfigFileUploadPayload, session: Session, current_user: Account, app_model: App):
|
||||
return _with_app_route_target(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
current_user=current_user,
|
||||
action=lambda target: _file_upload_response(target, payload),
|
||||
action=lambda target: _file_upload_response(target, req_data),
|
||||
)
|
||||
|
||||
|
||||
@@ -1324,4 +1332,5 @@ class AgentConfigFileApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
# pyrefly: ignore [unresolvable-dunder-all]
|
||||
__all__ = [name for name, value in globals().items() if inspect.isclass(value) and issubclass(value, Resource)]
|
||||
|
||||
@@ -20,6 +20,7 @@ from controllers.console.wraps import (
|
||||
annotation_import_rate_limit,
|
||||
cloud_edition_billing_resource_check,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
@@ -180,14 +181,14 @@ class AnnotationReplyActionApi(Resource):
|
||||
@cloud_edition_billing_resource_check("annotation")
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
def post(self, app_id: UUID, action: Literal["enable", "disable"]):
|
||||
args = AnnotationReplyPayload.model_validate(console_ns.payload)
|
||||
@model_validate(AnnotationReplyPayload)
|
||||
def post(self, req_data: AnnotationReplyPayload, app_id: UUID, action: Literal["enable", "disable"]):
|
||||
match action:
|
||||
case "enable":
|
||||
enable_args: EnableAnnotationArgs = {
|
||||
"score_threshold": args.score_threshold,
|
||||
"embedding_provider_name": args.embedding_provider_name,
|
||||
"embedding_model_name": args.embedding_model_name,
|
||||
"score_threshold": req_data.score_threshold,
|
||||
"embedding_provider_name": req_data.embedding_provider_name,
|
||||
"embedding_model_name": req_data.embedding_model_name,
|
||||
}
|
||||
result = AppAnnotationService.enable_app_annotation(enable_args, str(app_id))
|
||||
case "disable":
|
||||
@@ -231,12 +232,17 @@ class AppAnnotationSettingUpdateApi(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@with_session
|
||||
def post(self, session: Session, app_id: UUID, annotation_setting_id: UUID):
|
||||
@model_validate(AnnotationSettingUpdatePayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: AnnotationSettingUpdatePayload,
|
||||
session: Session,
|
||||
app_id: UUID,
|
||||
annotation_setting_id: UUID,
|
||||
):
|
||||
annotation_setting_id_str = str(annotation_setting_id)
|
||||
|
||||
args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload)
|
||||
|
||||
setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold}
|
||||
setting_args: UpdateAnnotationSettingArgs = {"score_threshold": req_data.score_threshold}
|
||||
result = AppAnnotationService.update_app_annotation_setting(
|
||||
str(app_id), annotation_setting_id_str, setting_args, session
|
||||
)
|
||||
@@ -290,11 +296,11 @@ class AnnotationApi(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, app_id: UUID):
|
||||
args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
page = args.page
|
||||
limit = args.limit
|
||||
keyword = args.keyword
|
||||
@model_validate(AnnotationListQuery)
|
||||
def get(self, req_data: AnnotationListQuery, session: Session, app_id: UUID):
|
||||
page = req_data.page
|
||||
limit = req_data.limit
|
||||
keyword = req_data.keyword
|
||||
|
||||
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(
|
||||
str(app_id), page, limit, keyword, session
|
||||
@@ -317,17 +323,17 @@ class AnnotationApi(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@with_session
|
||||
def post(self, session: Session, app_id: UUID):
|
||||
args = CreateAnnotationPayload.model_validate(console_ns.payload)
|
||||
@model_validate(CreateAnnotationPayload)
|
||||
def post(self, req_data: CreateAnnotationPayload, session: Session, app_id: UUID):
|
||||
upsert_args: UpsertAnnotationArgs = {}
|
||||
if args.answer is not None:
|
||||
upsert_args["answer"] = args.answer
|
||||
if args.content is not None:
|
||||
upsert_args["content"] = args.content
|
||||
if args.message_id is not None:
|
||||
upsert_args["message_id"] = args.message_id
|
||||
if args.question is not None:
|
||||
upsert_args["question"] = args.question
|
||||
if req_data.answer is not None:
|
||||
upsert_args["answer"] = req_data.answer
|
||||
if req_data.content is not None:
|
||||
upsert_args["content"] = req_data.content
|
||||
if req_data.message_id is not None:
|
||||
upsert_args["message_id"] = req_data.message_id
|
||||
if req_data.question is not None:
|
||||
upsert_args["question"] = req_data.question
|
||||
annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, str(app_id), session)
|
||||
return dump_response(Annotation, annotation), 201
|
||||
|
||||
@@ -407,13 +413,13 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@with_session
|
||||
def post(self, session: Session, app_id: UUID, annotation_id: UUID):
|
||||
args = UpdateAnnotationPayload.model_validate(console_ns.payload)
|
||||
@model_validate(UpdateAnnotationPayload)
|
||||
def post(self, req_data: UpdateAnnotationPayload, session: Session, app_id: UUID, annotation_id: UUID):
|
||||
update_args: UpdateAnnotationArgs = {}
|
||||
if args.answer is not None:
|
||||
update_args["answer"] = args.answer
|
||||
if args.question is not None:
|
||||
update_args["question"] = args.question
|
||||
if req_data.answer is not None:
|
||||
update_args["answer"] = req_data.answer
|
||||
if req_data.question is not None:
|
||||
update_args["question"] = req_data.question
|
||||
app_ref = _get_app_ref(session, str(app_id))
|
||||
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
|
||||
annotation = AppAnnotationService.update_app_annotation_directly(update_args, annotation_ref, session)
|
||||
|
||||
@@ -36,7 +36,7 @@ from controllers.console.wraps import (
|
||||
with_current_user_id,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.entities.app_invoke_entities import AGENT_RUNTIME_EXIT_INTENT_ARG, InvokeFrom
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
|
||||
from core.errors.error import (
|
||||
ModelCurrentlyNotSupportError,
|
||||
@@ -353,12 +353,7 @@ def _resolve_current_user_agent_debug_conversation_id(
|
||||
draft_type: AgentConfigDraftType,
|
||||
start_new: bool = False,
|
||||
) -> str:
|
||||
"""Resolve or rotate the current editor's conversation within one draft surface.
|
||||
|
||||
``start_new`` rotates the scoped mapping through ``AgentRosterService`` so
|
||||
the old runtime session is retired before the new conversation is used.
|
||||
Continuations and Build chat keep resolving the existing mapping.
|
||||
"""
|
||||
"""Resolve the current editor's Build or Preview conversation."""
|
||||
|
||||
roster_service = AgentRosterService(session)
|
||||
resolved_agent_id = agent_id
|
||||
@@ -368,17 +363,26 @@ def _resolve_current_user_agent_debug_conversation_id(
|
||||
raise AgentNotFoundError()
|
||||
resolved_agent_id = agent.id
|
||||
|
||||
resolve_conversation = (
|
||||
roster_service.refresh_agent_app_debug_conversation_id
|
||||
if start_new
|
||||
else roster_service.get_or_create_agent_app_debug_conversation_id
|
||||
)
|
||||
return resolve_conversation(
|
||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
return roster_service.get_or_create_build_conversation(
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=resolved_agent_id,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
if start_new:
|
||||
return roster_service.rotate_preview_conversation(
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=resolved_agent_id,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
conversation_id = roster_service.get_current_preview_conversation(
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=resolved_agent_id,
|
||||
account_id=current_user.id,
|
||||
draft_type=draft_type,
|
||||
)
|
||||
if conversation_id is None:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
return conversation_id
|
||||
|
||||
|
||||
def _create_chat_message(
|
||||
@@ -450,7 +454,6 @@ def _create_build_chat_finalization_message(
|
||||
"draft_type": "debug_build",
|
||||
"conversation_id": debug_conversation_id,
|
||||
"auto_generate_name": False,
|
||||
AGENT_RUNTIME_EXIT_INTENT_ARG: "delete",
|
||||
}
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
@@ -16,6 +15,7 @@ from controllers.console.wraps import (
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
@@ -110,17 +110,16 @@ class AppMCPServerController(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@with_current_tenant_id
|
||||
@get_app_model
|
||||
def post(self, current_tenant_id: str, app_model: App):
|
||||
payload = MCPServerCreatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
description = payload.description
|
||||
@model_validate(MCPServerCreatePayload)
|
||||
def post(self, req_data: MCPServerCreatePayload, current_tenant_id: str, app_model: App):
|
||||
description = req_data.description
|
||||
if not description:
|
||||
description = app_model.description or ""
|
||||
|
||||
server = AppMCPServer(
|
||||
name=app_model.name,
|
||||
description=description,
|
||||
parameters=json.dumps(payload.parameters, ensure_ascii=False),
|
||||
parameters=json.dumps(req_data.parameters, ensure_ascii=False),
|
||||
status=AppMCPServerStatus.ACTIVE,
|
||||
app_id=app_model.id,
|
||||
tenant_id=current_tenant_id,
|
||||
@@ -145,10 +144,10 @@ class AppMCPServerController(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@get_app_model
|
||||
def put(self, app_model: App):
|
||||
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
|
||||
@model_validate(MCPServerUpdatePayload)
|
||||
def put(self, req_data: MCPServerUpdatePayload, app_model: App):
|
||||
app_ref = AppRefService.create_app_ref(app_model)
|
||||
server_ref = AppRefService.create_mcp_server_ref(app_ref, payload.id)
|
||||
server_ref = AppRefService.create_mcp_server_ref(app_ref, req_data.id)
|
||||
server = db.session.scalar(
|
||||
select(AppMCPServer)
|
||||
.where(
|
||||
@@ -161,7 +160,7 @@ class AppMCPServerController(Resource):
|
||||
if not server:
|
||||
raise NotFound()
|
||||
|
||||
description = payload.description
|
||||
description = req_data.description
|
||||
if description is None or not description:
|
||||
server.description = app_model.description or ""
|
||||
else:
|
||||
@@ -169,21 +168,21 @@ class AppMCPServerController(Resource):
|
||||
|
||||
server.name = app_model.name
|
||||
|
||||
server.parameters = json.dumps(payload.parameters, ensure_ascii=False)
|
||||
if payload.status:
|
||||
server.parameters = json.dumps(req_data.parameters, ensure_ascii=False)
|
||||
if req_data.status:
|
||||
try:
|
||||
server.status = AppMCPServerStatus(payload.status)
|
||||
server.status = AppMCPServerStatus(req_data.status)
|
||||
except ValueError:
|
||||
raise ValueError("Invalid status")
|
||||
db.session.commit()
|
||||
return dump_response(AppMCPServerResponse, server)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:server_id>/server/refresh")
|
||||
@console_ns.route("/apps/<uuid:app_id>/server/refresh")
|
||||
class AppMCPServerRefreshController(Resource):
|
||||
@console_ns.doc("refresh_app_mcp_server")
|
||||
@console_ns.doc(description="Refresh MCP server configuration and regenerate server code")
|
||||
@console_ns.doc(params={"server_id": "Server ID"})
|
||||
@console_ns.doc(params={"app_id": "App ID"})
|
||||
@console_ns.response(200, "MCP server refreshed successfully", console_ns.models[AppMCPServerResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(404, "Server not found")
|
||||
@@ -193,10 +192,11 @@ class AppMCPServerRefreshController(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, server_id: UUID):
|
||||
@get_app_model
|
||||
def post(self, current_tenant_id: str, app_model: App):
|
||||
server = db.session.scalar(
|
||||
select(AppMCPServer)
|
||||
.where(AppMCPServer.id == server_id, AppMCPServer.tenant_id == current_tenant_id)
|
||||
.where(AppMCPServer.app_id == app_model.id, AppMCPServer.tenant_id == current_tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
if not server:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import BadRequest
|
||||
@@ -14,6 +13,7 @@ from controllers.console.wraps import (
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
@@ -74,12 +74,11 @@ class TraceAppConfigApi(Resource):
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def get(self, app_model: App):
|
||||
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
@model_validate(TraceProviderQuery)
|
||||
def get(self, req_data: TraceProviderQuery, app_model: App):
|
||||
try:
|
||||
trace_config = OpsService.get_tracing_app_config(
|
||||
app_id=app_model.id, tracing_provider=args.tracing_provider, session=db.session()
|
||||
app_id=app_model.id, tracing_provider=req_data.tracing_provider, session=db.session()
|
||||
)
|
||||
if not trace_config:
|
||||
return {"has_not_configured": True}
|
||||
@@ -104,15 +103,14 @@ class TraceAppConfigApi(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def post(self, app_model: App):
|
||||
@model_validate(TraceConfigPayload)
|
||||
def post(self, req_data: TraceConfigPayload, app_model: App):
|
||||
"""Create a new trace app configuration"""
|
||||
args = TraceConfigPayload.model_validate(console_ns.payload)
|
||||
|
||||
try:
|
||||
result = OpsService.create_tracing_app_config(
|
||||
app_id=app_model.id,
|
||||
tracing_provider=args.tracing_provider,
|
||||
tracing_config=args.tracing_config,
|
||||
tracing_provider=req_data.tracing_provider,
|
||||
tracing_config=req_data.tracing_config,
|
||||
session=db.session(),
|
||||
)
|
||||
if not result:
|
||||
@@ -140,15 +138,14 @@ class TraceAppConfigApi(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def patch(self, app_model: App):
|
||||
@model_validate(TraceConfigPayload)
|
||||
def patch(self, req_data: TraceConfigPayload, app_model: App):
|
||||
"""Update an existing trace app configuration"""
|
||||
args = TraceConfigPayload.model_validate(console_ns.payload)
|
||||
|
||||
try:
|
||||
result = OpsService.update_tracing_app_config(
|
||||
app_id=app_model.id,
|
||||
tracing_provider=args.tracing_provider,
|
||||
tracing_config=args.tracing_config,
|
||||
tracing_provider=req_data.tracing_provider,
|
||||
tracing_config=req_data.tracing_config,
|
||||
session=db.session(),
|
||||
)
|
||||
if not result:
|
||||
@@ -170,13 +167,12 @@ class TraceAppConfigApi(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def delete(self, app_model: App):
|
||||
@model_validate(TraceProviderQuery)
|
||||
def delete(self, req_data: TraceProviderQuery, app_model: App):
|
||||
"""Delete an existing trace app configuration"""
|
||||
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
try:
|
||||
result = OpsService.delete_tracing_app_config(
|
||||
app_id=app_model.id, tracing_provider=args.tracing_provider, session=db.session()
|
||||
app_id=app_model.id, tracing_provider=req_data.tracing_provider, session=db.session()
|
||||
)
|
||||
if not result:
|
||||
raise TracingConfigNotExist()
|
||||
|
||||
@@ -2,11 +2,20 @@ import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
from typing import Any, NotRequired, Self, TypedDict
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, RootModel, ValidationError, field_validator
|
||||
from pydantic import (
|
||||
AliasChoices,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
RootModel,
|
||||
ValidationError,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
|
||||
|
||||
@@ -54,6 +63,10 @@ from core.trigger.debug.event_selectors import (
|
||||
create_event_poller,
|
||||
select_trigger_debug_events,
|
||||
)
|
||||
from core.workflow.llm_environment_variable import (
|
||||
LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE,
|
||||
LLMEnvironmentVariable,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from factories import file_factory, variable_factory
|
||||
@@ -76,11 +89,13 @@ from models import Account, App
|
||||
from models.model import AppMode
|
||||
from models.workflow import Workflow
|
||||
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_ref_service import WorkflowRefService
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,14 +116,35 @@ class EnvironmentVariableResponseDict(TypedDict):
|
||||
description: NotRequired[str | None]
|
||||
|
||||
|
||||
class SyncEnvironmentVariablePatchPayload(BaseModel):
|
||||
environment_variables: list[dict[str, Any]] = Field(default_factory=list)
|
||||
deleted_environment_variable_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_patch(self) -> Self:
|
||||
"""Require stable, disjoint IDs so the service can merge the patch deterministically."""
|
||||
upsert_ids = [variable.get("id") for variable in self.environment_variables]
|
||||
if any(not isinstance(variable_id, str) or not variable_id for variable_id in upsert_ids):
|
||||
raise ValueError("patched environment variables require an id")
|
||||
if len(set(upsert_ids)) != len(upsert_ids):
|
||||
raise ValueError("patched environment variable ids must be unique")
|
||||
if any(not variable_id for variable_id in self.deleted_environment_variable_ids):
|
||||
raise ValueError("deleted environment variable ids must not be empty")
|
||||
if len(set(self.deleted_environment_variable_ids)) != len(self.deleted_environment_variable_ids):
|
||||
raise ValueError("deleted environment variable ids must be unique")
|
||||
if set(upsert_ids).intersection(self.deleted_environment_variable_ids):
|
||||
raise ValueError("an environment variable cannot be upserted and deleted in the same patch")
|
||||
return self
|
||||
|
||||
|
||||
class SyncDraftWorkflowPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
graph: dict[str, Any]
|
||||
features: dict[str, Any]
|
||||
hash: str | None = None
|
||||
is_collaborative: bool = Field(default=False, alias="_is_collaborative")
|
||||
environment_variables: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
)
|
||||
environment_variable_patch: SyncEnvironmentVariablePatchPayload | None = None
|
||||
conversation_variables: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
)
|
||||
@@ -317,7 +353,7 @@ class _WorkflowResponseSource:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def created_by_account(self) -> Account | None:
|
||||
@@ -481,6 +517,15 @@ def _parse_file(workflow: Workflow, files: list[dict] | None = None) -> Sequence
|
||||
|
||||
def _serialize_environment_variable(value: Any) -> EnvironmentVariableResponseDict | Any:
|
||||
match value:
|
||||
case LLMEnvironmentVariable():
|
||||
return {
|
||||
"id": value.id,
|
||||
"name": value.name,
|
||||
"value": value.value,
|
||||
"value_type": LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE,
|
||||
"description": value.description,
|
||||
}
|
||||
|
||||
case SecretVariable():
|
||||
return {
|
||||
"id": value.id,
|
||||
@@ -505,6 +550,8 @@ def _serialize_environment_variable(value: Any) -> EnvironmentVariableResponseDi
|
||||
raise TypeError(
|
||||
f"unexpected type for value_type field, value={value_type_str}, type={type(value_type_str)}"
|
||||
)
|
||||
if value_type_str == LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE:
|
||||
return value
|
||||
value_type = SegmentType(value_type_str).exposed_type()
|
||||
if value_type not in ENVIRONMENT_VARIABLE_SUPPORTED_TYPES:
|
||||
raise ValueError(f"Unsupported environment variable value type: {value_type}")
|
||||
@@ -588,30 +635,38 @@ class DraftWorkflowApi(Resource):
|
||||
return {"message": "Invalid JSON data"}, 400
|
||||
else:
|
||||
abort(415)
|
||||
args = args_model.model_dump()
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
try:
|
||||
environment_variables_list = Workflow.normalize_environment_variable_mappings(
|
||||
args.get("environment_variables") or [],
|
||||
)
|
||||
environment_variables = [
|
||||
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
|
||||
]
|
||||
conversation_variables_list = args.get("conversation_variables") or []
|
||||
environment_variable_patch = args_model.environment_variable_patch
|
||||
environment_variable_upserts: list[VariableBase] | None = None
|
||||
deleted_environment_variable_ids: list[str] = []
|
||||
if environment_variable_patch is not None:
|
||||
environment_variable_upsert_mappings = Workflow.normalize_environment_variable_mappings(
|
||||
environment_variable_patch.environment_variables,
|
||||
)
|
||||
environment_variable_upserts = [
|
||||
variable_factory.build_environment_variable_from_mapping(obj)
|
||||
for obj in environment_variable_upsert_mappings
|
||||
]
|
||||
deleted_environment_variable_ids = environment_variable_patch.deleted_environment_variable_ids
|
||||
conversation_variables = [
|
||||
variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list
|
||||
variable_factory.build_conversation_variable_from_mapping(obj)
|
||||
for obj in args_model.conversation_variables
|
||||
]
|
||||
workflow = workflow_service.sync_draft_workflow(
|
||||
app_model=app_model,
|
||||
graph=args["graph"],
|
||||
features=args["features"],
|
||||
unique_hash=args.get("hash"),
|
||||
graph=args_model.graph,
|
||||
features=args_model.features,
|
||||
unique_hash=args_model.hash,
|
||||
account=current_user,
|
||||
environment_variables=environment_variables,
|
||||
environment_variables=[],
|
||||
conversation_variables=conversation_variables,
|
||||
session=db.session(),
|
||||
graph_only=args["is_collaborative"],
|
||||
environment_variable_upserts=environment_variable_upserts,
|
||||
deleted_environment_variable_ids=deleted_environment_variable_ids,
|
||||
preserve_environment_variables=True,
|
||||
graph_only=args_model.is_collaborative,
|
||||
)
|
||||
except WorkflowHashNotEqualError:
|
||||
raise DraftWorkflowNotSync()
|
||||
@@ -1245,7 +1300,7 @@ class PublishedWorkflowApi(Resource):
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
workflow = workflow_service.publish_workflow(
|
||||
workflow, retirement_candidates = workflow_service.publish_workflow(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
@@ -1262,6 +1317,16 @@ class PublishedWorkflowApi(Resource):
|
||||
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=app_model.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate, TypedDict, override
|
||||
from typing import Any, Concatenate, Self, TypedDict, override
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource, fields, marshal, marshal_with
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from controllers.common.errors import InvalidArgumentError, NotFoundError
|
||||
@@ -27,6 +27,7 @@ from controllers.console.wraps import (
|
||||
with_current_user,
|
||||
)
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.workflow.llm_environment_variable import environment_variable_value_type
|
||||
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||
from extensions.ext_database import db
|
||||
from factories import variable_factory
|
||||
@@ -109,6 +110,35 @@ class EnvironmentVariableUpdatePayload(BaseModel):
|
||||
...,
|
||||
description="Environment variables for the draft workflow",
|
||||
)
|
||||
patch: bool = Field(
|
||||
default=False,
|
||||
description="Treat environment_variables as per-ID upserts instead of replacing the full collection",
|
||||
)
|
||||
deleted_environment_variable_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Environment variable IDs to delete when patch is true",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_patch(self) -> Self:
|
||||
"""Validate the per-variable patch contract without changing legacy replacement requests."""
|
||||
if not self.patch:
|
||||
if self.deleted_environment_variable_ids:
|
||||
raise ValueError("deleted_environment_variable_ids requires patch=true")
|
||||
return self
|
||||
|
||||
upsert_ids = [variable.id for variable in self.environment_variables]
|
||||
if any(not variable_id for variable_id in upsert_ids):
|
||||
raise ValueError("patched environment variables require an id")
|
||||
if len(set(upsert_ids)) != len(upsert_ids):
|
||||
raise ValueError("patched environment variable ids must be unique")
|
||||
if any(not variable_id for variable_id in self.deleted_environment_variable_ids):
|
||||
raise ValueError("deleted environment variable ids must not be empty")
|
||||
if len(set(self.deleted_environment_variable_ids)) != len(self.deleted_environment_variable_ids):
|
||||
raise ValueError("deleted environment variable ids must be unique")
|
||||
if set(upsert_ids).intersection(self.deleted_environment_variable_ids):
|
||||
raise ValueError("an environment variable cannot be upserted and deleted in the same patch")
|
||||
return self
|
||||
|
||||
|
||||
class EnvironmentVariableItemResponse(ResponseModel):
|
||||
@@ -698,7 +728,7 @@ class EnvironmentVariableCollectionApi(Resource):
|
||||
"name": v.name,
|
||||
"description": v.description,
|
||||
"selector": v.selector,
|
||||
"value_type": str(v.value_type.exposed_type()),
|
||||
"value_type": environment_variable_value_type(v),
|
||||
"value": v.value,
|
||||
# Do not track edited for env vars.
|
||||
"edited": False,
|
||||
@@ -737,11 +767,20 @@ class EnvironmentVariableCollectionApi(Resource):
|
||||
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
|
||||
]
|
||||
|
||||
workflow_service.update_draft_workflow_environment_variables(
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
environment_variables=environment_variables,
|
||||
session=db.session(),
|
||||
)
|
||||
if payload.patch:
|
||||
workflow_service.patch_draft_workflow_environment_variables(
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
environment_variables=environment_variables,
|
||||
deleted_environment_variable_ids=payload.deleted_environment_variable_ids,
|
||||
session=db.session(),
|
||||
)
|
||||
else:
|
||||
workflow_service.update_draft_workflow_environment_variables(
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
environment_variables=environment_variables,
|
||||
session=db.session(),
|
||||
)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@@ -17,6 +17,7 @@ from ..wraps import (
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
is_admin_or_owner_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
@@ -87,10 +88,10 @@ class ApiKeyAuthDataSourceBinding(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
|
||||
@console_ns.expect(console_ns.models[ApiKeyAuthBindingPayload.__name__])
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
@model_validate(ApiKeyAuthBindingPayload)
|
||||
def post(self, req_data: ApiKeyAuthBindingPayload, current_tenant_id: str):
|
||||
# The role of the current user in the table must be admin or owner
|
||||
payload = ApiKeyAuthBindingPayload.model_validate(console_ns.payload)
|
||||
data = payload.model_dump()
|
||||
data = req_data.model_dump()
|
||||
ApiKeyAuthService.validate_api_key_auth_args(data)
|
||||
try:
|
||||
ApiKeyAuthService.create_provider_auth(current_tenant_id, data, session=db.session())
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
|
||||
from controllers.console.wraps import account_initialization_required, model_validate, setup_required, with_current_user
|
||||
from core.db.session_factory import session_factory
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from libs.login import login_required
|
||||
@@ -154,8 +154,8 @@ class OAuthServerAppApi(Resource):
|
||||
@console_ns.expect(console_ns.models[OAuthProviderRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[OAuthProviderAppResponse.__name__])
|
||||
@oauth_server_client_id_required
|
||||
def post(self, oauth_provider_app: OAuthProviderApp):
|
||||
payload = OAuthProviderRequest.model_validate(request.get_json())
|
||||
@model_validate(OAuthProviderRequest)
|
||||
def post(self, payload: OAuthProviderRequest, oauth_provider_app: OAuthProviderApp):
|
||||
redirect_uri = payload.redirect_uri
|
||||
|
||||
# check if redirect_uri is valid
|
||||
@@ -196,9 +196,8 @@ class OAuthServerUserTokenApi(Resource):
|
||||
@console_ns.expect(console_ns.models[OAuthTokenRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[OAuthProviderTokenResponse.__name__])
|
||||
@oauth_server_client_id_required
|
||||
def post(self, oauth_provider_app: OAuthProviderApp):
|
||||
payload = OAuthTokenRequest.model_validate(request.get_json())
|
||||
|
||||
@model_validate(OAuthTokenRequest)
|
||||
def post(self, payload: OAuthTokenRequest, oauth_provider_app: OAuthProviderApp):
|
||||
try:
|
||||
grant_type = OAuthGrantType(payload.grant_type)
|
||||
except ValueError:
|
||||
|
||||
@@ -218,7 +218,7 @@ class _DatasetQueryResponseSource:
|
||||
return self.query.get_queries(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.query, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.query, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class DatasetQueryListResponse(ResponseModel):
|
||||
@@ -257,7 +257,7 @@ class _RelatedAppResponseSource:
|
||||
return self.app.mode_compatible_with_agent_with_session(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.app, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.app, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class RelatedAppListResponse(ResponseModel):
|
||||
@@ -360,7 +360,6 @@ def _get_retrieval_methods_by_vector_type(vector_type: str | None, is_mock: bool
|
||||
# Define vector database types that only support semantic search
|
||||
semantic_only_types = {
|
||||
VectorType.RELYT,
|
||||
VectorType.TIDB_VECTOR,
|
||||
VectorType.CHROMA,
|
||||
VectorType.PGVECTO_RS,
|
||||
VectorType.VIKINGDB,
|
||||
@@ -408,6 +407,9 @@ def _get_retrieval_methods_by_vector_type(vector_type: str | None, is_mock: bool
|
||||
if vector_type == VectorType.MILVUS:
|
||||
return semantic_methods if is_mock else full_methods
|
||||
|
||||
if vector_type == VectorType.TIDB_VECTOR:
|
||||
return full_methods if dify_config.TIDB_VECTOR_ENABLE_FULLTEXT_SEARCH else semantic_methods
|
||||
|
||||
if vector_type in semantic_only_types:
|
||||
return semantic_methods
|
||||
elif vector_type in full_search_types:
|
||||
|
||||
@@ -21,7 +21,7 @@ from controllers.common.fields import SimpleResultMessageResponse, SimpleResultR
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import RBACPermission, RBACResourceScope, rbac_permission_required
|
||||
from controllers.console.wraps import RBACPermission, RBACResourceScope, model_validate, rbac_permission_required
|
||||
from core.entities.knowledge_entities import IndexingEstimate
|
||||
from core.errors.error import (
|
||||
LLMBadRequestError,
|
||||
@@ -60,6 +60,7 @@ from services.dataset_ref_service import DatasetRefService
|
||||
from services.dataset_service import DatasetService, DocumentService
|
||||
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel
|
||||
from services.file_service import FileService
|
||||
from services.vector_space_admission_service import get_vector_space_admission_error_fields
|
||||
from tasks.generate_summary_index_task import generate_summary_index_task
|
||||
|
||||
from ..app.error import (
|
||||
@@ -935,6 +936,7 @@ class DocumentBatchIndexingStatusApi(DocumentResource):
|
||||
"completed_at": document.completed_at,
|
||||
"paused_at": document.paused_at,
|
||||
"error": document.error,
|
||||
**get_vector_space_admission_error_fields(document.error),
|
||||
"stopped_at": document.stopped_at,
|
||||
"completed_segments": completed_segments,
|
||||
"total_segments": total_segments,
|
||||
@@ -995,6 +997,7 @@ class DocumentIndexingStatusApi(DocumentResource):
|
||||
"completed_at": document.completed_at,
|
||||
"paused_at": document.paused_at,
|
||||
"error": document.error,
|
||||
**get_vector_space_admission_error_fields(document.error),
|
||||
"stopped_at": document.stopped_at,
|
||||
"completed_segments": completed_segments,
|
||||
"total_segments": total_segments,
|
||||
@@ -1264,13 +1267,20 @@ class DocumentMetadataApi(DocumentResource):
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
def put(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID):
|
||||
@model_validate(DocumentMetadataUpdatePayload)
|
||||
def put(
|
||||
self,
|
||||
req_data: DocumentMetadataUpdatePayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
document_id: UUID,
|
||||
):
|
||||
dataset_id_str = str(dataset_id)
|
||||
document_id_str = str(document_id)
|
||||
document = self.get_document(session, dataset_id_str, document_id_str, current_user, current_tenant_id)
|
||||
|
||||
req_data = DocumentMetadataUpdatePayload.model_validate(request.get_json() or {})
|
||||
|
||||
doc_type = req_data.doc_type
|
||||
doc_metadata = req_data.doc_metadata
|
||||
|
||||
@@ -1441,9 +1451,11 @@ class DocumentRetryApi(DocumentResource):
|
||||
retry_documents = []
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
documents = DocumentService.get_documents_by_ids(dataset.id, payload.document_ids, session)
|
||||
documents_by_id = {document.id: document for document in documents}
|
||||
for document_id in payload.document_ids:
|
||||
try:
|
||||
document = DocumentService.get_document(dataset.id, document_id, session=session)
|
||||
document = documents_by_id.get(document_id)
|
||||
|
||||
# 404 if document not found
|
||||
if document is None:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
@@ -37,6 +39,7 @@ from models import Account
|
||||
from models.dataset import ExternalKnowledgeApis
|
||||
from services.dataset_service import DatasetService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.entities.external_knowledge_entities.external_knowledge_entities import ExternalDatasetCreatePayload
|
||||
from services.external_knowledge_service import ExternalDatasetService
|
||||
from services.hit_testing_service import HitTestingService
|
||||
from services.knowledge_service import BedrockRetrievalSetting, ExternalDatasetTestService
|
||||
@@ -47,14 +50,6 @@ class ExternalKnowledgeApiPayload(BaseModel):
|
||||
settings: dict[str, Any]
|
||||
|
||||
|
||||
class ExternalDatasetCreatePayload(BaseModel):
|
||||
external_knowledge_api_id: str
|
||||
external_knowledge_id: str
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: str | None = Field(None, max_length=400)
|
||||
external_retrieval_model: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ExternalHitTestingPayload(BaseModel):
|
||||
query: str
|
||||
external_retrieval_model: dict[str, Any] | None = None
|
||||
@@ -62,7 +57,7 @@ class ExternalHitTestingPayload(BaseModel):
|
||||
|
||||
|
||||
class BedrockRetrievalPayload(BaseModel):
|
||||
retrieval_setting: "BedrockRetrievalSetting"
|
||||
retrieval_setting: BedrockRetrievalSetting
|
||||
query: str
|
||||
knowledge_id: str
|
||||
|
||||
@@ -106,7 +101,7 @@ class ExternalKnowledgeApiResponseSource:
|
||||
return self.external_knowledge_api.get_dataset_bindings(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.external_knowledge_api, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.external_knowledge_api, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def external_knowledge_api_response(
|
||||
@@ -353,14 +348,15 @@ class ExternalDatasetCreateApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EXTERNAL_CONNECT)
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.DATASET, RBACPermission.DATASET_EXTERNAL_CONNECT, resource_required=False
|
||||
)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str, current_user: Account):
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
payload = ExternalDatasetCreatePayload.model_validate(console_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
# The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
|
||||
if not current_user.is_dataset_editor:
|
||||
@@ -370,7 +366,7 @@ class ExternalDatasetCreateApi(Resource):
|
||||
dataset = ExternalDatasetService.create_external_dataset(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
args=args,
|
||||
args=payload,
|
||||
session=session,
|
||||
)
|
||||
except services.errors.dataset.DatasetNameDuplicateError:
|
||||
|
||||
@@ -26,6 +26,7 @@ from controllers.console.app.workflow_draft_variable import (
|
||||
from controllers.console.datasets.wraps import get_rag_pipeline
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, environment_variable_value_type
|
||||
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||
from extensions.ext_database import db
|
||||
from factories.file_factory import build_from_mapping, build_from_mappings
|
||||
@@ -362,7 +363,11 @@ class RagPipelineEnvironmentVariableCollectionApi(Resource):
|
||||
"name": v.name,
|
||||
"description": v.description,
|
||||
"selector": v.selector,
|
||||
"value_type": v.value_type.value,
|
||||
"value_type": (
|
||||
environment_variable_value_type(v)
|
||||
if isinstance(v, LLMEnvironmentVariable)
|
||||
else v.value_type.value
|
||||
),
|
||||
"value": v.value,
|
||||
# Do not track edited for env vars.
|
||||
"edited": False,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, RootModel
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.datasets.error import WebsiteCrawlError
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from controllers.console.wraps import account_initialization_required, model_validate, setup_required
|
||||
from libs.login import login_required
|
||||
from services.website_service import WebsiteCrawlApiRequest, WebsiteCrawlStatusApiRequest, WebsiteService
|
||||
|
||||
@@ -40,12 +39,11 @@ class WebsiteCrawlApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
payload = WebsiteCrawlPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
@model_validate(WebsiteCrawlPayload)
|
||||
def post(self, req_data: WebsiteCrawlPayload):
|
||||
# Create typed request and validate
|
||||
try:
|
||||
api_request = WebsiteCrawlApiRequest.from_args(payload.model_dump())
|
||||
api_request = WebsiteCrawlApiRequest.from_args(req_data.model_dump())
|
||||
except ValueError as e:
|
||||
raise WebsiteCrawlError(str(e))
|
||||
|
||||
@@ -69,12 +67,11 @@ class WebsiteCrawlStatusApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, job_id: str):
|
||||
args = WebsiteCrawlStatusQuery.model_validate(request.args.to_dict())
|
||||
|
||||
@model_validate(WebsiteCrawlStatusQuery)
|
||||
def get(self, req_data: WebsiteCrawlStatusQuery, job_id: str):
|
||||
# Create typed request and validate
|
||||
try:
|
||||
api_request = WebsiteCrawlStatusApiRequest.from_args(args.model_dump(), job_id)
|
||||
api_request = WebsiteCrawlStatusApiRequest.from_args(req_data.model_dump(), job_id)
|
||||
except ValueError as e:
|
||||
raise WebsiteCrawlError(str(e))
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from controllers.console.app.error import (
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from controllers.console.explore.wraps import InstalledAppResource
|
||||
from controllers.console.wraps import model_validate
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
@@ -100,16 +101,15 @@ class ChatAudioApi(InstalledAppResource):
|
||||
class ChatTextApi(InstalledAppResource):
|
||||
@console_ns.expect(console_ns.models[TextToAudioPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
|
||||
def post(self, installed_app: InstalledApp):
|
||||
@model_validate(TextToAudioPayload)
|
||||
def post(self, req_data: TextToAudioPayload, installed_app: InstalledApp):
|
||||
app_model = installed_app.app_with_session(session=db.session())
|
||||
if app_model is None:
|
||||
raise AppUnavailableError()
|
||||
try:
|
||||
payload = TextToAudioPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
message_id = payload.message_id
|
||||
text = payload.text
|
||||
voice = payload.voice
|
||||
message_id = req_data.message_id
|
||||
text = req_data.text
|
||||
voice = req_data.voice
|
||||
message_ref = None
|
||||
if message_id:
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
@@ -1,37 +1,62 @@
|
||||
from typing import Any, cast
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from pydantic import BaseModel, Field, RootModel, field_validator
|
||||
from sqlalchemy import select
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models
|
||||
from controllers.console import api
|
||||
from controllers.console.explore.wraps import explore_banner_enabled
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from models.enums import BannerStatus
|
||||
from models.model import ExporleBanner
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
class BannerListQuery(BaseModel):
|
||||
language: str = Field(default="en-US", description="Banner language")
|
||||
|
||||
|
||||
class BannerContentResponse(ResponseModel):
|
||||
category: str
|
||||
title: str = Field(min_length=1)
|
||||
description: str
|
||||
image_source: str = Field(
|
||||
min_length=1,
|
||||
validation_alias="img-src",
|
||||
serialization_alias="img-src",
|
||||
)
|
||||
|
||||
|
||||
class BannerResponse(ResponseModel):
|
||||
id: str
|
||||
content: Any
|
||||
link: str | None = None
|
||||
content: BannerContentResponse
|
||||
link: str
|
||||
sort: int
|
||||
status: str
|
||||
created_at: str | None = None
|
||||
status: BannerStatus
|
||||
created_at: str
|
||||
|
||||
@field_validator("created_at", mode="before")
|
||||
@classmethod
|
||||
def serialize_created_at(cls, value: datetime | str) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
class BannerListResponse(RootModel[list[BannerResponse]]):
|
||||
root: list[BannerResponse]
|
||||
|
||||
|
||||
register_response_schema_models(cast(Namespace, api), BannerListResponse)
|
||||
register_response_schema_models(
|
||||
cast(Namespace, api),
|
||||
BannerContentResponse,
|
||||
BannerResponse,
|
||||
BannerListResponse,
|
||||
)
|
||||
|
||||
|
||||
class BannerApi(Resource):
|
||||
@@ -39,38 +64,28 @@ class BannerApi(Resource):
|
||||
|
||||
@api.doc(params=query_params_from_model(BannerListQuery))
|
||||
@api.response(200, "Success", api.models[BannerListResponse.__name__])
|
||||
@explore_banner_enabled
|
||||
def get(self):
|
||||
"""Get banner list."""
|
||||
language = request.args.get("language", "en-US")
|
||||
if not FeatureService.is_explore_banner_enabled():
|
||||
return dump_response(BannerListResponse, [])
|
||||
|
||||
query = BannerListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
# Build base query for enabled banners
|
||||
base_query = select(ExporleBanner).where(ExporleBanner.status == BannerStatus.ENABLED)
|
||||
|
||||
# Try to get banners in the requested language
|
||||
banners = db.session.scalars(
|
||||
base_query.where(ExporleBanner.language == language).order_by(ExporleBanner.sort)
|
||||
base_query.where(ExporleBanner.language == query.language).order_by(ExporleBanner.sort)
|
||||
).all()
|
||||
|
||||
# Fallback to en-US if no banners found and language is not en-US
|
||||
if not banners and language != "en-US":
|
||||
if not banners and query.language != "en-US":
|
||||
banners = db.session.scalars(
|
||||
base_query.where(ExporleBanner.language == "en-US").order_by(ExporleBanner.sort)
|
||||
).all()
|
||||
# Convert banners to serializable format
|
||||
result = []
|
||||
for banner in banners:
|
||||
banner_data = {
|
||||
"id": banner.id,
|
||||
"content": banner.content, # Already parsed as JSON by SQLAlchemy
|
||||
"link": banner.link,
|
||||
"sort": banner.sort,
|
||||
"status": banner.status,
|
||||
"created_at": banner.created_at.isoformat() if banner.created_at else None,
|
||||
}
|
||||
result.append(banner_data)
|
||||
|
||||
return result
|
||||
return dump_response(BannerListResponse, banners)
|
||||
|
||||
|
||||
api.add_resource(BannerApi, "/explore/banners")
|
||||
|
||||
@@ -20,7 +20,7 @@ from controllers.console.app.error import (
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.console.explore.error import NotChatAppError, NotCompletionAppError
|
||||
from controllers.console.explore.wraps import InstalledAppResource
|
||||
from controllers.console.wraps import with_current_user, with_current_user_id
|
||||
from controllers.console.wraps import model_validate, with_current_user, with_current_user_id
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import (
|
||||
@@ -89,17 +89,23 @@ class CompletionApi(InstalledAppResource):
|
||||
@console_ns.response(200, "Success")
|
||||
@with_current_user
|
||||
@with_session
|
||||
def post(self, session: Session, current_user: Account, installed_app: InstalledApp):
|
||||
@model_validate(CompletionMessageExplorePayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: CompletionMessageExplorePayload,
|
||||
session: Session,
|
||||
current_user: Account,
|
||||
installed_app: InstalledApp,
|
||||
):
|
||||
app_model = installed_app.app_with_session(session=session)
|
||||
if app_model is None:
|
||||
raise AppUnavailableError()
|
||||
if app_model.mode != AppMode.COMPLETION:
|
||||
raise NotCompletionAppError()
|
||||
|
||||
payload = CompletionMessageExplorePayload.model_validate(console_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
args = req_data.model_dump(exclude_none=True)
|
||||
|
||||
streaming = payload.response_mode == "streaming"
|
||||
streaming = req_data.response_mode == "streaming"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
installed_app.last_used_at = naive_utc_now()
|
||||
@@ -173,7 +179,8 @@ class ChatApi(InstalledAppResource):
|
||||
@console_ns.response(200, "Success")
|
||||
@with_current_user
|
||||
@with_session
|
||||
def post(self, session: Session, current_user: Account, installed_app: InstalledApp):
|
||||
@model_validate(ChatMessagePayload)
|
||||
def post(self, req_data: ChatMessagePayload, session: Session, current_user: Account, installed_app: InstalledApp):
|
||||
app_model = installed_app.app_with_session(session=session)
|
||||
if app_model is None:
|
||||
raise AppUnavailableError()
|
||||
@@ -181,8 +188,7 @@ class ChatApi(InstalledAppResource):
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
payload = ChatMessagePayload.model_validate(console_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
args = req_data.model_dump(exclude_none=True)
|
||||
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
@@ -191,10 +197,10 @@ class ChatApi(InstalledAppResource):
|
||||
|
||||
try:
|
||||
# Eagerly validate conversation to avoid hanging on invalid conversation_id
|
||||
if payload.conversation_id:
|
||||
if req_data.conversation_id:
|
||||
ConversationService.get_conversation(
|
||||
app_model=app_model,
|
||||
conversation_id=payload.conversation_id,
|
||||
conversation_id=req_data.conversation_id,
|
||||
user=current_user,
|
||||
session=session,
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from controllers.common.schema import query_params_from_model, register_response
|
||||
from controllers.console.app.error import AppUnavailableError
|
||||
from controllers.console.explore.error import NotChatAppError
|
||||
from controllers.console.explore.wraps import InstalledAppResource
|
||||
from controllers.console.wraps import with_current_user
|
||||
from controllers.console.wraps import model_validate, with_current_user
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from extensions.ext_database import db
|
||||
from fields.conversation_fields import (
|
||||
@@ -133,7 +133,8 @@ class ConversationRenameApi(InstalledAppResource):
|
||||
@console_ns.expect(console_ns.models[ConversationRenamePayload.__name__])
|
||||
@console_ns.response(200, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
|
||||
@model_validate(ConversationRenamePayload)
|
||||
def post(self, req_data: ConversationRenamePayload, current_user: Account, installed_app: InstalledApp, c_id: UUID):
|
||||
app_model = installed_app.app_with_session(session=db.session())
|
||||
if app_model is None:
|
||||
raise AppUnavailableError()
|
||||
@@ -143,12 +144,10 @@ class ConversationRenameApi(InstalledAppResource):
|
||||
|
||||
conversation_id = str(c_id)
|
||||
|
||||
payload = ConversationRenamePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
session = db.session()
|
||||
conversation = ConversationService.rename(
|
||||
app_model, conversation_id, current_user, payload.name, payload.auto_generate, session=session
|
||||
app_model, conversation_id, current_user, req_data.name, req_data.auto_generate, session=session
|
||||
)
|
||||
return (
|
||||
TypeAdapter(SimpleConversation)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, computed_field, field_validator
|
||||
from sqlalchemy import and_, exists, or_, select
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
from sqlalchemy import and_, select
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
from controllers.common.fields import SimpleMessageResponse, SimpleResultMessageResponse
|
||||
@@ -15,6 +15,7 @@ from controllers.console.explore.wraps import InstalledAppResource
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_resource_check,
|
||||
model_validate,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
@@ -22,13 +23,12 @@ from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.file import helpers as file_helpers
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import to_timestamp
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account, App, AppModelConfig, InstalledApp, RecommendedApp, Workflow
|
||||
from models import Account, App, InstalledApp, RecommendedApp
|
||||
from models.model import AppMode, IconType
|
||||
from services.account_service import TenantService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
from services.installed_app_service import InstalledAppCursor, InstalledAppService
|
||||
|
||||
|
||||
class InstalledAppCreatePayload(BaseModel):
|
||||
@@ -41,65 +41,53 @@ class InstalledAppUpdatePayload(BaseModel):
|
||||
|
||||
class InstalledAppsListQuery(BaseModel):
|
||||
app_id: str | None = Field(default=None, description="App ID to filter by")
|
||||
name: str | None = Field(default=None, max_length=100, description="App name to search for")
|
||||
cursor: str | None = Field(default=None, description="Opaque cursor returned by the previous page")
|
||||
limit: int = Field(
|
||||
default=20,
|
||||
ge=1,
|
||||
le=100,
|
||||
description="Number of installed apps to return",
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_icon_url(icon_type: str | IconType | None, icon: str | None) -> str | None:
|
||||
def _build_icon_url(icon_type: IconType | None, icon: str | None) -> str | None:
|
||||
if icon is None or icon_type is None:
|
||||
return None
|
||||
icon_type_value = icon_type.value if isinstance(icon_type, IconType) else str(icon_type)
|
||||
if icon_type_value.lower() != IconType.IMAGE:
|
||||
if icon_type != IconType.IMAGE:
|
||||
return None
|
||||
return file_helpers.get_signed_file_url(icon)
|
||||
|
||||
|
||||
def _safe_primitive(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (str, int, float, bool, datetime)):
|
||||
return value
|
||||
return None
|
||||
def _encode_installed_app_cursor(cursor: InstalledAppCursor) -> str:
|
||||
payload = cursor.model_dump_json().encode()
|
||||
return base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
||||
|
||||
|
||||
def _published_app_filter():
|
||||
"""Return the SQL predicate for installed-app web API availability.
|
||||
def _decode_installed_app_cursor(cursor: str | None) -> InstalledAppCursor | None:
|
||||
if cursor is None:
|
||||
return None
|
||||
|
||||
The installed-app parameters endpoint reads the published workflow for
|
||||
workflow-style apps and the published app model config for easy UI apps.
|
||||
Keep the list endpoint aligned in SQL so it does not return entries that
|
||||
will immediately fail with app_unavailable when opened.
|
||||
"""
|
||||
workflow_app_modes = (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW)
|
||||
has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id))
|
||||
has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id))
|
||||
|
||||
return and_(
|
||||
App.mode != AppMode.AGENT,
|
||||
or_(
|
||||
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
|
||||
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
|
||||
),
|
||||
)
|
||||
try:
|
||||
padded_cursor = cursor + "=" * (-len(cursor) % 4)
|
||||
payload = base64.b64decode(padded_cursor, altchars=b"-_", validate=True)
|
||||
return InstalledAppCursor.model_validate_json(payload)
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||
raise BadRequest("Invalid cursor") from None
|
||||
|
||||
|
||||
class InstalledAppInfoResponse(ResponseModel):
|
||||
id: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
mode: str | None = None
|
||||
icon_type: str | None = None
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
use_icon_as_answer_icon: bool | None = None
|
||||
|
||||
@field_validator("mode", "icon_type", mode="before")
|
||||
@classmethod
|
||||
def _normalize_enum_like(cls, value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(getattr(value, "value", value))
|
||||
name: str
|
||||
description: str
|
||||
mode: AppMode
|
||||
icon_type: IconType | None
|
||||
icon: str | None
|
||||
icon_background: str | None
|
||||
use_icon_as_answer_icon: bool
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore[prop-decorator]
|
||||
@property
|
||||
@@ -112,34 +100,33 @@ class InstalledAppResponse(ResponseModel):
|
||||
app: InstalledAppInfoResponse
|
||||
app_owner_tenant_id: str
|
||||
is_pinned: bool
|
||||
last_used_at: int | None = None
|
||||
last_used_at: int | None
|
||||
editable: bool
|
||||
uninstallable: bool
|
||||
|
||||
@field_validator("app", mode="before")
|
||||
@classmethod
|
||||
def _normalize_app(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {
|
||||
"id": _safe_primitive(getattr(value, "id", "")) or "",
|
||||
"name": _safe_primitive(getattr(value, "name", None)),
|
||||
"description": _safe_primitive(getattr(value, "description", None)),
|
||||
"mode": _safe_primitive(getattr(value, "mode", None)),
|
||||
"icon_type": _safe_primitive(getattr(value, "icon_type", None)),
|
||||
"icon": _safe_primitive(getattr(value, "icon", None)),
|
||||
"icon_background": _safe_primitive(getattr(value, "icon_background", None)),
|
||||
"use_icon_as_answer_icon": _safe_primitive(getattr(value, "use_icon_as_answer_icon", None)),
|
||||
}
|
||||
|
||||
@field_validator("last_used_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class InstalledAppListResponse(ResponseModel):
|
||||
installed_apps: list[InstalledAppResponse]
|
||||
has_more: bool
|
||||
next_cursor: str | None
|
||||
|
||||
|
||||
def _installed_app_response_data(
|
||||
installed_app: InstalledApp,
|
||||
app_model: App,
|
||||
*,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
) -> InstalledAppResponse:
|
||||
return InstalledAppResponse(
|
||||
id=installed_app.id,
|
||||
app=InstalledAppInfoResponse.model_validate(app_model),
|
||||
app_owner_tenant_id=installed_app.app_owner_tenant_id,
|
||||
is_pinned=installed_app.is_pinned,
|
||||
last_used_at=to_timestamp(installed_app.last_used_at),
|
||||
editable=current_user.role in {"owner", "admin"},
|
||||
uninstallable=current_tenant_id == installed_app.app_owner_tenant_id,
|
||||
)
|
||||
|
||||
|
||||
register_schema_models(
|
||||
@@ -168,78 +155,40 @@ class InstalledAppsListApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
query = InstalledAppsListQuery.model_validate(request.args.to_dict())
|
||||
|
||||
stmt = (
|
||||
select(InstalledApp, App)
|
||||
.join(App, App.id == InstalledApp.app_id)
|
||||
.where(InstalledApp.tenant_id == current_tenant_id, _published_app_filter())
|
||||
)
|
||||
if query.app_id:
|
||||
stmt = stmt.where(InstalledApp.app_id == query.app_id)
|
||||
|
||||
installed_apps = db.session.execute(stmt).all()
|
||||
|
||||
cursor = _decode_installed_app_cursor(query.cursor)
|
||||
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, session=db.session())
|
||||
installed_app_list: list[dict[str, Any]] = []
|
||||
for installed_app, app_model in installed_apps:
|
||||
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:
|
||||
user_id = current_user.id
|
||||
app_ids = [installed_app["app"].id for installed_app in installed_app_list]
|
||||
webapp_settings = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids)
|
||||
|
||||
# Pre-filter out apps without setting or with sso_verified
|
||||
filtered_installed_apps = []
|
||||
|
||||
for installed_app in installed_app_list:
|
||||
app_id = installed_app["app"].id
|
||||
webapp_setting = webapp_settings.get(app_id)
|
||||
if not webapp_setting or webapp_setting.access_mode == "sso_verified":
|
||||
continue
|
||||
filtered_installed_apps.append(installed_app)
|
||||
|
||||
# Batch permission check
|
||||
app_ids = [installed_app["app"].id for installed_app in filtered_installed_apps]
|
||||
permissions = EnterpriseService.WebAppAuth.batch_is_user_allowed_to_access_webapps(
|
||||
user_id=user_id,
|
||||
app_ids=app_ids,
|
||||
)
|
||||
|
||||
# Keep only allowed apps
|
||||
res = []
|
||||
for installed_app in filtered_installed_apps:
|
||||
app_id = installed_app["app"].id
|
||||
if permissions.get(app_id):
|
||||
res.append(installed_app)
|
||||
|
||||
installed_app_list = res
|
||||
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, user_id)
|
||||
|
||||
installed_app_list.sort(
|
||||
key=lambda app: (
|
||||
-app["is_pinned"],
|
||||
app["last_used_at"] is None,
|
||||
-app["last_used_at"].timestamp() if app["last_used_at"] is not None else 0,
|
||||
)
|
||||
installed_apps, has_more, next_cursor = InstalledAppService.get_visible_page(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=str(current_user.id),
|
||||
cursor=cursor,
|
||||
limit=query.limit,
|
||||
app_id=query.app_id,
|
||||
name=query.name,
|
||||
session=db.session,
|
||||
)
|
||||
|
||||
return InstalledAppListResponse.model_validate(
|
||||
{"installed_apps": installed_app_list}, from_attributes=True
|
||||
).model_dump(mode="json")
|
||||
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
|
||||
installed_app_list = [
|
||||
_installed_app_response_data(
|
||||
installed_app,
|
||||
app_model,
|
||||
current_tenant_id=current_tenant_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
for installed_app, app_model in installed_apps
|
||||
]
|
||||
|
||||
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, current_user.id)
|
||||
return dump_response(
|
||||
InstalledAppListResponse,
|
||||
{
|
||||
"installed_apps": installed_app_list,
|
||||
"has_more": has_more,
|
||||
"next_cursor": _encode_installed_app_cursor(next_cursor) if next_cursor else None,
|
||||
},
|
||||
)
|
||||
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@@ -247,16 +196,15 @@ class InstalledAppsListApi(Resource):
|
||||
@console_ns.expect(console_ns.models[InstalledAppCreatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleMessageResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
payload = InstalledAppCreatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
@model_validate(InstalledAppCreatePayload)
|
||||
def post(self, req_data: InstalledAppCreatePayload, current_tenant_id: str):
|
||||
recommended_app = db.session.scalar(
|
||||
select(RecommendedApp).where(RecommendedApp.app_id == payload.app_id).limit(1)
|
||||
select(RecommendedApp).where(RecommendedApp.app_id == req_data.app_id).limit(1)
|
||||
)
|
||||
if recommended_app is None:
|
||||
raise NotFound("Recommended app not found")
|
||||
|
||||
app = db.session.get(App, payload.app_id)
|
||||
app = db.session.get(App, req_data.app_id)
|
||||
|
||||
if app is None:
|
||||
raise NotFound("App entity not found")
|
||||
@@ -266,7 +214,7 @@ class InstalledAppsListApi(Resource):
|
||||
|
||||
installed_app = db.session.scalar(
|
||||
select(InstalledApp)
|
||||
.where(and_(InstalledApp.app_id == payload.app_id, InstalledApp.tenant_id == current_tenant_id))
|
||||
.where(and_(InstalledApp.app_id == req_data.app_id, InstalledApp.tenant_id == current_tenant_id))
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@@ -275,7 +223,7 @@ class InstalledAppsListApi(Resource):
|
||||
recommended_app.install_count += 1
|
||||
|
||||
new_installed_app = InstalledApp(
|
||||
app_id=payload.app_id,
|
||||
app_id=req_data.app_id,
|
||||
tenant_id=current_tenant_id,
|
||||
app_owner_tenant_id=app.tenant_id,
|
||||
is_pinned=False,
|
||||
@@ -290,10 +238,36 @@ class InstalledAppsListApi(Resource):
|
||||
@console_ns.route("/installed-apps/<uuid:installed_app_id>")
|
||||
class InstalledAppApi(InstalledAppResource):
|
||||
"""
|
||||
update and delete an installed app
|
||||
get, update, and delete an installed app
|
||||
use InstalledAppResource to apply default decorators and get installed_app
|
||||
"""
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[InstalledAppResponse.__name__])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(
|
||||
self,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
installed_app: InstalledApp,
|
||||
):
|
||||
app_model = InstalledAppService.get_published_app(installed_app.app_id, session=db.session)
|
||||
if app_model is None:
|
||||
raise NotFound("Installed app not found")
|
||||
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, session=db.session())
|
||||
return dump_response(
|
||||
InstalledAppResponse,
|
||||
_installed_app_response_data(
|
||||
installed_app,
|
||||
app_model,
|
||||
current_tenant_id=current_tenant_id,
|
||||
current_user=current_user,
|
||||
),
|
||||
)
|
||||
|
||||
@console_ns.response(204, "App uninstalled successfully")
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, installed_app: InstalledApp):
|
||||
@@ -307,12 +281,11 @@ class InstalledAppApi(InstalledAppResource):
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
|
||||
@console_ns.expect(console_ns.models[InstalledAppUpdatePayload.__name__])
|
||||
def patch(self, installed_app: InstalledApp):
|
||||
payload = InstalledAppUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
@model_validate(InstalledAppUpdatePayload)
|
||||
def patch(self, req_data: InstalledAppUpdatePayload, installed_app: InstalledApp):
|
||||
commit_args = False
|
||||
if payload.is_pinned is not None:
|
||||
installed_app.is_pinned = payload.is_pinned
|
||||
if req_data.is_pinned is not None:
|
||||
installed_app.is_pinned = req_data.is_pinned
|
||||
commit_args = True
|
||||
|
||||
if commit_args:
|
||||
|
||||
@@ -24,7 +24,7 @@ from controllers.console.explore.error import (
|
||||
NotCompletionAppError,
|
||||
)
|
||||
from controllers.console.explore.wraps import InstalledAppResource
|
||||
from controllers.console.wraps import with_current_user
|
||||
from controllers.console.wraps import model_validate, with_current_user
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
@@ -119,22 +119,23 @@ class MessageFeedbackApi(InstalledAppResource):
|
||||
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
|
||||
@console_ns.response(200, "Feedback submitted successfully", console_ns.models[ResultResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, installed_app: InstalledApp, message_id: UUID):
|
||||
@model_validate(MessageFeedbackPayload)
|
||||
def post(
|
||||
self, req_data: MessageFeedbackPayload, current_user: Account, installed_app: InstalledApp, message_id: UUID
|
||||
):
|
||||
app_model = installed_app.app_with_session(session=db.session())
|
||||
if app_model is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
message_id_str = str(message_id)
|
||||
|
||||
payload = MessageFeedbackPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
MessageService.create_feedback(
|
||||
app_model=app_model,
|
||||
message_id=message_id_str,
|
||||
user=current_user,
|
||||
rating=FeedbackRating(payload.rating) if payload.rating else None,
|
||||
content=payload.content,
|
||||
rating=FeedbackRating(req_data.rating) if req_data.rating else None,
|
||||
content=req_data.content,
|
||||
session=db.session(),
|
||||
)
|
||||
except MessageNotExistsError:
|
||||
|
||||
@@ -10,7 +10,7 @@ from controllers.console import console_ns
|
||||
from controllers.console.app.error import AppUnavailableError
|
||||
from controllers.console.explore.error import NotCompletionAppError
|
||||
from controllers.console.explore.wraps import InstalledAppResource
|
||||
from controllers.console.wraps import with_current_user
|
||||
from controllers.console.wraps import model_validate, with_current_user
|
||||
from extensions.ext_database import db
|
||||
from fields.conversation_fields import MessageResponseSource, ResultResponse
|
||||
from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem
|
||||
@@ -55,17 +55,16 @@ class SavedMessageListApi(InstalledAppResource):
|
||||
@console_ns.expect(console_ns.models[SavedMessageCreatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, installed_app: InstalledApp):
|
||||
@model_validate(SavedMessageCreatePayload)
|
||||
def post(self, req_data: SavedMessageCreatePayload, current_user: Account, installed_app: InstalledApp):
|
||||
app_model = installed_app.app_with_session(session=db.session())
|
||||
if app_model is None:
|
||||
raise AppUnavailableError()
|
||||
if app_model.mode != "completion":
|
||||
raise NotCompletionAppError()
|
||||
|
||||
payload = SavedMessageCreatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
SavedMessageService.save(app_model, current_user, str(payload.message_id), session=db.session())
|
||||
SavedMessageService.save(app_model, current_user, str(req_data.message_id), session=db.session())
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ from core.errors.error import (
|
||||
ProviderTokenNotInitError,
|
||||
QuotaExceededError,
|
||||
)
|
||||
from core.helper import encrypter
|
||||
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from fields.base import ResponseModel
|
||||
@@ -67,6 +69,7 @@ from fields.file_fields import FileResponse, FileWithSignedUrl
|
||||
from fields.message_fields import SuggestedQuestionsResponse
|
||||
from graphon.graph_engine.manager import GraphEngineManager
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from graphon.variables import SecretVariable, VariableBase
|
||||
from libs import helper
|
||||
from libs.helper import dump_response, to_timestamp, uuid_value
|
||||
from models import Account, App
|
||||
@@ -385,6 +388,26 @@ class TrialWorkflowResponse(ResponseModel):
|
||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||
return to_timestamp(value)
|
||||
|
||||
@field_validator("environment_variables", mode="before")
|
||||
@classmethod
|
||||
def _serialize_environment_variables(cls, value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
result: list[Any] = []
|
||||
for item in value:
|
||||
if isinstance(item, SecretVariable):
|
||||
serialized = item.model_dump(mode="json")
|
||||
serialized["value"] = encrypter.full_mask_token()
|
||||
result.append(serialized)
|
||||
elif isinstance(item, LLMEnvironmentVariable):
|
||||
result.append(dump_environment_variable(item, mode="json"))
|
||||
elif isinstance(item, VariableBase):
|
||||
result.append(item.model_dump(mode="json"))
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrialWorkflowResponseSource:
|
||||
@@ -404,7 +427,7 @@ class TrialWorkflowResponseSource:
|
||||
return self.workflow.get_tool_published(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
register_schema_models(
|
||||
|
||||
@@ -15,7 +15,7 @@ from controllers.console.app.error import (
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.console.explore.error import NotWorkflowAppError
|
||||
from controllers.console.explore.wraps import InstalledAppResource
|
||||
from controllers.console.wraps import with_current_user
|
||||
from controllers.console.wraps import model_validate, with_current_user
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@@ -47,7 +47,14 @@ class InstalledAppWorkflowRunApi(InstalledAppResource):
|
||||
@console_ns.response(200, "Success")
|
||||
@with_current_user
|
||||
@with_session
|
||||
def post(self, session: Session, current_user: Account, installed_app: InstalledApp):
|
||||
@model_validate(WorkflowRunPayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: WorkflowRunPayload,
|
||||
session: Session,
|
||||
current_user: Account,
|
||||
installed_app: InstalledApp,
|
||||
):
|
||||
"""
|
||||
Run workflow
|
||||
"""
|
||||
@@ -58,8 +65,7 @@ class InstalledAppWorkflowRunApi(InstalledAppResource):
|
||||
if app_mode != AppMode.WORKFLOW:
|
||||
raise NotWorkflowAppError()
|
||||
|
||||
payload = WorkflowRunPayload.model_validate(console_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
args = req_data.model_dump(exclude_none=True)
|
||||
try:
|
||||
response = AppGenerateService.generate(
|
||||
session=session,
|
||||
|
||||
@@ -114,17 +114,6 @@ def trial_feature_enable[**P, R](view: Callable[P, R]):
|
||||
return decorated
|
||||
|
||||
|
||||
def explore_banner_enabled[**P, R](view: Callable[P, R]):
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.enable_explore_banner:
|
||||
abort(403, "Explore banner feature is not enabled.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
class InstalledAppResource(Resource):
|
||||
# must be reversed if there are multiple decorators
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from services.code_based_extension_service import CodeBasedExtensionService
|
||||
|
||||
from ..common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from . import console_ns
|
||||
from .wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from .wraps import account_initialization_required, model_validate, setup_required, with_current_tenant_id
|
||||
|
||||
|
||||
class CodeBasedExtensionQuery(BaseModel):
|
||||
@@ -123,14 +123,13 @@ class APIBasedExtensionAPI(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
payload = APIBasedExtensionPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
@model_validate(APIBasedExtensionPayload)
|
||||
def post(self, req_data: APIBasedExtensionPayload, current_tenant_id: str):
|
||||
extension_data = APIBasedExtension(
|
||||
tenant_id=current_tenant_id,
|
||||
name=payload.name,
|
||||
api_endpoint=payload.api_endpoint,
|
||||
api_key=payload.api_key,
|
||||
name=req_data.name,
|
||||
api_endpoint=req_data.api_endpoint,
|
||||
api_key=req_data.api_key,
|
||||
)
|
||||
|
||||
extension = APIBasedExtensionService.save(extension_data, session=db.session())
|
||||
@@ -138,7 +137,7 @@ class APIBasedExtensionAPI(Resource):
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
api_endpoint=extension.api_endpoint,
|
||||
api_key=payload.api_key,
|
||||
api_key=req_data.api_key,
|
||||
created_at=to_timestamp(extension.created_at),
|
||||
).model_dump(mode="json"), 201
|
||||
|
||||
@@ -172,22 +171,22 @@ class APIBasedExtensionDetailAPI(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, id: UUID):
|
||||
@model_validate(APIBasedExtensionPayload)
|
||||
def post(self, req_data: APIBasedExtensionPayload, current_tenant_id: str, id: UUID):
|
||||
api_based_extension_id = str(id)
|
||||
|
||||
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(
|
||||
current_tenant_id, api_based_extension_id, session=db.session()
|
||||
)
|
||||
|
||||
payload = APIBasedExtensionPayload.model_validate(console_ns.payload or {})
|
||||
api_key_for_response = extension_data_from_db.api_key
|
||||
|
||||
extension_data_from_db.name = payload.name
|
||||
extension_data_from_db.api_endpoint = payload.api_endpoint
|
||||
extension_data_from_db.name = req_data.name
|
||||
extension_data_from_db.api_endpoint = req_data.api_endpoint
|
||||
|
||||
if payload.api_key != HIDDEN_VALUE:
|
||||
extension_data_from_db.api_key = payload.api_key
|
||||
api_key_for_response = payload.api_key
|
||||
if req_data.api_key != HIDDEN_VALUE:
|
||||
extension_data_from_db.api_key = req_data.api_key
|
||||
api_key_for_response = req_data.api_key
|
||||
|
||||
APIBasedExtensionService.save(extension_data_from_db, session=db.session())
|
||||
return APIBasedExtensionResponse(
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from services.feature_service import (
|
||||
from machinery.context import RequestContext
|
||||
from services.entities.feature_entities import (
|
||||
FeatureModel,
|
||||
FeatureService,
|
||||
LicenseModel,
|
||||
LimitationModel,
|
||||
SystemFeatureModel,
|
||||
VectorSpaceLimitationModel,
|
||||
)
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import (
|
||||
account_initialization_required,
|
||||
cloud_utm_record,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
)
|
||||
from .wraps import cloud_utm_record
|
||||
|
||||
|
||||
class TrialModelsResponse(ResponseModel):
|
||||
@@ -37,6 +34,7 @@ register_response_schema_models(
|
||||
LimitationModel,
|
||||
SystemFeatureModel,
|
||||
TrialModelsResponse,
|
||||
VectorSpaceLimitationModel,
|
||||
)
|
||||
|
||||
|
||||
@@ -49,17 +47,11 @@ class FeatureApi(Resource):
|
||||
"Success",
|
||||
console_ns.models[FeatureModel.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_account_admission()
|
||||
@cloud_utm_record
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
def get(self, request_context: RequestContext):
|
||||
"""Get feature configuration for current tenant"""
|
||||
payload = FeatureService.get_features(
|
||||
current_tenant_id,
|
||||
exclude_vector_space=True,
|
||||
).model_dump()
|
||||
payload = application_services().feature_queries.get_features(request_context).model_dump()
|
||||
payload.pop("vector_space", None)
|
||||
return payload
|
||||
|
||||
@@ -71,16 +63,13 @@ class FeatureVectorSpaceApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Success",
|
||||
console_ns.models[LimitationModel.__name__],
|
||||
console_ns.models[VectorSpaceLimitationModel.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_account_admission()
|
||||
@cloud_utm_record
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
def get(self, request_context: RequestContext):
|
||||
"""Get vector-space usage and limit for current tenant"""
|
||||
return FeatureService.get_vector_space(current_tenant_id).model_dump()
|
||||
return application_services().feature_queries.get_vector_space(request_context).model_dump()
|
||||
|
||||
|
||||
@console_ns.route("/trial-models")
|
||||
@@ -92,14 +81,12 @@ class TrialModelsApi(Resource):
|
||||
"Success",
|
||||
console_ns.models[TrialModelsResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
@console_account_admission()
|
||||
def get(self, _request_context: RequestContext):
|
||||
"""Get hosted trial model provider configuration for model-provider pages."""
|
||||
return dump_response(
|
||||
TrialModelsResponse,
|
||||
{"trial_models": FeatureService.get_trial_models()},
|
||||
{"trial_models": application_services().feature_queries.get_trial_models()},
|
||||
)
|
||||
|
||||
|
||||
@@ -116,7 +103,7 @@ class AppDslVersionApi(Resource):
|
||||
"""Get current app DSL version for workflow clipboard compatibility."""
|
||||
return dump_response(
|
||||
AppDslVersionResponse,
|
||||
{"app_dsl_version": FeatureService.get_app_dsl_version()},
|
||||
{"app_dsl_version": application_services().feature_queries.get_app_dsl_version()},
|
||||
)
|
||||
|
||||
|
||||
@@ -138,7 +125,7 @@ class SystemFeatureApi(Resource):
|
||||
Authentication configuration must be available before the authentication flow can be selected.
|
||||
Authenticated license detail is served separately by SystemFeatureLicenseApi.
|
||||
"""
|
||||
return dump_response(SystemFeatureModel, FeatureService.get_system_features())
|
||||
return dump_response(SystemFeatureModel, application_services().feature_queries.get_system_features())
|
||||
|
||||
|
||||
@console_ns.route("/system-features/license")
|
||||
@@ -150,13 +137,11 @@ class SystemFeatureLicenseApi(Resource):
|
||||
"Success",
|
||||
console_ns.models[LicenseModel.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
@console_account_admission()
|
||||
def get(self, _request_context: RequestContext):
|
||||
"""Get full license detail (status, expiry, workspace/seat usage).
|
||||
|
||||
Authenticated counterpart to the license *status* exposed on the public
|
||||
system-features endpoint.
|
||||
"""
|
||||
return FeatureService.get_license().model_dump()
|
||||
return application_services().feature_queries.get_license().model_dump()
|
||||
|
||||
@@ -30,6 +30,7 @@ from fields.file_fields import FileResponse, UploadConfig
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account, UploadFile
|
||||
from services.feature_service import FeatureService
|
||||
from services.file_service import FileService
|
||||
|
||||
from . import console_ns
|
||||
@@ -58,7 +59,7 @@ FILE_UPLOAD_PARAMS = {
|
||||
|
||||
def upload_file_from_request(*, current_user: Account, resource_tenant_id: str | None = None) -> UploadFile:
|
||||
"""Validate the multipart request and persist the file under the requested resource tenant."""
|
||||
source_str = request.form.get("source")
|
||||
source_str = request.args.get("source") or request.form.get("source")
|
||||
source: Literal["datasets"] | None = "datasets" if source_str == "datasets" else None
|
||||
|
||||
if "file" not in request.files:
|
||||
@@ -76,6 +77,12 @@ def upload_file_from_request(*, current_user: Account, resource_tenant_id: str |
|
||||
if source not in ("datasets", None):
|
||||
source = None
|
||||
|
||||
default_file_size_limit = (
|
||||
FeatureService.get_knowledge_file_size_limit(resource_tenant_id or current_user.current_tenant_id)
|
||||
if source == "datasets"
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
return FileService(db.engine).upload_file(
|
||||
filename=file.filename,
|
||||
@@ -84,6 +91,7 @@ def upload_file_from_request(*, current_user: Account, resource_tenant_id: str |
|
||||
user=current_user,
|
||||
tenant_id=resource_tenant_id,
|
||||
source=source,
|
||||
default_file_size_limit=default_file_size_limit,
|
||||
)
|
||||
except services.errors.file.FileTooLargeError as file_too_large_error:
|
||||
raise FileTooLargeError(file_too_large_error.description)
|
||||
@@ -99,14 +107,17 @@ class FileApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[UploadConfig.__name__])
|
||||
def get(self):
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
config = UploadConfig(
|
||||
file_size_limit=dify_config.UPLOAD_FILE_SIZE_LIMIT,
|
||||
knowledge_file_size_limit=FeatureService.get_knowledge_file_size_limit(current_tenant_id),
|
||||
batch_count_limit=dify_config.UPLOAD_FILE_BATCH_LIMIT,
|
||||
file_upload_limit=dify_config.BATCH_UPLOAD_LIMIT,
|
||||
image_file_size_limit=dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT,
|
||||
video_file_size_limit=dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT,
|
||||
audio_file_size_limit=dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT,
|
||||
skill_file_size_limit=dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT,
|
||||
workflow_file_upload_limit=dify_config.WORKFLOW_FILE_UPLOAD_LIMIT,
|
||||
image_file_batch_limit=dify_config.IMAGE_FILE_BATCH_LIMIT,
|
||||
single_chunk_attachment_limit=dify_config.SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
|
||||
64
api/controllers/console/flask_admission.py
Normal file
64
api/controllers/console/flask_admission.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Flask adapter for Console API admission."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Concatenate
|
||||
|
||||
from flask import Response, abort, request
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
|
||||
from core.logging.context import get_request_id, get_trace_id
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from machinery.context import RequestContext
|
||||
|
||||
|
||||
def console_account_admission[T, **P, R](
|
||||
*,
|
||||
editions: frozenset[DeploymentEdition] | None = None,
|
||||
require_valid_enterprise_license: bool = False,
|
||||
) -> Callable[
|
||||
[Callable[Concatenate[T, RequestContext, P], R]],
|
||||
Callable[Concatenate[T, P], R | Response],
|
||||
]:
|
||||
"""Declare Console account admission and inject a stable RequestContext.
|
||||
|
||||
All combinations use this decorator factory. Requirements are data, while
|
||||
the execution order stays fixed: edition, setup, login/CSRF, account
|
||||
initialization, optional enterprise license, then context construction.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
view: Callable[Concatenate[T, RequestContext, P], R],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
@wraps(view)
|
||||
def inject_request_context(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
account_with_tenant = current_account_with_tenant()
|
||||
request_context = RequestContext(
|
||||
account_id=account_with_tenant.account.id,
|
||||
active_workspace_id=account_with_tenant.tenant_id,
|
||||
request_id=get_request_id(),
|
||||
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
|
||||
)
|
||||
return view(self, request_context, *args, **kwargs)
|
||||
|
||||
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
|
||||
if require_valid_enterprise_license:
|
||||
admitted = enterprise_license_required(admitted)
|
||||
admitted = account_initialization_required(admitted)
|
||||
admitted = login_required(admitted)
|
||||
admitted = setup_required(admitted)
|
||||
|
||||
if editions is None:
|
||||
return admitted
|
||||
|
||||
@wraps(view)
|
||||
def enforce_edition(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
|
||||
if dify_config.DEPLOYMENT_EDITION not in editions:
|
||||
abort(404)
|
||||
return admitted(self, *args, **kwargs)
|
||||
|
||||
return enforce_edition
|
||||
|
||||
return decorator
|
||||
@@ -137,7 +137,7 @@ class ConsoleHumanInputFormApi(Resource):
|
||||
self._ensure_console_access(form, current_tenant_id)
|
||||
self._ensure_console_recipient_type(form)
|
||||
recipient_type = form.recipient_type
|
||||
# The type checker is not smart enought to validate the following invariant.
|
||||
# The type checker is not smart enough to validate the following invariant.
|
||||
# So we need to assert it manually.
|
||||
assert recipient_type is not None, "recipient_type cannot be None here."
|
||||
|
||||
@@ -215,6 +215,7 @@ class ConsoleWorkflowEventsApi(Resource):
|
||||
raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}")
|
||||
|
||||
include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true"
|
||||
continue_on_pause = request.args.get("continue_on_pause", "false").lower() == "true"
|
||||
|
||||
def _generate_stream_events():
|
||||
if include_state_snapshot:
|
||||
@@ -225,6 +226,8 @@ class ConsoleWorkflowEventsApi(Resource):
|
||||
tenant_id=workflow_run.tenant_id,
|
||||
app_id=workflow_run.app_id,
|
||||
session_maker=session_maker,
|
||||
human_input_surface=HumanInputSurface.CONSOLE,
|
||||
close_on_pause=not continue_on_pause,
|
||||
)
|
||||
)
|
||||
return generator.convert_to_event_stream(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import TypedDict
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -10,6 +9,7 @@ from controllers.common.schema import register_response_schema_models, register_
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
only_edition_cloud,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
@@ -141,8 +141,8 @@ class NotificationDismissApi(Resource):
|
||||
@only_edition_cloud
|
||||
@console_ns.expect(console_ns.models[DismissNotificationPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
def post(self, current_user: Account):
|
||||
payload = DismissNotificationPayload.model_validate(request.get_json())
|
||||
@model_validate(DismissNotificationPayload)
|
||||
def post(self, payload: DismissNotificationPayload, current_user: Account):
|
||||
BillingService.dismiss_notification(
|
||||
notification_id=payload.notification_id,
|
||||
account_id=str(current_user.id),
|
||||
|
||||
@@ -21,7 +21,13 @@ from models import Account
|
||||
from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import account_initialization_required, setup_required, with_current_tenant_id, with_current_user
|
||||
from .wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
|
||||
StepByStepTourAction = Literal[
|
||||
"skip",
|
||||
@@ -92,9 +98,9 @@ class StepByStepTourStateApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def patch(self, current_tenant_id: str, current_user: Account):
|
||||
payload = StepByStepTourStatePatchPayload.model_validate(console_ns.payload or {})
|
||||
patch = cast(StepByStepTourPatch, payload.model_dump(exclude_unset=True, exclude_none=True))
|
||||
@model_validate(StepByStepTourStatePatchPayload)
|
||||
def patch(self, req_data: StepByStepTourStatePatchPayload, current_tenant_id: str, current_user: Account):
|
||||
patch = cast(StepByStepTourPatch, req_data.model_dump(exclude_unset=True, exclude_none=True))
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.patch_state(
|
||||
|
||||
@@ -2,15 +2,16 @@ from typing import Literal
|
||||
|
||||
from flask import request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.fastopenapi import console_router
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import EmailStr, extract_remote_ip
|
||||
from libs.password import valid_password
|
||||
from models.model import DifySetup, db
|
||||
from services.account_service import RegisterService, TenantService
|
||||
from services.setup_service import (
|
||||
InitializationValidationRequiredError,
|
||||
SetupAlreadyCompletedError,
|
||||
SetupInput,
|
||||
)
|
||||
|
||||
from .error import AlreadySetupError, NotInitValidateError
|
||||
from .init_validate import get_init_validate_status
|
||||
@@ -53,14 +54,12 @@ def get_setup_status_api() -> SetupStatusResponse:
|
||||
|
||||
Only bootstrap-safe status information should be returned by this endpoint.
|
||||
"""
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
|
||||
setup_status = get_setup_status()
|
||||
if setup_status and not isinstance(setup_status, bool):
|
||||
return SetupStatusResponse(step="finished", setup_at=setup_status.setup_at.isoformat())
|
||||
if setup_status:
|
||||
return SetupStatusResponse(step="finished")
|
||||
setup_status = application_services().setup.get_status()
|
||||
if not setup_status.completed:
|
||||
return SetupStatusResponse(step="not_started")
|
||||
return SetupStatusResponse(step="finished")
|
||||
|
||||
setup_at = setup_status.setup_at.isoformat() if setup_status.setup_at is not None else None
|
||||
return SetupStatusResponse(step="finished", setup_at=setup_at)
|
||||
|
||||
|
||||
@console_router.post(
|
||||
@@ -77,33 +76,22 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
|
||||
Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
|
||||
and init-password validation rather than user session authentication.
|
||||
"""
|
||||
if get_setup_status():
|
||||
raise AlreadySetupError()
|
||||
try:
|
||||
application_services().setup.initialize(
|
||||
SetupInput(
|
||||
email=payload.email,
|
||||
name=payload.name,
|
||||
password=payload.password,
|
||||
ip_address=extract_remote_ip(request),
|
||||
language=payload.language,
|
||||
),
|
||||
initialization_validated=get_init_validate_status(),
|
||||
)
|
||||
except SetupAlreadyCompletedError:
|
||||
raise AlreadySetupError() from None
|
||||
except InitializationValidationRequiredError:
|
||||
raise NotInitValidateError() from None
|
||||
|
||||
tenant_count = TenantService.get_tenant_count(session=db.session())
|
||||
if tenant_count > 0:
|
||||
raise AlreadySetupError()
|
||||
|
||||
if not get_init_validate_status():
|
||||
raise NotInitValidateError()
|
||||
|
||||
normalized_email = payload.email.lower()
|
||||
|
||||
RegisterService.setup(
|
||||
email=normalized_email,
|
||||
name=payload.name,
|
||||
password=payload.password,
|
||||
ip_address=extract_remote_ip(request),
|
||||
language=payload.language,
|
||||
session=db.session(),
|
||||
)
|
||||
mark_setup_completed()
|
||||
|
||||
return SetupResponse(result="success")
|
||||
|
||||
|
||||
def get_setup_status() -> DifySetup | bool | None:
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
|
||||
return db.session.scalar(select(DifySetup).limit(1))
|
||||
|
||||
return True
|
||||
|
||||
@@ -56,10 +56,12 @@ from libs.helper import TimestampField
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import Account
|
||||
from models.snippet import CustomizedSnippet
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.snippet_generate_service import SnippetGenerateService
|
||||
from services.snippet_service import SnippetService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -295,8 +297,9 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
|
||||
with Session(db.engine) as session:
|
||||
snippet = session.merge(snippet)
|
||||
tenant_id = snippet.tenant_id
|
||||
try:
|
||||
workflow = snippet_service.publish_workflow(
|
||||
workflow, retirement_candidates = snippet_service.publish_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account=current_user,
|
||||
@@ -306,6 +309,16 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
|
||||
@@ -40,6 +40,7 @@ from controllers.console.wraps import (
|
||||
with_current_user,
|
||||
)
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.workflow.llm_environment_variable import environment_variable_value_type
|
||||
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||
from extensions.ext_database import db
|
||||
from factories.file_factory import build_from_mapping, build_from_mappings
|
||||
@@ -329,7 +330,7 @@ class SnippetEnvironmentVariableCollectionApi(Resource):
|
||||
"name": v.name,
|
||||
"description": v.description,
|
||||
"selector": v.selector,
|
||||
"value_type": v.value_type.exposed_type().value,
|
||||
"value_type": environment_variable_value_type(v),
|
||||
"value": v.value,
|
||||
"edited": False,
|
||||
"visible": True,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user