Compare commits

...

279 Commits

Author SHA1 Message Date
yyh
4929a67885 refactor(web): simplify the member selector component
Use a typed function declaration instead of React.FC and remove the redundant React namespace import. Preserve the component API and behavior without mixing this style cleanup into the following accessibility layer.
2026-08-09 12:08:50 +08:00
medimedi
e036075e22 chore: Remove redundant dataset context string conversions (#40189) 2026-08-08 18:26:52 +00:00
Asuka Minato
5a84cde198 test: move RAG pipeline workflow coverage to unit tests (#38937) 2026-08-08 12:00:32 +00:00
Byron.wang
4e18ab0ad4 refactor(api): decouple setup and spec endpoints (#40104) 2026-08-08 11:52:15 +00:00
chelsealong
2a742486c5 fix(agent-v2): accept composer UI ids on manual metadata filter conditions (#40171)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-08 06:55:27 +00:00
Likalikali
d362bdbb4e refactor: replace manual model_validate with @model_validate decorator in app controllers (#40170) 2026-08-08 05:47:11 +00:00
Likalikali
60d6be5b13 chore: dep inject for model in explore workflow run (#40158) 2026-08-08 02:50:27 +00:00
Likalikali
cc1b55aaf7 chore: dep inject for model in explore apis (#40160)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-08 02:23:17 +00:00
yyh
d1fa17032e revert(web): use suspense boundary in home page (#40167) 2026-08-07 18:50:33 +00:00
盐粒 Yanli
916d7e1c96 refactor(dify-agent): download Binding files through Agent Stub (#40143)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-07 14:56:52 +00:00
Likalikali
c094117ca2 chore: dep inject for model in agent app sandbox upload (#40153) 2026-08-07 13:28:31 +00:00
Likalikali
c6b1311aa3 chore: dep inject for model in workspace snippets, load balancing, and endpoint (#40161) 2026-08-07 13:22:02 +00:00
Likalikali
515c3d0c62 chore: dep inject for model in onboarding step by step tour (#40155) 2026-08-07 12:41:22 +00:00
Likalikali
fe94ddaaeb chore: dep inject for model in workflow run archive download (#40157) 2026-08-07 12:40:44 +00:00
Likalikali
3e8c0fdcef chore: dep inject for model in website crawl api (#40156) 2026-08-07 12:40:12 +00:00
Likalikali
055462fe6e chore: dep inject for model in tags, mcp server, and data source auth (#40159) 2026-08-07 12:38:29 +00:00
Likalikali
06e229e8b2 chore: dep inject for model in api based extension (#40154) 2026-08-07 12:37:50 +00:00
Likalikali
fa498ad7a2 chore: dep inject for model in oauth server (#40147) 2026-08-07 11:16:11 +00:00
Likalikali
a279be91a8 chore: dep inject for model in datasets document and web human input … (#40151) 2026-08-07 11:15:43 +00:00
盐粒 Yanli
12300f3833 feat(dify-agent): propagate run limit error types (#40116)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-07 10:37:22 +00:00
Joel
b439b6a800 feat: support accessibility checks in CI (#40137) 2026-08-07 10:28:44 +00:00
github-actions[bot]
7a7d8fe19e chore(i18n): sync translations with en-US (#40150)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-08-07 10:10:33 +00:00
Joel
2635d1b3df fix: improve readability of DSL import warnings (#40148) 2026-08-07 10:01:09 +00:00
yyh
a952487789 chore(web): remove unreachable explore sidebar (#40136) 2026-08-07 09:29:44 +00:00
WH-2099
c9e6db5ef2 fix(api): scope temporary capabilities to owners (#39863) 2026-08-07 09:21:13 +00:00
Likalikali
b31e66107c chore: dep inject for model in notification dismiss (#40146) 2026-08-07 09:02:49 +00:00
Joel
42d6589ce2 fix: show inline agent build draft change details (#40140) 2026-08-07 07:28:41 +00:00
yyh
f00d0329d4 perf(web): prefetch app links on intent (#40133)
Co-authored-by: 非法操作 <hjlarry@163.com>
2026-08-07 07:16:38 +00:00
yyh
af9d2d00a3 chore(web): add dedicated home loading boundary (#40135) 2026-08-07 07:16:25 +00:00
非法操作
ae8cb996c7 chore: use multi-arch plugin daemon image tag (#40139) 2026-08-07 07:01:06 +00:00
盐粒 Yanli
adb89a8926 refactor(dify-agent): make config pulls direct and remove gRPC (#40044) 2026-08-07 06:54:07 +00:00
非法操作
3a4920c595 chore: bump plugin daemon image (#40134) 2026-08-07 06:22:33 +00:00
yyh
276c3b6e02 refactor(web): localize education status query (#40132)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-07 05:29:31 +00:00
Mubelotix
7ff5c85796 docs: Fixes Star History chart (#40115) 2026-08-07 05:23:22 +00:00
yyh
b8330df96e refactor(web): simplify billing portal link (#40131) 2026-08-07 04:26:18 +00:00
yyh
7422052759 fix(web): scope hidden outlines to focus-visible (#40130) 2026-08-07 04:00:19 +00:00
Xiyuan Chen
ab0e131e87 refactor(api): move license expiry notice flag onto license detail (#40128)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-07 03:53:35 +00:00
Asuka Minato
1375dc3864 test: migrate console app sessions to SQLite (#40081)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-07 03:46:27 +00:00
Asuka Minato
6530f2bcdb test: migrate document indexing task sessions to SQLite (#40092)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-07 03:45:56 +00:00
Asuka Minato
7039217c99 test: use sqlite sessions at generation boundaries (#40095) 2026-08-07 03:45:27 +00:00
yyh
924d787e41 fix(web): enforce frame ancestors on protected routes (#40124)
Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
2026-08-07 03:11:30 +00:00
非法操作
baba36d47d chore: improve current workspace summary (#40121)
Co-authored-by: Jingyi-Dify <jingyi.qi@dify.ai>
2026-08-07 03:08:55 +00:00
Xiyuan Chen
4b2c5ac4ae feat: make the console license expiry badge optional (#39972)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-07 02:28:58 +00:00
yyh
73197e4354 refactor(web): modernize Studio app list data flow (#40109) 2026-08-07 02:23:38 +00:00
Jingyi
5dfd83e9ab feat: optimize integrations initial loading (#40118)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-07 02:06:25 +00:00
yyh
6f2e4d72d3 test(e2e): align duplicate Agent publish state (#40108) 2026-08-07 01:56:05 +00:00
Byron.wang
b76a14d71d refactor(api): decouple console queries through application services (#40031) 2026-08-07 00:42:55 +00:00
Yunlu Wen
1983e842ca feat: support azure keyvault (#39933)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-08-07 00:42:13 +00:00
zyssyz123
90d6046345 fix: gate Agent access until first publish (#40105)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-08-06 12:36:24 +00:00
盐粒 Yanli
05c7386013 fix(dify-agent): end SSE streams on terminal events (#40042) 2026-08-06 10:53:10 +00:00
盐粒 Yanli
4d0bc32dd3 fix(dify-agent): align agent and shell execution limits (#40043)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-06 10:53:03 +00:00
yyh
d6336c5d49 revert(web): remove redundant slash picker intent tracking (#40103) 2026-08-06 09:20:46 +00:00
yyh
7073baa737 fix(ci): switch Playwright jobs to Azure Ubuntu mirror (#40100) 2026-08-06 09:01:37 +00:00
Asuka Minato
78129cf6c3 test: use SQLite sessions in datasource files (#39048) 2026-08-06 08:28:27 +00:00
Asuka Minato
1a69435fff test: use sqlite3 session in test_external_dataset_service (#38696)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-06 08:27:37 +00:00
yyh
33b238343b chore: remove obsolete development docs (#40098) 2026-08-06 08:21:17 +00:00
github-actions[bot]
4b8a2c7e6c chore(i18n): sync translations with en-US (#40096)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-08-06 08:14:10 +00:00
Joel
06d8301a90 fix: report unavailable agent tools in publish and workflow checks (#40077) 2026-08-06 07:44:22 +00:00
yyh
dc309ced59 chore(web): remove obsolete PWA assets (#40082) 2026-08-06 07:41:01 +00:00
Elliot Bai
cf88aef710 feat: support TiDB Vector full-text and hybrid search (#38112)
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: bkidy <bkidyy.macmini@gmail.com>
2026-08-06 07:08:42 +00:00
BoneNI
0277b9eb30 feat: Add Lao (lo-LA) language support #39489 (#39474)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: crazywoola <427733928@qq.com>
2026-08-06 07:00:37 +00:00
Eldar Dadashov
8fbbfbc243 fix(web): register audio listeners on real media events (#37841)
Co-authored-by: crazywoola <427733928@qq.com>
2026-08-06 06:57:58 +00:00
wangxiaolei
5bceac05eb fix: get role and list role need filter billing permission key (#38218)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-06 06:37:52 +00:00
yyh
febcc4e054 chore: format GitHub metadata with Oxfmt (#40073) 2026-08-06 06:37:06 +00:00
Souravrajvi0
36c990a0cf fix(web): only open slash variable picker when / is typed (#39755) (#39761) 2026-08-06 06:36:30 +00:00
yyh
658d5e0dcf fix(e2e): align marketplace plugin download URL (#40075) 2026-08-06 06:32:16 +00:00
yyh
cfef48c408 chore: upgrade workspace dependencies (#40069) 2026-08-06 05:56:30 +00:00
Liu Ziming
1973a2e96f feat(trace-aliyun): GenAI spans for LLM TTFT, agent ReAct, tools, and failed LLM nodes (#39339)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 05:51:28 +00:00
Asuka Minato
b105f21763 test: use SQLite sessions in core repositories (#39061)
Co-authored-by: Byron Wang <byron@dify.ai>
2026-08-06 05:50:40 +00:00
Wu Tianwei
e5a07e97a4 fix(web): improve markdown form rendering (#40070) 2026-08-06 05:49:24 +00:00
Asuka Minato
046df1260a test: use SQLite sessions in controllers console app (#39096)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-06 05:25:01 +00:00
Asuka Minato
9e95e1302e test: use SQLite sessions in commands (#39082) 2026-08-06 05:14:38 +00:00
Asuka Minato
59cbba3b37 test: use SQLite sessions in controllers console auth (#39060) 2026-08-06 05:12:35 +00:00
Asuka Minato
20b31de791 test: use SQLite sessions in core entities (#39056)
Co-authored-by: Byron Wang <byron@dify.ai>
2026-08-06 05:10:04 +00:00
yyh
5e036e5489 refactor(system-features): derive nullable SSO protocol (#40063) 2026-08-06 04:51:32 +00:00
Asuka Minato
e6ede05fe9 test: use SQLite sessions in clean dataset (#39051) 2026-08-06 04:49:10 +00:00
Asuka Minato
7edd05100a test: move webhook service coverage to unit tests (#38945)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-06 04:48:27 +00:00
Asuka Minato
46024b6ad8 test: use SQLite sessions in core app apps (#39083)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-06 04:38:15 +00:00
Asuka Minato
123f128ccc test: use real ORM models in app and API tests (#40020) 2026-08-06 04:36:58 +00:00
Joel
6474059eb1 fix: hide log detail actions for legacy app logs (#40057) 2026-08-06 04:18:23 +00:00
Asuka Minato
479a5613a8 test: use real ORM models in dataset service tests (#40018) 2026-08-06 04:14:12 +00:00
Joel
d51900974f fix: offset legacy app error toasts below model controls (#40040)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-08-06 04:14:12 +00:00
Asuka Minato
f08cc41546 test: use SQLite sessions in services core (#39058) 2026-08-06 04:07:01 +00:00
Asuka Minato
2443df2e42 test: use real ORM models in workflow repository tests (#40019) 2026-08-06 04:06:04 +00:00
Asuka Minato
2e65ab9d1e test: use SQLite sessions in core tools (#39108) 2026-08-06 03:57:33 +00:00
Aiden
6add64a44d fix(api): allow account avatars across workspaces (#40055)
Co-authored-by: aiden <zhouyuchi@kurogames.com>
2026-08-06 03:33:23 +00:00
非法操作
8170a2a5f3 chore: more upload file size for paid user (#39967)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-06 03:23:16 +00:00
pineapple880066
70051950ab fix: preserve UTF-8 byte size for text uploads (#40034) 2026-08-06 02:25:29 +00:00
csurong
f7881bb635 fix: make document retry batches consistent (#39978)
Co-authored-by: caosurong <surong.cao@thinkingdata.cn>
2026-08-06 02:01:14 +00:00
Escape0707
b6b3e747f3 refactor(api): extract workspace member listing into layered service (#39974)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-06 01:08:39 +00:00
Jingyi
18f740402f fix(web): redirect legacy plugin fallbacks (#40051)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-05 23:27:08 +00:00
Jingyi
15a5317bd3 fix(web): refresh model provider plugins after updates (#40002) 2026-08-05 23:26:18 +00:00
盐粒 Yanli
1369a2cdfc fix(dify-agent): wait for E2B shellctl readiness (#39996) 2026-08-05 11:03:06 +00:00
Asuka Minato
ac0c909d11 test: use real ORM models in RAG indexing tests (#40017) 2026-08-05 10:03:23 +00:00
林玮 (Jade Lin)
945503523b fix(web): improve debug plugin copy accessibility (#40035) 2026-08-05 10:00:37 +00:00
Asuka Minato
fe7e2dc62f test: use sqlite3 session in test_agent_config_service (#38764) 2026-08-05 08:43:40 +00:00
Asuka Minato
3bb21a9a88 test: use sqlite3 session in test_credit_pool_service (#38694) 2026-08-05 08:43:27 +00:00
yyh
dc09384b1d refactor(web): derive system feature enums from schemas (#40030) 2026-08-05 08:32:54 +00:00
yyh
890e5aa294 refactor(web): align query client ownership (#40024) 2026-08-05 07:02:09 +00:00
yyh
0bf3416431 fix(web): remove misleading ARIA states (#40015) 2026-08-05 06:52:41 +00:00
yyh
685e291e65 refactor(web): remove duplicate dataset title update (#40023) 2026-08-05 06:18:09 +00:00
yyh
c15dd6d994 refactor(web): simplify tag selector trigger (#40014) 2026-08-05 05:23:53 +00:00
Joel
72b30ff313 chore: add copy action to workflow log errors (#40011) 2026-08-05 05:15:59 +00:00
yyh
7dd7b3dfa8 fix(ci): make web workflows reproducible (#40010) 2026-08-05 04:08:12 +00:00
yyh
d033124552 fix(ci): harden Playwright browser installs (#40009) 2026-08-05 03:57:08 +00:00
yyh
2fd8f98f2d chore(deps): upgrade Base UI to 1.7.0 (#40006) 2026-08-05 03:55:46 +00:00
Asuka Minato
7d6d173b01 test: use SQLite in Arize Phoenix traces (#38984) 2026-08-05 03:45:06 +00:00
yyh
2dc6b08044 fix(web): preserve feedback state on submission failure (#40004) 2026-08-05 02:55:50 +00:00
Byron.wang
896e5e20c0 refactor(api): extract workspace list into layered application service (#39822)
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-08-05 02:36:16 +00:00
yyh
143bc121da fix(e2e): configure sandbox-reachable API URL (#40005) 2026-08-05 02:28:16 +00:00
zyssyz123
4213e6b150 fix(agent): expose feedback in agent logs (#39994)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-04 14:11:13 +00:00
盐粒 Yanli
b462cb041d feat(agent): separate CLI file URL audiences (#39952)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-04 13:14:02 +00:00
nightcityblade
9d81f0da10 chore(api): remove unreachable match suppressions (#39949)
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
2026-08-04 11:56:37 +00:00
Asuka Minato
482ec5e712 test: use sqlite3 session in test_qa_index_processor (#38765) 2026-08-04 10:53:55 +00:00
Asuka Minato
36a4c98732 test: use sqlite3 session in test_if_else (#38753) 2026-08-04 10:53:42 +00:00
yyh
dec1cb3798 fix(ui): align button spacing with design (#39990) 2026-08-04 10:37:16 +00:00
林玮 (Jade Lin)
3ada29bbe0 fix(api): surface system quota exhaustion without fallback (#39986) 2026-08-04 08:49:25 +00:00
非法操作
8f817644e9 fix(workflow): avoid app context dependency in parallel iteration (#39983) 2026-08-04 07:47:49 +00:00
Joel
0271ca6e5f fix: align build and preview chat layouts (#39980) 2026-08-04 07:07:48 +00:00
-LAN-
aab31292d8 feat(workflow): support LLM environment variables (#39125)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-04 06:17:13 +00:00
-LAN-
545bdecff2 chore(docker): move optional settings to service envs (#39970) 2026-08-04 05:28:05 +00:00
yyh
7d92097526 fix(dify-ui): improve select and status dot accessibility (#39971) 2026-08-04 05:14:02 +00:00
Xiyuan Chen
edcd3fc2e3 fix(api): allow external knowledge base creation under enterprise RBAC (#39930) 2026-08-04 04:50:43 +00:00
yyh
be413650ba chore(web): upgrade Next.js to 16.3 (#39962) 2026-08-04 04:46:44 +00:00
-LAN-
2d2cc7cab1 refactor(workflow): move AgentNode events out of graphon (#39845) 2026-08-04 03:54:40 +00:00
dependabot[bot]
2838957004 chore(deps): bump the vdb group with 9 updates (#39911)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-08-04 03:40:41 +00:00
Yufeng He
f6852205d2 fix(vdb): bound the TiDB Cloud API timeouts (#39953)
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-08-04 03:17:05 +00:00
dependabot[bot]
9802329b91 chore(deps): bump aiohttp from 3.14.1 to 3.14.3 in /api (#39959)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-08-04 03:14:55 +00:00
盐粒 Yanli
dc5192ba11 fix(dify-agent): make run cancellation route-independent (#39942)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-04 03:11:22 +00:00
盐粒 Yanli
21dbc6c60c fix(dify-agent): keep tmux server alive without sessions (#39943) 2026-08-04 03:09:11 +00:00
dependabot[bot]
a1769d0b3d chore(deps): bump cryptography from 49.0.0 to 50.0.0 in /dify-agent (#39958)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-08-04 03:02:14 +00:00
林玮 (Jade Lin)
be0e2c03e4 feat(api): isolate credentials from local packages (#39957) 2026-08-04 02:50:04 +00:00
Xiyuan Chen
1b7a132afa fix(api): validate installation scope for cached marketplace packages (#39935) 2026-08-04 02:22:45 +00:00
Xiyuan Chen
f2d0001424 fix(api): bump OpenTelemetry to stop duplicate Flask context detach (#39921) 2026-08-04 02:17:53 +00:00
yyh
9ea21e4fd1 perf(web): streaming ssr for home page (#39922) 2026-08-04 02:16:08 +00:00
yyh
5456d4d56e fix(web): improve main navigation responsiveness and tour focus (#39940)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-03 19:05:49 +00:00
Asuka Minato
b4f360127d test: use SQLite sessions in core app apps (#39079) 2026-08-03 15:45:14 +00:00
Asuka Minato
f15a514878 test: use SQLite sessions in core test_provider_manager.py (#39071)
Co-authored-by: Byron Wang <byron@dify.ai>
2026-08-03 15:44:54 +00:00
Asuka Minato
ca1cec6011 test: use SQLite sessions in controllers console explore (#39064) 2026-08-03 15:44:43 +00:00
Asuka Minato
0a05328eb2 test: use SQLite sessions in controllers service api (#39059) 2026-08-03 15:44:25 +00:00
Asuka Minato
6e9a3f8654 test: use SQLite sessions in rag pipeline dsl (#39053) 2026-08-03 15:43:43 +00:00
Asuka Minato
730794426a test: use SQLite sessions in web controller fixtures (#39052) 2026-08-03 15:41:56 +00:00
yyh
a77236e564 fix(web): remove Web Apps loading skeleton (#39939) 2026-08-03 13:41:29 +00:00
yyh
984045190c fix(web): improve home accessibility (#39938) 2026-08-03 13:41:15 +00:00
Ingram Z
612ad161bc fix(dify-agent): atomically finalize run terminals (#39929)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-03 12:02:09 +00:00
mengnanjiugaipeifense
e2bc8f2c17 fix: keep colons in HTTP node form body values on migration (#38861)
Co-authored-by: xiaweiwei67-stack <293320877+xiaweiwei67-stack@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-03 08:39:04 +00:00
wangxiaolei
dc8dd09450 feat: pass image file to llm (#39848)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-03 06:04:32 +00:00
Asuka Minato
982c0667e1 chore: bump pyrefly version (#39876)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-03 05:59:01 +00:00
Ingram Z
66a69c30f5 fix(dify-agent): stabilize E2B config materialization (#39873) 2026-08-03 05:38:05 +00:00
Harsh Kashyap
a5f5acbe0c fix(api): stop echoing the raw api_key in the 403 error message (#39888) (#39902)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-03 03:18:06 +00:00
wangxiaolei
2f52ac94fe chore: keep dify-agent env same with dify (#39918) 2026-08-03 03:17:14 +00:00
Wu Tianwei
5d151ec214 fix(web): clarify member permission exception copy (#39917) 2026-08-03 02:51:51 +00:00
Hand Sonic
bb68a05f9e chore: pass session explicitly in ApiModeration (#39885) 2026-08-03 02:33:18 +00:00
dependabot[bot]
61583e3f0f chore(deps): bump the github-actions-dependencies group with 2 updates (#39909)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 02:32:24 +00:00
-LAN-
351577bdb0 feat(workflow): support human input in loop and iteration (#39243)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-03 02:24:18 +00:00
Harsh Kashyap
8dbac96621 fix(vdb-oceanbase): reject trailing newlines in metadata-key and document-id validators (#39884) (#39901)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-03 02:17:13 +00:00
Hassan Rady
26b4798e88 perf(dataset): fix N+1 database insert bottleneck in Notion sync (#39899) 2026-08-03 00:57:22 +00:00
yyh
802fe70423 test(web): remove Base UI component mocks (#39877) 2026-08-02 09:51:45 +00:00
Harsh Kashyap
02a9c51a3e fix(api): bound JinaReader and adaptive-crawl HTTP timeouts (#39859) (#39860)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-02 02:49:17 +00:00
Taranum Wasu
83b3a8fd88 fix(api): make SSRF-blocked error message actionable (#39867)
Co-authored-by: Taranum01 <50813317+Taranum01@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-02 02:48:44 +00:00
csurong
8a2cf65ec5 fix(matrixone): apply score threshold to vector search (#39784)
Co-authored-by: caosurong <surong.cao@thinkingdata.cn>
2026-08-02 02:43:56 +00:00
kenwoodjw
a42d486bc0 fix(api): prevent cookies from shadowing admin API keys (#39872)
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
2026-08-02 02:43:29 +00:00
yyh
b6824334cf refactor(web): align Base UI state consumers (#39864)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-01 13:09:30 +00:00
Harsh Kashyap
eec0e2e44e fix(api): bound billing service HTTP timeouts (#39874) (#39875)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-01 10:46:13 +00:00
Jony
f6832fddee chore(api): fix typos in config description and comment (#39870)
Co-authored-by: Jony <13896935+zyz619963502zyz@users.noreply.github.com>
2026-08-01 07:41:28 +00:00
WH-2099
e7380865d8 fix(api): scope segment batch deletes (#38217) 2026-08-01 01:39:40 +00:00
yyh
c4d9919d30 fix(web): align secondary sidebar help trigger (#39862) 2026-07-31 20:33:27 +00:00
Chester
4557f127ff fix(auth): add bounded timeout to WaterCrawl credential validation (#39824) 2026-07-31 13:52:02 +00:00
Chester
97e9d8be5a fix(rag): add bounded timeout to Notion extractor requests (#39833) 2026-07-31 13:41:44 +00:00
Chester
074811106d fix(rag): add bounded timeout to Firecrawl extractor requests (#39831) 2026-07-31 13:41:19 +00:00
Asuka Minato
33518407c9 test: use SQLite sessions in services workflow (#39087) 2026-07-31 12:54:41 +00:00
Asuka Minato
f6bbb94112 test: use SQLite sessions in core workflow (#39086) 2026-07-31 12:54:30 +00:00
Asuka Minato
917207414b test: use SQLite sessions in core callback_handler (#39081) 2026-07-31 12:54:20 +00:00
Asuka Minato
753583c06c test: use SQLite sessions in core repositories (#39078) 2026-07-31 12:54:10 +00:00
Asuka Minato
8c4c1dc656 test: use SQLite sessions in core llm_generator (#39070) 2026-07-31 12:54:00 +00:00
Asuka Minato
bab3db7f77 test: use SQLite sessions in core tools (#39068) 2026-07-31 12:53:50 +00:00
Asuka Minato
9ee7750411 test: use SQLite sessions in core ops (#39062)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-31 12:53:32 +00:00
Asuka Minato
5800338911 test: use SQLite sessions in core rag (#39055) 2026-07-31 12:52:42 +00:00
Asuka Minato
5e71fdde0f test: use SQLite sessions in services agent (#39054) 2026-07-31 12:52:11 +00:00
yyh
7e30aba12a refactor(ui): simplify scroll area API (#39857) 2026-07-31 12:43:11 +00:00
yyh
c62ab071eb fix(web): hide prompt log action in agent v2 logs (#39858) 2026-07-31 12:40:44 +00:00
非法操作
b4d93b02fd fix(web): skip auth validation for local file datasource (#39856) 2026-07-31 09:55:48 +00:00
Joel
37bf80dcf6 feat: prioritize configured tools in the agent prompt picker (#39850) 2026-07-31 08:50:54 +00:00
KVOJJJin
f9966c2ee4 fix(workflow): remap snippet references (#39843) 2026-07-31 06:57:10 +00:00
Bond Zhu
c562f78d4f fix(api): silence opentelemetry.context error reports to Sentry on cloud (#39841) 2026-07-31 05:32:02 +00:00
iridescentWen
0d8c02ca62 docs: fix typos in api comments and prompt example (#39838) 2026-07-31 05:30:53 +00:00
Xin Zhang
4b415e7d6b fix: refresh mcp server code by app id (#39828)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-31 04:57:02 +00:00
Escape0707
676dff04c8 test: exercise credential visibility with SQLite (#39827) 2026-07-31 03:20:21 +00:00
Parman Mohammadalizadeh
908dc703ec chore(api): consolidate chained .where() calls into single calls (#39819) 2026-07-30 16:59:50 +00:00
起岚
bea2f78e09 docs: remove trailing whitespace in docker/README.md (#39808) 2026-07-30 16:45:37 +00:00
yyh
5e6154fdd1 refactor(web): align Base UI composition contracts (#39820) 2026-07-30 13:37:04 +00:00
wangxiaolei
69a16d8319 feat: change api/v1/plugins/download to api/v1/plugins/download-url (#39811) 2026-07-30 10:23:29 +00:00
yyh
563cf2602f refactor(dify-ui): govern public component APIs (#39806)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-30 10:12:34 +00:00
Evan
3aa937def4 test: use caplog for telemetry error logging (#39722) 2026-07-30 09:32:56 +00:00
Escape0707
f057a7d32f test: use SQLite sessions in app DSL service tests (#39803) 2026-07-30 09:31:12 +00:00
林玮 (Jade Lin)
8cca3959ae feat: make skill package upload size limit configurable (#39799) 2026-07-30 08:42:24 +00:00
yyh
48bb46a72b fix(ui): align select trigger focus ring (#39804) 2026-07-30 08:40:20 +00:00
yyh
6a52069872 refactor(web): narrow app list context (#39802) 2026-07-30 08:24:22 +00:00
yyh
72afcc1326 refactor(web): remove stale header maximize state (#39801) 2026-07-30 08:20:41 +00:00
yyh
e834fa212d refactor(web): remove dead dataset operator context state (#39800) 2026-07-30 08:20:31 +00:00
Joel
b32c900761 feat: add upload size guidance and validation for agent files and skills (#39798) 2026-07-30 07:57:53 +00:00
Jingyi
e8b17345c3 fix(web): isolate MCP card action overlays (#39797) 2026-07-30 07:51:20 +00:00
yyh
95166806ae refactor(web): remove configuration mitt context (#39796) 2026-07-30 07:07:43 +00:00
yyh
e4947811a5 refactor(web): localize retrieval method query (#39795) 2026-07-30 07:07:32 +00:00
yyh
0bb1b4d6d2 refactor(web): internalize trigger limit modal state (#39794) 2026-07-30 07:07:23 +00:00
yyh
01ff1b5128 refactor(web): remove app log drawer context (#39792) 2026-07-30 06:11:33 +00:00
yyh
71ae1d5511 refactor(web): localize dataset external API state (#39791) 2026-07-30 06:11:23 +00:00
yyh
3d01e8389a fix(chat): derive themes per chat root (#39790) 2026-07-30 06:11:13 +00:00
yyh
44a3ea7c0c refactor(web): localize model load balancing modal state (#39789) 2026-07-30 06:10:57 +00:00
Rafli Surya Wijaya
1fd9ab14f9 fix(workflow): provide default values for AgentNodeData fields (#39743) (#39754)
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-30 05:23:48 +00:00
Asuka Minato
0aefcfa373 test: use SQLite sessions in tasks (#39076) 2026-07-30 05:18:19 +00:00
Asuka Minato
09d5aa47bb test: use sqlite3 session in test_workflow_node_execution_truncation (#38734) 2026-07-30 04:47:38 +00:00
Asuka Minato
b852252076 test: use sqlite3 session in test_trial (#38732) 2026-07-30 04:47:18 +00:00
Asuka Minato
814fee5550 test: use sqlite3 session in test_factory (#38731) 2026-07-30 04:39:16 +00:00
Asuka Minato
754c380e2d test: use sqlite3 session in test_billing (#38722) 2026-07-30 04:39:04 +00:00
Asuka Minato
9f17e1ca95 test: move RAG pipeline import coverage to unit tests (#38938) 2026-07-30 04:32:23 +00:00
Asuka Minato
61a5b4d205 test: use SQLite sessions in core tools (#39094) 2026-07-30 04:23:36 +00:00
Asuka Minato
7b33622cdd test: use SQLite in Langfuse traces (#38985) 2026-07-30 04:14:18 +00:00
Asuka Minato
942a16b2a5 test: use SQLite in Opik traces (#38987) 2026-07-30 04:13:58 +00:00
Joel
97a13b6c36 fix: open inline agent editor from checklist warnings (#39781) 2026-07-30 03:22:07 +00:00
李政达
7fd2f4f4e8 fix(api): prevent HITL pause from aborting workflow resume (#39485)
Co-authored-by: 李政达 <1242427577@qq.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
2026-07-30 03:21:28 +00:00
Asuka Minato
1593b7cc60 test: use SQLite sessions in core app apps (#39102)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-30 03:18:31 +00:00
Asuka Minato
3f1036e55a test: use SQLite sessions in core app apps (#39101) 2026-07-30 03:18:09 +00:00
Asuka Minato
7c9a0ce93b test: use SQLite sessions in controllers console app (#39097)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-30 03:17:48 +00:00
Asuka Minato
9e8733413a test: use SQLite sessions in controllers console app (#39095)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-30 03:07:43 +00:00
Asuka Minato
3453fdce44 test: use SQLite sessions in core rag (#39092)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-30 03:04:18 +00:00
Asuka Minato
6804dd474d test: use SQLite sessions in services workflow (#39091)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-30 03:03:47 +00:00
Asuka Minato
edd6a6e77f test: use SQLite sessions in core repositories (#39073) 2026-07-30 02:53:50 +00:00
Asuka Minato
19b0a8ce08 test: use SQLite sessions in core repositories (#39067) 2026-07-30 02:53:38 +00:00
Asuka Minato
e12e86a308 test: use sqlite3 session in test_completion_completion_app_generator (#38723) 2026-07-30 02:45:54 +00:00
Asuka Minato
b855d71adb test: use SQLite sessions in rag pipeline transform (#39050) 2026-07-30 02:43:48 +00:00
Asuka Minato
87aa982185 test: use SQLite sessions in services data_migration (#39065) 2026-07-30 02:41:42 +00:00
Asuka Minato
bea82fb57f test: use SQLite sessions in services tools (#39063) 2026-07-30 02:39:55 +00:00
yyh
a21d2b1201 fix(web): constrain slash picker triggers (#39768) 2026-07-30 02:23:52 +00:00
Souravrajvi0
fdaa073d53 fix(api): delete custom models stored with legacy model_type values (#39708)
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-07-30 01:12:26 +00:00
Jony
89ceba027e fix: allow an empty Agent tools selection (#39695) 2026-07-30 01:06:32 +00:00
Checo
832f42a996 fix(socketio): omit socket_timeout from RedisManager pub/sub options (#39587)
Signed-off-by: sergioperezcheco <checo520@outlook.com>
2026-07-30 00:50:21 +00:00
zyssyz123
72c20daa61 fix(plugin): preserve credentials during local replacement (#39764)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-29 12:39:06 +00:00
yyh
81fd1da3ff fix: refactor account about dialog ownership (#39765) 2026-07-29 12:33:26 +00:00
csurong
fc2f1f4f75 fix(matrixone): generate ids for every document (#39734)
Co-authored-by: caosurong <surong.cao@thinkingdata.cn>
2026-07-29 12:31:11 +00:00
wangxiaolei
080a1d42f2 fix: fix hardcode /home/dify (#39767) 2026-07-29 12:27:38 +00:00
Stephen Zhou
0441d27cac chore: canonicalize Tailwind classes (#39762) 2026-07-29 11:58:14 +00:00
Escape0707
10621b5a9e test: migrate account service tests to shared SQLite (#39758) 2026-07-29 11:43:32 +00:00
非法操作
c1fdd6fb78 chore: paginate installed apps (#39739)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-29 09:51:34 +00:00
Joel
fd804dfcc0 fix(web): show API errors when agent publishing fails (#39756) 2026-07-29 09:37:18 +00:00
Asuka Minato
155e4fbdc5 test: use SQLite in Tencent traces (#38988) 2026-07-29 09:25:26 +00:00
David Park
20b9278ca3 fix(docker): mount shared storage volume in api_websocket (#39424) 2026-07-29 09:00:37 +00:00
Asuka Minato
2844948aa0 test: use SQLite sessions in core app (#39100)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-29 08:14:06 +00:00
Asuka Minato
62791579ab test: move service dataset controller coverage to unit tests (#38941)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron Wang <byron@dify.ai>
2026-07-29 07:57:35 +00:00
yyh
dbabddf1ab fix(web): force render dialog backdrop and use dialog description in education verify modal (#39749) 2026-07-29 07:43:21 +00:00
yyh
3ad925a66a refactor(web): clarify education state ownership (#39748) 2026-07-29 07:25:54 +00:00
Joel
54aced9e20 fix: display workflow run errors in the result panel (#39746) 2026-07-29 07:24:48 +00:00
非法操作
0d6b2fc40c fix: avoid returning unused vectors in TiDB on Qdrant search (#39744) 2026-07-29 06:51:18 +00:00
林玮 (Jade Lin)
14a938c5c5 fix: handle unconfigured LLM workflow streams (#39741) 2026-07-29 06:07:24 +00:00
yyh
d4eab4ae88 refactor(web): consolidate settings state in URL (#39740) 2026-07-29 06:00:55 +00:00
Asuka Minato
8629b91517 test: use SQLite sessions in libs (#39089) 2026-07-29 05:53:33 +00:00
Asuka Minato
0290c8d525 test: use SQLite sessions in core rag (#39106) 2026-07-29 05:52:45 +00:00
Asuka Minato
c5b0772ab0 test: use SQLite sessions in core tools (#39107)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-29 05:52:33 +00:00
Asuka Minato
8e74d7d715 test: use sqlite3 session in test_session (#38772) 2026-07-29 05:51:02 +00:00
Asuka Minato
103c864b35 test: use sqlite3 session in test_sqlalchemy_workflow_execution_repository (#38761) 2026-07-29 05:50:43 +00:00
Asuka Minato
133e974dff test: use sqlite3 session in test_metadata_bug_complete (#38721) 2026-07-29 05:50:26 +00:00
Asuka Minato
958d8a7997 test: use sqlite3 session in test_human_input_timeout_tasks (#38703) 2026-07-29 05:49:56 +00:00
Asuka Minato
b6bfae7e3c test: use sqlite3 session in test_workflow_pause_events (#38688) 2026-07-29 05:49:15 +00:00
yyh
8946d7fbf4 refactor(web): remove workflow node useBoolean usage (#39729) 2026-07-29 03:07:30 +00:00
非法操作
e604fad630 fix(web): gate plugin auth permission hint (#39721) 2026-07-29 02:07:57 +00:00
Xiyuan Chen
f07cc3dfb4 chore(deps): bump wandb 0.28.0 -> 0.28.1 for CVE fix attempt (#39718) 2026-07-29 02:01:54 +00:00
盐粒 Yanli
6506aaae83 chore: add E2B sync and tini supervision (#39705) 2026-07-29 02:01:25 +00:00
盐粒 Yanli
f6683c2c6b feat(agent): make home snapshots optional and use backend defaults (#39702)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-29 02:01:12 +00:00
Xiyuan Chen
7eba562f7d chore(deps): bump starlette, soupsieve, cryptography for CVE fixes (#39716) 2026-07-29 01:47:29 +00:00
yyh
fd412a82e9 refactor(e2e): unify seeded runtime orchestration (#39700) 2026-07-28 15:05:20 +00:00
Escape0707
1419c7c6db test: share unbound unit test sessions (#39690) 2026-07-28 14:12:08 +00:00
Escape0707
7083a953e1 test: enforce strict typing in migrated unit tests (#39689) 2026-07-28 14:04:56 +00:00
yyh
93e7d50845 test(e2e): align Agent tool credential states (#39698) 2026-07-28 13:41:23 +00:00
盐粒 Yanli
a048f35099 refactor: introduce agent working environment architecture (#39480)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 13:16:05 +00:00
Yunlu Wen
22cef67804 chore(deps): bump gitpython from 3.1.54 to 3.1.57 in /api (#39684) 2026-07-28 10:27:16 +00:00
Joel
3bc8c69def feat: label agent conversation reset action on hover (#39691) 2026-07-28 10:06:46 +00:00
yyh
798e5ed7a7 refactor(web): derive workspace billing from current workspace (#39674)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 09:32:03 +00:00
Escape0707
eef709e475 ci: enforce strict checks for unit tests (#39682) 2026-07-28 09:10:02 +00:00
Escape0707
04158ac8ea ci: replace custom noqa markers with guard ignores (#39679) 2026-07-28 08:52:12 +00:00
3117 changed files with 106856 additions and 80029 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -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:
- "*"
- '*'

View File

@@ -1 +1 @@
failure-threshold: "error"
failure-threshold: 'error'

View File

@@ -1,5 +1,4 @@
---
extends: default
rules:

View File

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

View File

@@ -12,8 +12,8 @@
## Screenshots
| Before | After |
|--------|-------|
| ... | ... |
| ------ | ----- |
| ... | ... |
## Checklist

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
name: "Pull Request Labeler"
name: 'Pull Request Labeler'
on:
pull_request_target:

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ on:
- reopened
- synchronize
merge_group:
branches: ["main"]
branches: ['main']
types: [checks_requested]
jobs:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -140,6 +140,9 @@ dmypy.json
pyrightconfig.json
!api/pyrightconfig.json
# import-linter
.import_linter_cache/
# Pyre type checker
.pyre/
.idea/'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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