Compare commits

...

179 Commits

Author SHA1 Message Date
Yunlu Wen
09a855dcef chore: bump version to 1.17.0 (#41247)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-25 10:52:21 +00:00
wangxiaolei
a6fe278662 feat: hide add library skill in agent ui (#41250)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-25 10:26:06 +00:00
林玮 (Jade Lin)
960018253b feat(api): track credit usage contexts (#41181)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-25 10:21:32 +00:00
yyh
8abff1b0b1 refactor(web): migrate dataset rename form (#41233) 2026-08-25 09:41:37 +00:00
yyh
eb7a1829fe refactor(web): migrate pipeline DSL import form (#41232) 2026-08-25 09:41:36 +00:00
yyh
9ce92d8605 fix(ui): add tabs panel focus treatment (#41231) 2026-08-25 09:41:36 +00:00
wangxiaolei
ddcdbb2d14 feat: add skill enable feature switch (#41246) 2026-08-25 08:42:15 +00:00
Joel
18d3990ae6 fix: show a not-allowed cursor for the prompt editor in build mode (#41243) 2026-08-25 07:43:32 +00:00
zxhlyh
03e4bc50a2 fix(skills): align Japanese Skills page title (#41242) 2026-08-25 07:42:23 +00:00
Joel
370efc493c fix: prevent sidebar footer actions from triggering hover preview (#41230) 2026-08-25 06:53:52 +00:00
Rafli Surya Wijaya
cf236aed8a fix(api): enforce APP_EDIT RBAC permission on MCP server refresh endpoint (#41235) 2026-08-25 06:38:13 +00:00
Asuka Minato
48315be2b1 test: migrate RAG variable controller sessions and ORM models to SQLite (#40609)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-25 06:20:48 +00:00
Asuka Minato
a9035a3264 test: migrate step-by-step tour sessions to SQLite (#40636) 2026-08-25 06:20:13 +00:00
Asuka Minato
39ce9f68fc test(vdb): use real ORM models in provider tests (#40687) 2026-08-25 06:19:59 +00:00
Asuka Minato
ed9d3801ba test: move pure integration cases to unit tests (#40705)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-25 06:19:48 +00:00
Asuka Minato
f3bb73b671 test: centralize app and account config overrides (#40860) 2026-08-25 06:19:35 +00:00
wangxiaolei
bc845ea748 fix: fix skill display name rename conflict (#41229) 2026-08-25 06:13:47 +00:00
非法操作
1afac21690 feat(api): support New Agent Beta for inline Workflow agents (#41215) 2026-08-25 05:39:41 +00:00
samzong
dd2e691ea8 fix(cli): declare write effect for import and context-switch commands (#41225)
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-08-25 05:32:55 +00:00
非法操作
f360a7bf94 fix: allow OAuth trigger builder verification without credentials (#39199)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-25 05:32:27 +00:00
Xiyuan Chen
caf7e90ca5 test(cli): tsconfig gap (#41224) 2026-08-25 05:11:44 +00:00
Alexey Shabalin
1e501cd75b fix(web): stop digits rendering in the emoji font (#41201)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-25 05:04:23 +00:00
盐粒 Yanli
45cb385d77 fix(docker): harden agent compose configuration (#41065) 2026-08-25 04:41:51 +00:00
Yunlu Wen
679514d40a perf(agent): exponential backoff in polling to prevent forking storm (#41222) 2026-08-25 04:36:46 +00:00
Yunlu Wen
9643dcacce perf(agent): polling interval tuning (#41221) 2026-08-25 03:50:22 +00:00
Joel
fcb380044c fix: enforce read-only agent resources in build mode (#41220) 2026-08-25 03:37:02 +00:00
向阳
d4ed92cb0a perf(api): batch recommended app catalog lookups (#41218) 2026-08-25 03:23:52 +00:00
Joel
12f259ecae fix: address Agent UX feedback (#41219) 2026-08-25 03:14:24 +00:00
Aarush Karak
d9ca69d34f fix(types): replace any[] with ThoughtItem[] for agent_thoughts (#41141)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-25 02:08:51 +00:00
Taranum Wasu
ebb0f12cd0 fix(model): re-sign bare/backticked tool file URLs in answer (#40799)
Co-authored-by: Taranum01 <50813317+Taranum01@users.noreply.github.com>
Co-authored-by: Taranum01 <taranumwasu@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-08-25 02:04:21 +00:00
zxhlyh
667d66c4dc fix(skills): align management UI accessibility and localization (#41180)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-08-25 01:53:19 +00:00
非法操作
ef92cb7c65 refactor(api): complete account education boundary (#41153)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-25 01:48:12 +00:00
dependabot[bot]
c9ea15a968 chore(deps): bump boto3 from 1.43.71 to 1.43.76 in /api in the storage group (#41126)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 10:00:23 +08:00
Jingyi
b56d30b366 fix(web): refresh model provider version after update (#41212) 2026-08-25 01:12:01 +00:00
yyh
8d6128957f fix(web): restore model selector popup spacing (#41190) 2026-08-24 15:05:22 +00:00
Joel
e25ca611d8 fix: failed e2e test in agent caused by changed copywriting (#41187) 2026-08-24 13:16:37 +00:00
Joel
44aec257bf fix: avoid copy-sensitive agent file toast assertion (#41179) 2026-08-24 10:33:25 +00:00
Joel
2be3c3d69e fix: refine agent configuration copy (#41177) 2026-08-24 09:58:06 +00:00
yyh
09b199b280 fix: keep pagination skeletons in app grid (#41175) 2026-08-24 09:35:54 +00:00
github-actions[bot]
b9764c4779 chore(i18n): sync translations with en-US (#41173)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-08-24 09:08:26 +00:00
Harsh Kashyap
ff3cf5681f fix(api): attach exc_info=True to 11 logger.debug/info('...: %s', str(e)) sites — sibling of #41066 (DEBUG/INFO level) (#41077)
Co-authored-by: Harsh Kashyap <harsh23kashyap@gmail.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-24 09:08:08 +00:00
Asuka Minato
cfae2515fd test: remove obsolete integration coverage (#40704) 2026-08-24 09:06:29 +00:00
yyh
b5982927de fix: align main navigation card grids (#41167) 2026-08-24 09:04:30 +00:00
Xiyuan Chen
28a4859218 fix(api): respect configured model params in prompt generator (#41165) 2026-08-24 09:02:34 +00:00
QuantumGhost
4f8a4c20fd ci: add HITL IM test environment deployment (#41171) 2026-08-24 08:54:27 +00:00
Crazywoola
21d540f760 feat(web): enhance Go to Anything navigation and discovery (#41160) 2026-08-24 08:48:53 +00:00
Joel
fef28097cc fix: preserve agent task editor focus when inserting commands (#41166) 2026-08-24 08:33:29 +00:00
yyh
3de20e0ba8 fix: keep home intro without banner (#41155) 2026-08-24 08:30:55 +00:00
yyh
703f6d1780 fix(web): align agent v2 monitoring translations (#41161) 2026-08-24 08:21:41 +00:00
-LAN-
2e1620c1ac chore(api): enforce backend layer boundaries (#41159) 2026-08-24 08:17:11 +00:00
zyssyz123
4847447635 fix(agent): allow saving model-less inline Agent drafts (#41147) 2026-08-24 07:57:14 +00:00
Joel
7ce751ffe8 chore: show full agent save time on hover (#41148)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-08-24 07:53:31 +00:00
Joel
7cb9aa2d3b fix: align duplicate agent name behavior with agent forms (#41156) 2026-08-24 07:48:33 +00:00
yyh
3ab8476d2d fix(web): align agent v2 monitoring copy (#41150) 2026-08-24 07:36:18 +00:00
yyh
643e2d66bf chore(deps): upgrade pnpm workspace dependencies (#41138) 2026-08-24 07:07:47 +00:00
Joel
693e1b0fc5 fix: show checklist errors for inline agents without models (#41146) 2026-08-24 06:57:34 +00:00
林玮 (Jade Lin)
70a513fc62 fix(api): scope hosted credits by tenant plan (#41131)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-24 06:35:42 +00:00
FFXN
bec4ef6ac7 fix: fix upload for plugin (#41133)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-24 05:39:27 +00:00
wangxiaolei
0024a845f7 feat: support agent skill (#39675)
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: 起岚 <155608604+xiaoyaoqilan@users.noreply.github.com>
Co-authored-by: Parman Mohammadalizadeh <prmma23@gmail.com>
Co-authored-by: Escape0707 <tothesong@gmail.com>
Co-authored-by: Xin Zhang <zhangxin@dify.ai>
Co-authored-by: iridescentWen <66297467+iridescentWen@users.noreply.github.com>
Co-authored-by: Bond Zhu <37842169+MRZHUH@users.noreply.github.com>
Co-authored-by: KVOJJJin <jzongcode@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: Chester <superdiao6@gmail.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
Co-authored-by: Jony <619963502@qq.com>
Co-authored-by: Jony <13896935+zyz619963502zyz@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <harsh.kashyap2001@gmail.com>
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
Co-authored-by: kenwoodjw <blackxin55+@gmail.com>
Co-authored-by: csurong <csurong1@gmail.com>
Co-authored-by: caosurong <surong.cao@thinkingdata.cn>
Co-authored-by: Taranum Wasu <81034301+Taranum01@users.noreply.github.com>
Co-authored-by: Taranum01 <50813317+Taranum01@users.noreply.github.com>
Co-authored-by: Pranav Agarwal <agarwalpranav0711@gmail.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: zl86790 <marshal_li_b@163.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-08-24 05:29:14 +00:00
Crazywoola
0ab88ef36a chore: Replace star image with new asset link (#41136) 2026-08-24 04:33:24 +00:00
yyh
7b3d97cb42 refactor(web): migrate pipeline publishing input form (#41129) 2026-08-24 04:05:39 +00:00
yyh
429edf4d82 refactor(web): migrate pipeline template input form (#41128) 2026-08-24 04:05:38 +00:00
yyh
5a9b6cb239 refactor(web): migrate duplicate app input form (#41127) 2026-08-24 04:05:37 +00:00
Asuka Minato
bda6bf3fee test: migrate trigger and webhook sessions and ORM models to SQLite (#40594) 2026-08-24 04:01:02 +00:00
Joel
24287bcb0c fix: stabilize the agent tool picker placement (#41132) 2026-08-24 03:38:57 +00:00
AsperforMias
3f7e6cdcb4 fix(tts): propagate and validate actual audio MIME (#41043)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-08-24 03:38:09 +00:00
Benjamin
ea6c15b68e fix: fall back to the batch API when the global plugin manifest is empty (#41107)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 03:33:23 +00:00
dependabot[bot]
77998e41e4 chore(deps): bump gunicorn from 26.0.0 to 26.1.0 in /api in the flask group (#41118)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 03:28:14 +00:00
酒诗
aed1672366 refactor: pass session to workflow comment account accessors (#41117)
Co-authored-by: geneachina <geneachina@gmail.com>
2026-08-24 03:20:32 +00:00
dependabot[bot]
11c922b5d4 chore(deps): bump the github-actions-dependencies group with 4 updates (#41120)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 03:17:12 +00:00
XIIRUAN
ccd0f4b15d refactor(web): use shared session factory for JWT decoding (#41007) 2026-08-24 03:16:44 +00:00
lei_lei
ab34f9ae09 fix(workflow): honor stop between non-streaming nodes (#41090)
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: crazywoola <427733928@qq.com>
2026-08-24 02:51:20 +00:00
ump45nose
9385e583ff fix(agent): avoid empty Bedrock schema fields (#40443)
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-24 02:08:11 +00:00
非法操作
5018f8aa8a refactor(api): standardize session factory wiring (#41085) 2026-08-24 01:57:17 +00:00
非法操作
ed45016475 refactor(api): standardize console billing portal errors (#41055)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-24 01:55:48 +00:00
陈志谦
9d6c846a49 docs: fix dead anchors in localized READMEs and stale dify-agent link (#41101) 2026-08-24 01:53:59 +00:00
yyh
8bdf702f73 fix(agent-v2): prevent workflow refresh render loop (#41105) 2026-08-23 08:56:02 +00:00
yyh
c62947f1f1 refactor(dify-ui): rename radio family entrypoint (#41104) 2026-08-23 08:08:29 +00:00
Pranav Agarwal
24f5e2ac69 fix(api): enforce admin and RBAC permissions on default model and provider models GET endpoints (#41099) (#41100) 2026-08-23 07:33:57 +00:00
yyh
e18310e52e refactor(dify-ui): simplify overlay content APIs (#41098) 2026-08-22 23:54:08 +00:00
Pranav Agarwal
07119e3f00 fix(api): enforce admin and RBAC permissions on data source integrates GET endpoint (#41079) (#41080) 2026-08-21 17:57:23 +00:00
yyh
a9b8c84e9b refactor(dify-ui): simplify popover content API (#41069) 2026-08-21 13:11:40 +00:00
盐粒 Yanli
35a4533889 refactor(agent): simplify Agent v2 output contract (#41062)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-21 11:55:25 +00:00
Blackoutta
3a8fbcbd02 feat(trace): add provider-neutral unified tracing (#39451)
Co-authored-by: EvoX <evox@evomap.ai>
2026-08-21 11:28:18 +00:00
mesrop2008
23150c2319 fix(api): attach exc_info=True to logger.warning sites that drop the traceback (#41068) 2026-08-21 11:20:42 +00:00
Xiyuan Chen
d6147456e3 feat: Enterprise Home Snapshot — runtime endpoints and dify-agent adapter (#40996) 2026-08-21 10:39:36 +00:00
gegemeimingzi
7d5c68c2e1 fix(web): prevent signin page content from overflowing on mobile (#40921)
Co-authored-by: gegemeimingzi <gegemeimingzi@users.noreply.github.com>
2026-08-21 10:36:31 +00:00
Rafli Surya Wijaya
385e8662bb fix(vdb): add document_id keyword mapping and fix VectorType in elasticsearch-ja adapter (#40400)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-21 10:35:54 +00:00
yyh
6cb96adfbe refactor(dify-ui): compose dialog close controls (#41064) 2026-08-21 10:21:43 +00:00
Yunlu Wen
373f33fd3b fix: add missing metrics back and enhance typing (#40935)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-21 09:46:45 +00:00
yyh
faca558bf5 fix(web): prevent Safari clipping tour recovery prompt (#41060) 2026-08-21 09:19:38 +00:00
Byron.wang
b38d907ede refactor(api): finish account controller application boundary (#40440) 2026-08-21 08:46:06 +00:00
Carson Yang
0c3d08f355 docs: add Sealos deployment option (#41026) 2026-08-21 08:15:35 +00:00
Byron.wang
a52adef203 refactor(api): extract change email state machine (#40439) 2026-08-21 08:13:23 +00:00
Byron.wang
3b05b5e553 refactor(api): move account lifecycle into application services (#40438) 2026-08-21 08:05:43 +00:00
Asuka Minato
92978f6d9f test: migrate console dataset segment sessions and ORM models to SQLite (#40515)
Co-authored-by: Byron.wang <byron@dify.ai>
2026-08-21 06:50:14 +00:00
Asuka Minato
6fe87aaca5 test: migrate residual app generator ORM models to SQLite (#40580)
Co-authored-by: Byron.wang <byron@dify.ai>
2026-08-21 06:49:58 +00:00
Asuka Minato
37a927bf63 test: migrate console agent skill ORM models to SQLite (#40570)
Co-authored-by: Byron.wang <byron@dify.ai>
2026-08-21 06:49:44 +00:00
yyh
44e89d8844 refactor(web): migrate external API input fields (#41052) 2026-08-21 06:38:21 +00:00
yyh
7b1339a156 refactor(web): migrate subscription confirmation input (#41051) 2026-08-21 06:38:21 +00:00
yyh
2ff3f7b533 refactor(web): migrate human input timeout field (#41050) 2026-08-21 06:38:20 +00:00
potoior
34fdac2113 fix: merge system messages and place them first to comply with Qwen/v… (#39136) 2026-08-21 06:24:20 +00:00
Asuka Minato
7d635d9f7e test: migrate auth and access controller ORM models to SQLite (#40572)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-21 06:12:29 +00:00
Asuka Minato
0ce41bfea1 test: migrate console app sessions and ORM models to SQLite (#40559) 2026-08-21 06:12:08 +00:00
Asuka Minato
92b93c9d37 test: migrate remaining controller sessions and ORM models to SQLite (#40538)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-21 06:11:44 +00:00
Asuka Minato
4692741aa0 test: migrate Agent App session store to SQLite ORM (#40638) 2026-08-21 06:10:31 +00:00
Asuka Minato
4ca0a10088 test: migrate vector space admission sessions and ORM models to SQLite (#40640) 2026-08-21 06:10:13 +00:00
Asuka Minato
6ce0976265 test: add tmp_path type (#40724)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-21 06:09:39 +00:00
Asuka Minato
5827ce2adf test: migrate annotation sessions and ORM models to SQLite (#40504) 2026-08-21 06:09:26 +00:00
Byron.wang
1d79b87307 refactor(api): move account access behind application services (#40437) 2026-08-21 05:56:30 +00:00
zl86790
9d8dacd0c8 fix(api): prevent dropped workflow_started events in Redis Streams (#40964)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
2026-08-21 03:46:50 +00:00
yyh
720e1fab9b refactor(dify-ui): expose slider anatomy (#41045) 2026-08-21 03:15:45 +00:00
yyh
ca6f4e11f9 docs(dify-ui): establish documentation hierarchy (#41044) 2026-08-21 02:29:41 +00:00
WH-2099
a5e284e6b1 fix(plugin): protect endpoint listings (#40897) 2026-08-21 02:17:58 +00:00
Stephen Zhou
3d08b2f89a fix(web): route enterprise documentation links (#40957) 2026-08-21 02:13:26 +00:00
Pranav Agarwal
43e59d3bc5 fix(api): enforce edit and RBAC permissions on datasource credential …fix(api): enforce edit and RBAC permissions on datasource credential GET endpoint (#41040) (#41041) 2026-08-21 02:11:46 +00:00
WH-2099
edc805c453 fix(api): enforce app-scoped access on indirect resources (#40893) 2026-08-21 00:52:39 +00:00
Ingram Z
0ca99312c1 fix(agent): align Binding file upload timeouts (#41027) 2026-08-20 18:59:44 +00:00
wangxiaolei
785ca4b915 fix: when rbac enabled is true, not check_dataset_permission (#41022) 2026-08-20 14:45:40 +00:00
dependabot[bot]
c7b5a455f5 chore(deps): bump google-cloud-aiplatform from 1.160.0 to 1.164.0 in /api in the google group across 1 directory (#39249)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 13:48:44 +00:00
dependabot[bot]
0c698744ab chore(deps): bump the storage group across 1 directory with 2 updates (#40393)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 13:47:50 +00:00
hyhyf
e425e18fca fix: add MESSAGE_TRACE task on chat pipline error events (#39129)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-20 13:44:22 +00:00
yyh
f4e50f3c8a refactor(web): migrate empty dataset input (#41030) 2026-08-20 13:38:53 +00:00
yyh
aba3f9c1d6 refactor(web): migrate document rename input (#41029) 2026-08-20 13:38:52 +00:00
yyh
979c8cc34b refactor(web): migrate conversation rename input (#41028) 2026-08-20 13:38:51 +00:00
Asuka Minato
502418f707 test: centralize typed runtime config overrides (#40857)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-20 13:30:04 +00:00
蓝破碎半圆/破碎蓝调
ccfb47d2c5 feat: support ODT document extraction (#39973)
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-08-20 13:23:03 +00:00
非法操作
1bb9183af9 refactor(api): replace legacy recommended app retrieval stack (#40395) 2026-08-20 13:13:43 +00:00
非法操作
de7e43a7ef refactor(api): decouple recommended app runtime admission (#40257) 2026-08-20 13:13:43 +00:00
非法操作
b09691e5d2 refactor(api): isolate trial app usage writes (#40208) 2026-08-20 13:13:42 +00:00
GGbond
6f70ca8d26 refactor(api): decouple console tag management (#40843)
Co-authored-by: Byron Wang <byron@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-20 12:31:01 +00:00
Fasih Ur Rahman
a7a30b20af fix(api): add missing admin and RBAC permission decorators to model credentials GET endpoints (#40962) 2026-08-20 12:15:13 +00:00
盐粒 Yanli
cbd78c0073 fix(agent): clean legacy Agent Soul files (#41024) 2026-08-20 10:34:11 +00:00
盐粒 Yanli
97a3039a2d refactor(dify-agent): use Workspace as shell temp space (#41023) 2026-08-20 10:16:05 +00:00
盐粒 Yanli
005edb7d47 fix(dify-agent): add reliable shellctl stdio mode (#39997) 2026-08-20 10:10:00 +00:00
盐粒 Yanli
b034368b3b fix(dify-agent): adjust local sandbox home paths (#41021) 2026-08-20 10:00:01 +00:00
Pranav Agarwal
43b6d8e32e fix(api): enforce admin and RBAC permissions on tool OAuth custom client GET endpoint (#40944) (#40945) 2026-08-20 09:10:02 +00:00
FFXN
248df5dd25 fix(snippet): support snippet history version export (#41012)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-20 08:52:45 +00:00
非法操作
f97fc01c2f refactor(api): extract recommended app query service (#40207) 2026-08-20 08:45:47 +00:00
林玮 (Jade Lin)
a6ace29e1a fix(api): handle suspended email domains (#41004)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-20 08:04:05 +00:00
盐粒 Yanli
013b328555 fix(agent): complete ownership-aware deletion lifecycle (#40888)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-08-20 07:48:02 +00:00
chelsealong
d7b1531431 fix(snippets): implement delete for published snippet workflow versions (#41003)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-20 07:36:17 +00:00
yyh
63acefa6d0 fix(billing): remove misleading tax assurance (#41009) 2026-08-20 07:33:20 +00:00
Stephen Zhou
e2f6418fe4 chore: inline custom ESLint rules (#41008) 2026-08-20 07:32:13 +00:00
yyh
2daa69c92e fix(web): clarify agent deletion consequences (#41002) 2026-08-20 06:45:55 +00:00
yyh
72b6a4e6ed refactor: consolidate frontend browser test infrastructure (#40997) 2026-08-20 06:13:30 +00:00
yyh
b16a24f084 refactor(web): migrate install account fields (#40992) 2026-08-20 05:54:30 +00:00
Asuka Minato
6e890524db test: migrate snippet service ORM models to SQLite (#40567) 2026-08-20 05:12:30 +00:00
Asuka Minato
e27eb51f8a test: migrate media and execution service sessions and ORM models to SQLite (#40552) 2026-08-20 05:11:46 +00:00
Asuka Minato
2155adff5a test: migrate Explore controller sessions and ORM models to SQLite (#40537) 2026-08-20 05:10:49 +00:00
Asuka Minato
c2c1876484 test: migrate document service sessions and ORM models to SQLite (#40536) 2026-08-20 05:10:13 +00:00
非法操作
48f338e649 refactor(api): extract web app runtime bootstrap (#40949) 2026-08-20 05:05:31 +00:00
Byron.wang
b4e13ec83e refactor(api): add account profile application service (#40436)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-20 05:03:08 +00:00
yyh
11f830a17b ci(web): run browser tests on GitHub Ubuntu (#40995) 2026-08-20 04:24:32 +00:00
yyh
5550ec8ebf fix(web): redirect after deleting an agent (#40994) 2026-08-20 04:09:50 +00:00
yyh
ea59256349 refactor(web): migrate signup password fields (#40991) 2026-08-20 03:29:14 +00:00
yyh
0f1536585e refactor(web): migrate signup email field (#40990) 2026-08-20 03:29:13 +00:00
林玮 (Jade Lin)
602a646dbd fix(api): enforce dataset document ownership (#40989) 2026-08-20 03:24:45 +00:00
Amogh Bhat Nagri
54ffbc7f0f fix(logstore): normalize workflow timestamps to naive UTC (#40947)
Co-authored-by: wangxiaolei <fatelei@gmail.com>
2026-08-20 02:58:07 +00:00
WH-2099
ae20d64304 fix(ci): run Playwright E2E on GitHub Ubuntu (#40986) 2026-08-20 02:48:27 +00:00
非法操作
ee997d00fa refactor(api): reuse web app access queries for permissions (#40984) 2026-08-20 02:27:49 +00:00
yyh
98c2ffeec7 fix(ui): align toast swipe direction with viewport (#40980) 2026-08-19 13:16:12 +00:00
yyh
8e1c259a66 fix(web): prevent workflow errors after app deletion (#40977) 2026-08-19 12:24:40 +00:00
盐粒 Yanli
ea3fd4c093 perf(dify-agent): parallelize Agent Stub config pulls (#40973) 2026-08-19 10:35:24 +00:00
盐粒 Yanli
930f4e6c26 fix(dify-agent): persist interrupted run history (#40972) 2026-08-19 10:35:13 +00:00
非法操作
356e3a8ab6 refactor(api): snapshot explore banner feature flag (#40555) 2026-08-19 10:34:37 +00:00
非法操作
532489babd refactor(api): extract web app access queries (#40482) 2026-08-19 10:34:36 +00:00
yyh
6fa9c14490 fix(ui): stabilize toast stack interactions (#40970) 2026-08-19 10:13:43 +00:00
zyssyz123
d0149c3f94 fix(agent): resolve suggested questions from runtime config (#40963) 2026-08-19 07:54:38 +00:00
非法操作
2f6e782215 refactor(api): reuse app definition queries for service API site (#40795) 2026-08-19 07:23:47 +00:00
zyssyz123
414f211cde fix(auth): harden email code login verification (#40960) 2026-08-19 06:57:47 +00:00
Pranav Agarwal
f1b48a93a9 fix(api): enforce edit and RBAC permissions on model provider credential GET endpoints (#40899) (#40900) 2026-08-19 06:48:15 +00:00
盐粒 Yanli
8874f3c80b refactor(agent): remove Agent Drive (#40887)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-19 06:34:36 +00:00
yyh
297fdc7cb8 refactor(web): migrate HTTP timeout inputs (#40953) 2026-08-19 06:30:37 +00:00
yyh
73b15fc562 refactor(web): compose reset password fields (#40952) 2026-08-19 06:30:37 +00:00
yyh
90a6b8c998 refactor(web): simplify debounced filter state (#40951) 2026-08-19 06:30:36 +00:00
Riskey
8d18931125 fix: refine access point copy and remove undeploy state warning (#40886) 2026-08-19 04:11:44 +00:00
Nguyễn Phương
7444f96a1d refactor: clean away unnecessary-type-conversion (bool/str no-op wrappers) (#40822) 2026-08-19 03:48:45 +00:00
Wu Tianwei
8a33a9adb5 fix(web): remove only-me resource access option (#40956) 2026-08-19 02:50:07 +00:00
1873 changed files with 98906 additions and 36995 deletions

View File

@@ -27,7 +27,7 @@ Read only the packs matched by the diff:
- Stable Dify runtime invariants in the named paths: [`references/dify-invariants.md`][dify-invariants]
- General TypeScript or styling quality not owned above: [`references/code-quality.md`][code-quality]
Read `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, `web/docs/overlay.md`, or `web/docs/test.md` only when the reviewed code falls under that contract. Check current official documentation when local code and bundled references do not settle a framework, browser, or accessibility behavior.
Read `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, `packages/dify-ui/docs/overlays.md`, or `web/docs/test.md` only when the reviewed code falls under that contract. Check current official documentation when local code and bundled references do not settle a framework, browser, or accessibility behavior.
## Severity And Output

View File

@@ -1,142 +1,29 @@
# Dify UI Rules
# Dify UI Review Routing
Use these rules whenever a review touches `packages/dify-ui/` or code consuming `@langgenius/dify-ui/*`.
Use this reference when a review touches `packages/dify-ui/` or consumes
`@langgenius/dify-ui/*`. It routes to owner documentation; it does not redefine package
contracts.
Before finalizing findings for those files, read the current local docs that apply:
Read `packages/dify-ui/AGENTS.md` and the primitive implementation first, then only the matching
owner:
- `packages/dify-ui/README.md`
- `packages/dify-ui/AGENTS.md`
- `web/docs/overlay.md` for floating UI
- `packages/dify-ui/src/<primitive>/index.tsx` for the primitive being changed or consumed
| Review area | Canonical owner |
| ------------------------------------------ | ------------------------------------------------------------------- |
| Package boundary and document routing | `packages/dify-ui/AGENTS.md` |
| Imports, exports, public types, and anatomy | `packages/dify-ui/docs/authoring.md` |
| Button or icon-only actions | `packages/dify-ui/src/button/README.md`, `packages/dify-ui/src/icon-button/README.md` |
| Compound inputs | `packages/dify-ui/src/input-group/README.md` |
| Forms and field semantics | `packages/dify-ui/docs/forms.md` |
| Selection and typed values | `packages/dify-ui/docs/selection.md` |
| Portals, layers, and floating surfaces | `packages/dify-ui/docs/overlays.md` |
| Tailwind and radius tokens | `packages/dify-ui/docs/styling.md` |
| Package tests and Storybook | `packages/dify-ui/docs/testing.md` |
## Package Boundary
For consumer code under `web/`, also read `web/AGENTS.md` for application-owned reuse policy and
`packages/dify-ui/README.md` for the available public subpaths.
Flag in `packages/dify-ui`:
- Imports from `web/`.
- Dependencies on Next.js, i18n, ky, Jotai, Zustand, TanStack Query, oRPC, or business APIs.
- Business-specific component behavior that belongs in `web/`.
- Multiple unrelated primitives in one component folder.
`packages/dify-ui` is a primitive layer: Base UI headless components + `cva` + `cn` + Dify design tokens.
## Imports And Exports
Flag:
- Consumer imports from `@langgenius/dify-ui` without a subpath.
- Missing `package.json#exports` entry for a new primitive.
- Internal package imports using workspace subpaths instead of relative paths.
- Exported props using internal-only types that consumers cannot import from the component subpath.
- Canonical primitive boundaries or their associated public types using a redundant `Root` suffix when no higher-level convenience component exists in the same subpath.
Consumers use subpath exports such as `@langgenius/dify-ui/button`.
Canonical boundaries use the primitive name and matching public types (`Select` / `SelectProps`). Keep `Root` only to distinguish a low-level anatomy root from a higher-level convenience component (`CheckboxRoot` / `Checkbox`); implementation aliases should still show their Base UI source (`BaseSelect.Root.Props`).
## Props And State
Flag:
- Flattened props where related values need a discriminated union, such as `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`.
- React state used only to mirror Base UI state for class names.
- JavaScript conditional class logic for visual states that the Dify UI/Base UI primitive already exposes through `data-*` attributes or CSS variables.
- Controlled props added when uncontrolled DOM state or CSS variables would be enough.
- Thin wrappers that rename Base UI parts without adding semantics.
- Generic Base UI selection primitives wrapped without preserving their value generics, such as `Select.Root<Value, Multiple>`, `RadioGroup<Value>`, or `Radio.Root<Value>`.
- Shared select/radio option components that type selected values as `string` while callers pass enums, unions, booleans, numbers, objects, or nullable placeholder values.
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
For non-string `Select` and `RadioGroup` values, prefer explicit domain generics at the root and at child value carriers. JSX children do not inherit the parent generic, so `RadioGroup<PromptMode>` should compose with `Radio<PromptMode>`, `RadioItem<PromptMode>`, or option values from a typed collection. For `Select`, prefer the Base UI `items` collection pattern for typed value-to-label rendering, and flag string coercion helpers used only to recover display labels.
## Forms
Flag:
- Form-like UI using unrelated `Input` and `Button` pieces without a submit boundary.
- Text-like fields not composed through `Field`, `FieldLabel`, and `Input` or `InputGroupInput` when using Dify UI form semantics.
- Prefixes, suffixes, or actions manually layered over `Input` instead of using the canonical `InputGroup` composition.
- Select fields using `FieldLabel` instead of `SelectLabel`.
- Slider fields using a generic label instead of `SliderLabel`.
- Checkbox/radio groups missing `Fieldset` and `FieldsetLegend`.
- Field errors or descriptions rendered without `FieldDescription` / `FieldError` relationships.
`Form` is the submit boundary. Dify UI form primitives are not a form state-management framework; business validation and schema-driven behavior belong in `web/`.
## Overlay Contract
Flag:
- Legacy web overlay imports in new or modified code.
- Manual portals around Dify UI overlay primitives.
- Call-site `z-*` overrides on overlays.
- Missing root `isolation: isolate` assumptions when debugging overlay stacking.
- Repeated backdrop, z-index, or portal chrome at call sites.
- Tooltip used for infotips, long text, or interactive content.
All Dify UI body-portalled overlays use `z-50`. Toast uses `z-60`. DOM order handles stacking between overlays.
## Primitive Selection
Flag:
- `Tabs` used for simple mode/filter/view selection where `SegmentedControl` is the semantic primitive.
- `SegmentedControl` used where `tablist` / `tabpanel` semantics are required.
- `Select` used for searchable or free-form input.
- `Combobox` used for unrestricted search text where no selected option is remembered.
- `Autocomplete` used for closed-list selection.
- Tooltip or PreviewCard used for content that must be reachable on touch or by screen readers.
Use:
- `Autocomplete` for free-form text with optional suggestions.
- `Combobox` for searchable selected values from a collection.
- `Select` for closed, scannable option sets.
- `Popover` for infotips, help text, rich content, or interactions.
## Bad Usage Patterns To Flag
Flag:
- Manually recreating UI behavior or chrome already owned by `@langgenius/dify-ui/*` or `web/app/components/base/*`, such as buttons, inputs, toggle groups, popovers, dropdown menus, alert dialogs, switches, avatars, scroll areas, toasts, borders, focus states, disabled states, segmented controls, or existing feature components.
- Styling a raw Base UI primitive directly in `web/` when a Dify UI primitive exists.
- Wrapping a Dify UI primitive in a feature component that hides its label, error, disabled, or focus contract.
- Replacing a semantic primitive with a generic `div` plus classes to match a screenshot.
- Using `Tooltip` because it is visually convenient when the content is actually help text or needs touch access.
- Adding a `z-*` override to make a child popup appear over a parent dialog.
- Adding a new app-level wrapper around Dialog, Drawer, Popover, Select, or Combobox that repeats portal/backdrop/positioner logic.
- Using dify-ui `Input` as a drop-in replacement for legacy inputs that include search, clear, copy, unit, localized placeholder, or number normalization behavior.
- Building a form row from loose text and controls instead of the matching Field/Form primitives.
- Adding component state only to style `data-open`, `data-checked`, `data-disabled`, or highlighted states that Base UI already exposes.
- Passing booleans down only so children can toggle classes already expressible with primitive `data-*` selectors.
## Tokens, Radius, And Styling
Flag:
- `radius-*` class names.
- Custom Tailwind `borderRadius` extension for Figma radius values.
- Generic colors where semantic Dify tokens exist.
- Hardcoded design values where Dify tokens, component variants, or documented Figma radius mappings exist.
- `!` important modifiers used to fight primitive styles instead of fixing the variant, selector, or component composition.
- Manual class strings that duplicate primitive variants.
- `min-w-(--anchor-width)` on picker popups when it defeats viewport clamping.
Use the Figma radius mapping from `packages/dify-ui/README.md`; for example `--radius/sm` maps to `rounded-md`, and `--radius/md` maps to `rounded-lg`.
Use `!` only for a tightly scoped compatibility override after confirming the primitive API, data attributes, and selector structure cannot express the state.
## Focus Details
Flag focus rings attached to the wrong element. For example, Base UI `Slider.Thumb` focuses an internal `input[type=range]`, so the visible thumb wrapper needs `has-[:focus-visible]` rather than direct wrapper `focus-visible`.
## Custom SVG Icons
Flag:
- New generated React icon components or JSON files under `web/app/components/base/icons/src/...` for custom SVG icons.
- Custom SVG icons consumed outside the Tailwind `i-custom-*` icon class pipeline.
- Generated `packages/iconify-collections/custom-*/icons.json` diffs where unrelated existing icons lost or changed intrinsic `width` or `height`.
New custom SVG icons belong in `packages/iconify-collections/assets/...`. Regenerate with `pnpm --filter @dify/iconify-collections generate`, validate with `pnpm --filter @dify/iconify-collections check:dimensions`, and consume the generated icon with Tailwind `i-custom-*` classes.
Treat the implementation, public types, tests, and stories as evidence for the documented
contract. If they disagree, identify the actual owner before reporting a finding. Read current
official Base UI documentation and installed type declarations for upstream-derived behavior.
Report only a reproducible contract violation or observable defect, not a preference inferred from
this routing file.

View File

@@ -13,4 +13,4 @@ description: Use when writing or changing Vitest or React Testing Library tests
4. Run the focused spec before the affected suite and relevant static checks.
5. Report the behavior verified and any remaining browser, visual, or end-to-end risk.
Recommend deleting low-value tests as readily as adding missing behavior coverage. Use `web/docs/test.md` for policy and Web commands; use the `packages/dify-ui/README.md` Development section for Dify UI commands.
Recommend deleting low-value tests as readily as adding missing behavior coverage. Use `web/docs/test.md` for policy and Web commands; use `packages/dify-ui/docs/testing.md` for Dify UI commands.

View File

@@ -32,7 +32,7 @@ Use this skill to route component architecture decisions to its bundled referenc
1. Identify the behavior owner and the public contract being changed.
2. Read the nearby implementation, tests, and only the routed skill references.
3. Implement one coherent vertical slice. Do not expand into equivalent patterns elsewhere unless the current contract cannot be completed without them.
4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and the `packages/dify-ui/README.md` Development section for Dify UI.
4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and `packages/dify-ui/docs/testing.md` for Dify UI.
[data]: references/data.md
[interactions]: references/interactions.md

View File

@@ -1,6 +1,6 @@
# Component Interactions And Overlays
Read this document when a change involves application hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces. Overlay primitive selection and layering are owned by the [overlay guide].
Read this document when a change involves application hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces. Overlay primitive selection and layering are owned by the [overlay contract].
## Focus And Semantics
@@ -20,12 +20,11 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
## Secondary Surfaces
- Follow `web/docs/overlay.md` for primitive choice. Dify UI primitives are the default, with package-approved Web wrappers such as `Infotip` where the overlay guide allows them.
- Follow the [overlay contract] for primitive choice and shared mechanics. The nearest consumer `AGENTS.md` owns application-specific composite reuse policy.
- Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment.
- Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content.
- Mount controlled overlays unconditionally unless unmounting is required for performance or reset semantics. Prefer keyed or owner-local reset over conditional wrappers.
- Put query and mutation work inside dialog or alert-dialog content when it should mount only after opening.
- Prefer uncontrolled roots when the primitive can own open state. Use controlled state only for business coordination, analytics, cleanup, or explicit reset behavior.
- Do not add manual portals or call-site z-index escalation. Fix ownership and stacking structure at the shared boundary.
[overlay guide]: ../../../../web/docs/overlay.md
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md

View File

@@ -1,13 +0,0 @@
have_fun: false
memory_config:
disabled: false
code_review:
disable: true
comment_severity_threshold: MEDIUM
max_review_comments: -1
pull_request_opened:
help: false
summary: false
code_review: false
include_drafts: false
ignore_patterns: []

4
.github/CODEOWNERS vendored
View File

@@ -267,5 +267,9 @@
/web/app/auth/refresh/route.ts @iamjoel @lyzno1
/web/service/server.ts @iamjoel @lyzno1
# Frontend - Browser Mode Tests
/web/app/**/*.browser.spec.ts @lyzno1
/web/app/**/*.browser.spec.tsx @lyzno1
# Docker
/docker/* @laipz8200

View File

@@ -9,7 +9,7 @@ runs:
with:
run_install: false
- name: Setup Vite+
uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
uses: voidzero-dev/setup-vp@1b32467adbe183473499fd9d5d372c3ed9641754 # v1.18.0
with:
node-version-file: .nvmrc
cache: true

View File

@@ -36,7 +36,7 @@ concurrency:
jobs:
accessibility:
name: WCAG Level ${{ inputs.level == 'a' && 'A' || 'AA' }} · ${{ inputs.page }}
runs-on: depot-ubuntu-24.04-4
runs-on: ubuntu-24.04
timeout-minutes: 120
defaults:
run:

View File

@@ -103,7 +103,7 @@ jobs:
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Set up Depot CLI
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2
- name: Extract metadata for Docker
id: meta
@@ -160,7 +160,7 @@ jobs:
file: 'docker/Dockerfile'
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Validate Docker image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@@ -246,7 +246,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0

View File

@@ -0,0 +1,25 @@
name: Deploy HITL IM Dev
on:
workflow_run:
workflows: ['Build and Push API & Web']
branches:
- 'build/feat/hitl-im-dev'
types:
- completed
jobs:
deploy:
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'build/feat/hitl-im-dev'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.HITL_SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}

View File

@@ -65,7 +65,7 @@ jobs:
file: 'docker/Dockerfile'
steps:
- name: Set up Depot CLI
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2
- name: Build Docker Image
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
@@ -96,7 +96,7 @@ jobs:
file: 'docker/Dockerfile'
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build Docker Image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0

View File

@@ -162,7 +162,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@9d7150bc8a3dae8149739a88019d192b579ad90c # v1.0.193
uses: anthropics/claude-code-action@dcb57747bfceeaa1fa72638cae52295d1d853d4a # v1.0.199
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -29,7 +29,7 @@ jobs:
persist-credentials: false
- name: Free Disk Space
uses: endersonmenezes/free-disk-space@7901478139cff6e9d44df5972fd8ab8fcade4db1 # v3.2.2
uses: endersonmenezes/free-disk-space@2a22f8c59cae9cddae7194b33e6b92d023b2b4b8 # v4.0.0
with:
remove_dotnet: true
remove_haskell: true

View File

@@ -26,7 +26,7 @@ jobs:
persist-credentials: false
- name: Free Disk Space
uses: endersonmenezes/free-disk-space@7901478139cff6e9d44df5972fd8ab8fcade4db1 # v3.2.2
uses: endersonmenezes/free-disk-space@2a22f8c59cae9cddae7194b33e6b92d023b2b4b8 # v4.0.0
with:
remove_dotnet: true
remove_haskell: true

View File

@@ -14,7 +14,7 @@ permissions:
jobs:
test:
name: Web Full-Stack E2E
runs-on: depot-ubuntu-24.04-4
runs-on: ubuntu-24.04
timeout-minutes: 120
defaults:
run:

View File

@@ -33,7 +33,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Run tests
run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
run: vp test run --project unit --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
- name: Upload blob report
if: ${{ !cancelled() }}
@@ -46,7 +46,7 @@ jobs:
browser-test:
name: Web Browser Tests
runs-on: depot-ubuntu-24.04-4
runs-on: ubuntu-24.04
timeout-minutes: 20
defaults:
run:
@@ -67,7 +67,17 @@ jobs:
run: vp exec playwright install --with-deps --only-shell chromium
- name: Run browser tests
run: vp test run --config vitest.browser.config.ts --silent=passed-only
run: vp test run --project browser --silent=passed-only
- name: Upload browser failure artifacts
if: ${{ failure() && !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: browser-test-failures
path: web/.vitest-browser/
if-no-files-found: ignore
include-hidden-files: true
retention-days: 7
merge-reports:
name: Merge Test Reports
@@ -111,7 +121,7 @@ jobs:
dify-ui-test:
name: dify-ui Tests
runs-on: depot-ubuntu-24.04-4
runs-on: ubuntu-24.04
timeout-minutes: 20
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@@ -136,6 +146,16 @@ jobs:
- name: Run dify-ui tests
run: vp test run --project unit --coverage --silent=passed-only
- name: Upload dify-ui test failure artifacts
if: ${{ failure() && !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dify-ui-test-failures
path: packages/dify-ui/.vitest-browser/
if-no-files-found: ignore
include-hidden-files: true
retention-days: 7
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
@@ -147,7 +167,7 @@ jobs:
dify-ui-storybook-test:
name: dify-ui Storybook Tests
runs-on: depot-ubuntu-24.04-4
runs-on: ubuntu-24.04
timeout-minutes: 20
defaults:
run:
@@ -169,3 +189,13 @@ jobs:
- name: Run dify-ui Storybook tests
run: vp run test:storybook
- name: Upload dify-ui Storybook test failure artifacts
if: ${{ failure() && !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dify-ui-storybook-test-failures
path: packages/dify-ui/.vitest-browser/
if-no-files-found: ignore
include-hidden-files: true
retention-days: 7

1
.gitignore vendored
View File

@@ -263,6 +263,7 @@ scripts/stress-test/reports/
.qoder/*
.context/
# Vitest local reports
web/.vitest-browser/
web/.vitest-reports/
# dify-agent-runtime

View File

@@ -129,83 +129,35 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
Star Dify on GitHub and be instantly notified of new releases.
![star-us](https://github.com/langgenius/dify/assets/13230914/b823edc1-6388-4e25-ad45-2f6b187adbb4)
<img width="1344" height="720" alt="star" src="https://github.com/user-attachments/assets/dcd086d1-af0f-471b-ae52-1ad2fa040595" />
## Advanced Setup
### Custom configurations
If you need to customize the configuration, edit `docker/.env`. The essential startup defaults live in [`docker/.env.example`](docker/.env.example), and optional advanced variables are split under `docker/envs/` by theme. After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
### Metrics Monitoring with Grafana
Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more.
- [Grafana Dashboard by @bowenliang123](https://github.com/bowenliang123/dify-grafana-dashboard)
### Deployment with Kubernetes
If you'd like to configure a highly available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes.
- [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify)
- [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
- [Helm Chart by @magicsong](https://github.com/magicsong/ai-charts)
- [YAML file by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
- [🚀 NEW! YAML files (Supports Dify v1.6.0) by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Using Terraform for Deployment
Deploy Dify to Cloud Platform with a single click using [terraform](https://www.terraform.io/)
##### Azure Global
- [Azure Terraform by @nikawang](https://github.com/nikawang/dify-azure-terraform)
##### Google Cloud
- [Google Cloud Terraform by @sotazum](https://github.com/DeNA/dify-google-cloud-terraform)
#### Using AWS CDK for Deployment
Deploy Dify to AWS with [CDK](https://aws.amazon.com/cdk/)
##### AWS
- [AWS CDK by @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
- [AWS CDK by @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
#### Using Alibaba Cloud Computing Nest
Quickly deploy Dify to Alibaba cloud with [Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
#### Using Alibaba Cloud Data Management
One-Click deploy Dify to Alibaba Cloud with [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
#### Deploy to AKS with Azure Devops Pipeline
One-Click deploy Dify to AKS with [Azure Devops Pipeline Helm Chart by @LeoZhang](https://github.com/Ruiruiz30/Dify-helm-chart-AKS)
For custom configuration, observability, and deployment options, see [Advanced Setup](docs/ADVANCED_SETUP.md).
## Contributing
For those who'd like to contribute code, see our [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
At the same time, please consider supporting Dify by sharing it on social media and at events and conferences.
Dify welcomes contributions of all kinds:
> We are looking for contributors to help translate Dify into languages other than Mandarin or English. If you are interested in helping, please see the [i18n README](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) for more information, and leave us a comment in the `global-users` channel of our [Discord Community Server](https://discord.gg/8Tpq4AcN9c).
- **Code**: Read the [Contribution Guide](CONTRIBUTING.md), then browse [good first issues](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22).
- **Ideas and feedback**: Start or join a [GitHub Discussion](https://github.com/langgenius/dify/discussions).
- **Translations**: Follow the [internationalization guide](web/i18n-config/README.md) to add or update a locale.
- **Community**: Share the apps you build, help other users, and spread the word about Dify.
### Contributors
<a href="https://github.com/langgenius/dify/graphs/contributors">
<img alt="Dify contributors" src="https://contrib.rocks/image?repo=langgenius/dify" />
</a>
## Community & contact
- [GitHub Discussion](https://github.com/langgenius/dify/discussions). Best for: sharing feedback and asking questions.
- [GitHub Issues](https://github.com/langgenius/dify/issues). Best for: bugs you encounter using Dify.AI, and feature proposals. See our [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
- [Discord](https://discord.gg/FngNHpbcY7). Best for: sharing your applications and hanging out with the community.
- [X(Twitter)](https://twitter.com/dify_ai). Best for: sharing your applications and hanging out with the community.
Choose the channel that best fits your question:
**Contributors**
<a href="https://github.com/langgenius/dify/graphs/contributors">
<img src="https://contrib.rocks/image?repo=langgenius/dify" />
</a>
- [GitHub Discussions](https://github.com/langgenius/dify/discussions): Get help, share feedback, and propose ideas.
- [GitHub Issues](https://github.com/langgenius/dify/issues): Report reproducible bugs and track engineering work. Read the [Contribution Guide](CONTRIBUTING.md) before opening one.
- [Discord](https://discord.gg/FngNHpbcY7): Chat in real time, share your apps, and connect with other Dify users.
- [X](https://x.com/dify_ai): Follow Dify for release news and project updates.
## Star History

View File

@@ -98,9 +98,11 @@ REDIS_KEEPALIVE=true
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
CELERY_BACKEND=redis
# Ops trace retry configuration
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60
# Ops trace configuration
OPS_TRACE_UNIFIED_ENABLED=false
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=780
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5
OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS=3900
# Database configuration
DB_TYPE=postgresql
@@ -492,6 +494,8 @@ SENTRY_DSN=
TURNSTILE_SECRET_KEY=
# Comma-separated parent or exact hostnames, for example: dify.ai,staging.dify.dev
TURNSTILE_ALLOWED_HOSTNAMES=
# Enable only after the compatible web client has been fully deployed.
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=false
# DEBUG
DEBUG=false
@@ -583,7 +587,7 @@ INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
# Workflow runtime configuration
WORKFLOW_MAX_EXECUTION_STEPS=500
WORKFLOW_MAX_EXECUTION_TIME=1200
WORKFLOW_MAX_EXECUTION_TIME=3600
WORKFLOW_CALL_MAX_DEPTH=5
MAX_VARIABLE_SIZE=204800
# Maximum concurrent node-builder LLM calls per workflow generation request
@@ -628,7 +632,7 @@ WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS=
# App configuration
APP_MAX_EXECUTION_TIME=1200
APP_MAX_EXECUTION_TIME=3600
APP_DEFAULT_ACTIVE_REQUESTS=0
APP_MAX_ACTIVE_REQUESTS=0
@@ -707,6 +711,8 @@ AGENT_BACKEND_BASE_URL=http://localhost:5050
AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
# Client deadline for converting a Binding file to a ToolFile through the Agent backend.
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=240
# KnowledgeFS (Dataset 2.0)
KNOWLEDGE_FS_ENABLED=false
@@ -733,6 +739,8 @@ RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5
EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5
CREATE_TIDB_SERVICE_JOB_ENABLED=false
@@ -842,3 +850,6 @@ CONVERSATION_CLEANUP_BATCH_SIZE=100
DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0
# Connect timeout for Nacos requests (default: 3.0)
DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0
# skill entry
ENABLE_SKILL=true

View File

@@ -15,6 +15,117 @@ root_packages =
services
include_external_packages = True
[importlinter:contract:backend-layers]
name = Backend modules follow the transport, application, domain, and shared-library layers
type = layers
layers =
controllers
services
core
libs
# Migration baseline: keep every exception exact. The default unmatched-import
# error makes this list shrink whenever a legacy dependency is removed.
unmatched_ignore_imports_alerting = error
ignore_imports =
core.app.app_config.easy_ui_based_app.dataset.manager -> services.dataset_service
core.app.apps.advanced_chat.app_generator -> controllers.console.app.workflow
core.app.apps.advanced_chat.app_generator -> services.conversation_service
core.app.apps.advanced_chat.app_generator -> services.errors.conversation
core.app.apps.advanced_chat.app_generator -> services.workflow_draft_variable_service
core.app.apps.advanced_chat.app_runner -> services.conversation_variable_updater
core.app.apps.agent_app.app_generator -> services.agent.composer_service
core.app.apps.agent_app.app_generator -> services.agent.workspace_service
core.app.apps.agent_app.app_generator -> services.conversation_service
core.app.apps.agent_app.runtime_request_builder -> services.agent.prompt_mentions
core.app.apps.agent_app.session_store -> services.agent.workspace_service
core.app.apps.agent_chat.app_generator -> services.conversation_service
core.app.apps.base_app_generator -> services.workflow_draft_variable_service
core.app.apps.chat.app_generator -> services.conversation_service
core.app.apps.common.workflow_response_converter -> services.variable_truncator
core.app.apps.completion.app_generator -> services.errors.app
core.app.apps.completion.app_generator -> services.errors.message
core.app.apps.message_based_app_generator -> services.errors.app_model_config
core.app.apps.message_based_app_generator -> services.errors.conversation
core.app.apps.message_based_app_generator -> services.errors.message
core.app.apps.pipeline.pipeline_generator -> services
core.app.apps.pipeline.pipeline_generator -> services.dataset_service
core.app.apps.pipeline.pipeline_generator -> services.datasource_provider_service
core.app.apps.pipeline.pipeline_generator -> services.feature_service
core.app.apps.pipeline.pipeline_generator -> services.workflow_draft_variable_service
core.app.apps.pipeline.pipeline_runner -> services.dataset_ref_service
core.app.apps.workflow.app_generator -> controllers.console.app.workflow
core.app.apps.workflow.app_generator -> services.snippet_generate_service
core.app.apps.workflow.app_generator -> services.workflow_draft_variable_service
core.app.apps.workflow_app_runner -> tasks.mail_human_input_delivery_task
core.app.features.annotation_reply.annotation_reply -> services.annotation_service
core.app.features.annotation_reply.annotation_reply -> services.dataset_service
core.app.llm.quota -> services.credit_pool_service
core.app.task_pipeline.message_cycle_manager -> services.account_service
core.app.task_pipeline.message_cycle_manager -> services.annotation_service
core.app.workflow.layers.persistence -> services.workflow.inspector_events
core.datasource.datasource_manager -> services.datasource_provider_service
core.helper.creators -> services.oauth_server
core.helper.credential_utils -> services.enterprise.plugin_manager_service
core.helper.credential_utils -> services.feature_service
core.indexing_runner -> services.vector_space_admission_service
core.mcp.auth_client -> services
core.provider_manager -> services.credential_permission_service
core.provider_manager -> services.credit_pool_service
core.provider_manager -> services.feature_service
core.rag.datasource.retrieval_service -> services.external_knowledge_service
core.rag.index_processor.index_processor -> services.vector_space_admission_service
core.rag.index_processor.index_processor_base -> services.file_service
core.rag.index_processor.processor.paragraph_index_processor -> services.account_service
core.rag.index_processor.processor.paragraph_index_processor -> services.summary_index_service
core.rag.index_processor.processor.parent_child_index_processor -> services.account_service
core.rag.index_processor.processor.parent_child_index_processor -> services.summary_index_service
core.rag.index_processor.processor.qa_index_processor -> services.summary_index_service
core.rag.retrieval.dataset_retrieval -> services.external_knowledge_service
core.rag.retrieval.dataset_retrieval -> services.feature_service
core.rag.summary_index.summary_index -> services.summary_index_service
core.rag.summary_index.summary_index -> tasks.generate_summary_index_task
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.file_service
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.variable_truncator
core.tools.tool_manager -> services
core.workflow.node_runtime -> services
core.workflow.nodes.agent.message_transformer -> services
core.workflow.nodes.agent_v2.agent_node -> services.agent.prompt_mentions
core.workflow.nodes.agent_v2.agent_node -> services.agent.workspace_service
core.workflow.nodes.agent_v2.dify_tools_builder -> services
core.workflow.nodes.agent_v2.runtime_feature_manifest -> services.agent.knowledge_datasets
core.workflow.nodes.agent_v2.runtime_request_builder -> services.agent.prompt_mentions
core.workflow.nodes.agent_v2.runtime_request_builder -> services.skill_management_service
core.workflow.nodes.agent_v2.session_store -> services.agent.workspace_service
core.workflow.nodes.agent_v2.validators -> services.agent.knowledge_datasets
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
libs.device_flow_security -> controllers.openapi._models
libs.device_flow_security -> services.entities.feature_entities
libs.device_flow_security -> services.feature_service
libs.email_i18n -> services.entities.feature_entities
libs.email_i18n -> services.feature_service
libs.external_api -> core
libs.external_api -> core.errors.error
libs.external_api -> extensions.ext_logging
libs.flask_utils -> models
libs.helper -> core.app.features.rate_limiting.rate_limit
libs.helper -> models
libs.helper -> models.model
libs.json_in_md_parser -> core.llm_generator.output_parser.errors
libs.login -> extensions.ext_login
libs.login -> models
libs.login -> models.model
libs.oauth -> core.helper.http_client_pooling
libs.oauth_bearer -> models
libs.oauth_data_source -> core.db.session_factory
libs.oauth_data_source -> core.helper.http_client_pooling
libs.rsa -> extensions.ext_storage
libs.workspace_permission -> services.enterprise.enterprise_service
libs.workspace_permission -> services.feature_service
services.account_service -> controllers
services.account_service -> controllers.console.error
services.app_generate_service -> controllers.console.app.workflow
services.billing_service -> controllers.console.error
[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
@@ -93,6 +204,33 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:account-application-boundary]
name = Account application services and contracts are framework and persistence neutral
type = forbidden
source_modules =
services.account_avatar_service
services.account_change_email_ports
services.account_change_email_service
services.account_deletion_service
services.account_deletion_feedback_service
services.account_education_service
services.account_errors
services.account_initialization_service
services.account_integration_service
services.account_password_service
services.account_ports
services.account_profile_service
services.entities.account_entities
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:app-definition-query-service-boundary]
name = App definition query application service is framework and persistence neutral
type = forbidden
@@ -107,6 +245,41 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:webapp-access-query-service-boundary]
name = Web app access query application service is framework and persistence neutral
type = forbidden
source_modules =
services.webapp_access_query_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
services.enterprise
services.feature_service
sqlalchemy
werkzeug
[importlinter:contract:web-app-runtime-query-service-boundary]
name = Web app runtime query application service does not directly depend on configuration, feature implementation, transport, or ORM modules
type = forbidden
source_modules =
services.web_app_runtime_query_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
services.feature_service
services.feature_service_gateway
sqlalchemy
werkzeug
allow_indirect_imports = True
[importlinter:contract:feature-query-service-boundary]
name = Feature query application service is framework and persistence neutral
type = forbidden
@@ -183,3 +356,60 @@ forbidden_modules =
repositories
sqlalchemy
werkzeug
[importlinter:contract:tag-application-service-boundary]
name = Tag application service is framework and persistence neutral
type = forbidden
source_modules =
services.tag_application_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-query-service-boundary]
name = Recommended app query application service is framework and persistence neutral
type = forbidden
source_modules =
services.recommended_app_query_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
services.feature_service
services.recommended_app_catalog_gateway
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-catalog-gateway-boundary]
name = Recommended app catalog gateway does not depend on Flask
type = forbidden
source_modules =
services.recommended_app_catalog_gateway
forbidden_modules =
flask
[importlinter:contract:billing-application-boundary]
name = Billing application services and ports are framework and infrastructure neutral
type = forbidden
source_modules =
services.billing_portal_service
services.partner_tenant_binding_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
services.billing_service
sqlalchemy
werkzeug

View File

@@ -8,11 +8,40 @@ classes.
from __future__ import annotations
from http import HTTPStatus
from typing import Any
from dify_agent.client import DifyAgentHTTPError
from dify_agent.protocol import RunFailureType
def backend_reported_failure(exc: DifyAgentHTTPError) -> bool:
"""Whether the backend itself reported the failure.
``DifyAgentValidationError`` is a ``DifyAgentHTTPError`` that the client also
raises when a *successful* response fails DTO validation. Those carry a 2xx
status and a Pydantic error list, not a backend-authored error envelope.
"""
return exc.status_code >= HTTPStatus.BAD_REQUEST
def backend_error_detail(
exc: DifyAgentHTTPError,
*,
default_code: str = "agent_backend_error",
) -> tuple[str, str]:
"""Split an Agent backend HTTP error into the code and message the backend itself sent."""
detail = exc.detail
if isinstance(detail, dict):
code = detail.get("code")
message = detail.get("message")
return (
code if isinstance(code, str) and code else default_code,
message if isinstance(message, str) and message else str(exc),
)
return default_code, str(detail)
class AgentBackendError(Exception):
"""Base error for API-side Agent backend integration failures."""

View File

@@ -8,10 +8,23 @@ from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackend
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
def create_agent_backend_client(*, base_url: str, api_token: str | None = None, stream_timeout: float = 30) -> Client:
def create_agent_backend_client(
*,
base_url: str,
api_token: str | None = None,
stream_timeout: float = 30,
timeout: float = 30.0,
binding_file_download_timeout: float = 240,
) -> Client:
api_token = api_token.strip() if api_token else None
headers = {"Authorization": f"Bearer {api_token}"} if api_token else None
return Client(base_url=base_url, stream_timeout=stream_timeout, headers=headers)
return Client(
base_url=base_url,
stream_timeout=stream_timeout,
timeout=timeout,
binding_file_download_timeout=binding_file_download_timeout,
headers=headers,
)
def create_agent_backend_run_client(

View File

@@ -28,7 +28,6 @@ from dify_agent.layers.dify_plugin import (
DifyPluginLLMLayerConfig,
DifyPluginToolsLayerConfig,
)
from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
from dify_agent.layers.execution_context import (
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
DifyExecutionContextLayerConfig,
@@ -56,7 +55,6 @@ 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"
DIFY_CORE_TOOLS_LAYER_ID = "core_tools"
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
@@ -72,24 +70,10 @@ def _shell_layer_deps() -> dict[str, str]:
}
def _drive_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _config_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _shell_config_with_drive_ref(
shell_config: DifyShellLayerConfig | None,
drive_config: DifyDriveLayerConfig | None,
) -> DifyShellLayerConfig:
config = shell_config or DifyShellLayerConfig()
if drive_config is None:
return config
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
def _markdown_backtick_fence(text: str) -> str:
"""Choose a fence that will not terminate inside the prompt body."""
longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
@@ -224,9 +208,6 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
core_tools: DifyCoreToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement; a deferred call ends the run and
# the workflow pauses via the existing HITL form mechanism (ENG-635).
@@ -273,9 +254,6 @@ class AgentBackendAgentAppRunInput(BaseModel):
core_tools: DifyCoreToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content.
drive_config: DifyDriveLayerConfig | None = None
# 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
@@ -307,7 +285,7 @@ class AgentBackendRunRequestBuilder:
"""Build an Agent App conversation-turn run request.
Layer graph: optional Agent Soul system prompt → user prompt →
execution context → optional shell / config / drive / history
execution context → optional shell / config / history
(multi-turn) → LLM → optional plugin-direct tools / core-routed tools /
knowledge search / ask_human / structured output. Mirrors the
workflow-node layer ordering minus the workflow-job / previous-node
@@ -345,9 +323,7 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = (
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
)
include_shell = run_input.include_shell or run_input.config_layer_config is not None
if include_shell:
layers.append(
RunLayerSpec(
@@ -357,16 +333,15 @@ class AgentBackendRunRequestBuilder:
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.
# Sandboxed bash workspace (dify.shell). It enters before config so
# eager pulls materialize content in the same filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
config=run_input.shell_config or DifyShellLayerConfig(),
)
)
@@ -381,19 +356,6 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps=_drive_layer_deps(),
metadata=run_input.metadata,
config=run_input.drive_config,
)
)
if run_input.include_history:
layers.append(
RunLayerSpec(
@@ -495,7 +457,7 @@ class AgentBackendRunRequestBuilder:
"""Build a workflow Agent Node run request without defining another wire schema.
Layer graph mirrors the workflow surface: prompts → execution context →
optional shell / config / drive / history → LLM → optional
optional shell / config / history → LLM → optional
plugin-direct tools / core-routed tools / knowledge search /
ask_human / structured output.
"""
@@ -537,9 +499,7 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = (
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
)
include_shell = run_input.include_shell or run_input.config_layer_config is not None
if include_shell:
layers.append(
RunLayerSpec(
@@ -549,16 +509,15 @@ class AgentBackendRunRequestBuilder:
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.
# Sandboxed bash workspace (dify.shell). It enters before config so
# eager pulls materialize content in the same filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
config=run_input.shell_config or DifyShellLayerConfig(),
)
)
@@ -573,19 +532,6 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps=_drive_layer_deps(),
metadata=run_input.metadata,
config=run_input.drive_config,
)
)
if run_input.include_history:
layers.append(
RunLayerSpec(

View File

@@ -20,7 +20,7 @@ def reset_password(email, new_password, password_confirm):
Reset password of owner account
Only available in SELF_HOSTED mode
"""
if str(new_password).strip() != str(password_confirm).strip():
if new_password.strip() != password_confirm.strip():
click.echo(click.style("Passwords do not match.", fg="red"))
return
normalized_email = email.strip().lower()
@@ -62,7 +62,7 @@ def reset_email(email, new_email, email_confirm):
Replace account email
:return:
"""
if str(new_email).strip() != str(email_confirm).strip():
if new_email.strip() != email_confirm.strip():
click.echo(click.style("New emails do not match.", fg="red"))
return
normalized_new_email = new_email.strip().lower()

View File

@@ -83,11 +83,6 @@ class EnterpriseTelemetryConfig(BaseSettings):
default=False,
)
ENTERPRISE_SERVICE_NAME: str = Field(
description="Service name for OTEL resource.",
default="dify",
)
ENTERPRISE_OTEL_SAMPLING_RATE: float = Field(
description="Sampling rate for enterprise traces (0.0 to 1.0, default 1.0 = 100%).",
default=1.0,

View File

@@ -1,6 +1,7 @@
from configs.extra.agent_backend_config import AgentBackendConfig
from configs.extra.archive_config import ArchiveStorageConfig
from configs.extra.knowledge_fs_config import KnowledgeFSConfig
from configs.extra.logstore_config import LogStoreConfig
from configs.extra.notion_config import NotionConfig
from configs.extra.sentry_config import SentryConfig
from configs.extra.turnstile_config import TurnstileConfig
@@ -11,6 +12,7 @@ class ExtraServiceConfig(
AgentBackendConfig,
ArchiveStorageConfig,
KnowledgeFSConfig,
LogStoreConfig,
NotionConfig,
SentryConfig,
TurnstileConfig,

View File

@@ -37,6 +37,19 @@ class AgentBackendConfig(BaseSettings):
default=3,
)
AGENT_BACKEND_HOME_SNAPSHOT_TIMEOUT_SECONDS: PositiveFloat = Field(
description=(
"Client timeout for Agent backend calls that may carry a Home Snapshot transfer: "
"snapshot capture and delete, and Execution Binding creation that restores one. "
),
default=45.0,
)
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS: PositiveFloat = Field(
description="Client timeout for converting a Binding file to a ToolFile through the Agent backend.",
default=240,
)
AGENT_SHELL_ENABLED: bool = Field(
description=(
"Inject the Home, Workspace, Sandbox, and Shell runtime layers into Agent runs. "

View File

@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class LogStoreConfig(BaseSettings):
"""Migration controls for repositories backed by Aliyun LogStore."""
LOGSTORE_DUAL_WRITE_ENABLED: bool = False
# Keep workflow graphs in LogStore by default. Deployments may disable this
# while migrating large graph payloads to another persistence owner.
LOGSTORE_ENABLE_PUT_GRAPH_FIELD: bool = True

View File

@@ -13,6 +13,13 @@ class TurnstileConfig(BaseSettings):
default="",
description="Comma-separated parent or exact hostnames accepted from Turnstile.",
)
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED: bool = Field(
default=False,
description=(
"Require a separate Turnstile challenge when verifying email login codes on Dify Cloud. "
"Enable after the compatible web client has been deployed."
),
)
@field_validator("TURNSTILE_SECRET_KEY", mode="before")
@classmethod

View File

@@ -12,6 +12,7 @@ from pydantic import (
PositiveInt,
computed_field,
field_validator,
model_validator,
)
from pydantic_settings import BaseSettings
@@ -83,7 +84,7 @@ class AppExecutionConfig(BaseSettings):
APP_MAX_EXECUTION_TIME: PositiveInt = Field(
description="Maximum allowed execution time for the application in seconds",
default=1200,
default=3600,
)
APP_DEFAULT_ACTIVE_REQUESTS: NonNegativeInt = Field(
description="Default number of concurrent active requests per app (0 for unlimited)",
@@ -889,7 +890,7 @@ class WorkflowConfig(BaseSettings):
WORKFLOW_MAX_EXECUTION_TIME: PositiveInt = Field(
description="Maximum execution time in seconds for a single workflow",
default=1200,
default=3600,
)
WORKFLOW_CALL_MAX_DEPTH: PositiveInt = Field(
@@ -1290,6 +1291,13 @@ class DataSetConfig(BaseSettings):
)
class SkillConfig(BaseSettings):
ENABLE_SKILL: bool = Field(
description="Enable or disable Skill feature entry points",
default=True,
)
class WorkspaceConfig(BaseSettings):
"""
Configuration for workspace management
@@ -1336,9 +1344,16 @@ class NewAgentBetaConfig(BaseSettings):
class OpsTraceConfig(BaseSettings):
OPS_TRACE_UNIFIED_ENABLED: bool = Field(
description="Enable unified ops tracing for providers registered in the unified registry.",
default=False,
)
# Include scheduling and export grace after the parent workflow's maximum execution time.
# Recommended: max_retries >= ceil((WORKFLOW_MAX_EXECUTION_TIME + grace_seconds) / delay_seconds).
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field(
description="Maximum retry attempts for transient ops trace provider dispatch failures.",
default=60,
default=780,
)
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS: PositiveInt = Field(
@@ -1346,6 +1361,20 @@ class OpsTraceConfig(BaseSettings):
default=5,
)
OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS: PositiveInt = Field(
description="Retention in seconds for unified tracing parent contexts.",
default=3900,
)
@model_validator(mode="after")
def validate_parent_context_retention(self) -> "OpsTraceConfig":
if not self.OPS_TRACE_UNIFIED_ENABLED:
return self
retry_window = self.OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES * self.OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS
if retry_window > self.OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS:
raise ValueError("OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS must cover the retry window")
return self
class CeleryBeatConfig(BaseSettings):
CELERY_BEAT_SCHEDULER_TIME: int = Field(
@@ -1549,6 +1578,10 @@ class LoginConfig(BaseSettings):
description="expiry time in minutes for email code login token",
default=5,
)
EMAIL_CODE_LOGIN_MAX_ATTEMPTS: PositiveInt = Field(
description="maximum number of verification attempts for an email code login challenge",
default=5,
)
ALLOW_REGISTER: bool = Field(
description="whether to enable register",
default=False,
@@ -1659,6 +1692,7 @@ class FeatureConfig(
RepositoryConfig,
SandboxExpiredRecordsCleanConfig,
SecurityConfig,
SkillConfig,
TenantIsolatedTaskQueueConfig,
ToolConfig,
UpdateConfig,

View File

@@ -167,8 +167,8 @@ class ApolloClient:
old_value = old_kv.get(key)
if old_value is None:
self._change_listener("add", namespace, key, new_value)
except BaseException as e:
logger.warning(str(e))
except BaseException:
logger.warning("Apollo config change listener failed", exc_info=True)
def _path_checker(self) -> None:
if not os.path.isdir(self._cache_file_path):
@@ -235,8 +235,8 @@ class ApolloClient:
return
else:
logger.warning("Sleep...")
except Exception as e:
logger.warning(str(e))
except Exception:
logger.warning("Apollo long polling failed", exc_info=True)
def _get_net_and_set_local(self, namespace: str, n_id: int, call_change: bool = False) -> None:
namespace_data = self.get_json_from_net(namespace)

View File

@@ -36,6 +36,7 @@ _UNSTRUCTURED_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
"pptx",
"xml",
"epub",
"odt",
)
)
_DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
@@ -53,6 +54,7 @@ _DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
"csv",
"vtt",
"properties",
"odt",
)
)

View File

@@ -166,6 +166,7 @@
"categories": ["Workflow"],
"copyright": null,
"description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.",
"is_learn_dify": true,
"is_listed": true,
"position": 5,
"privacy_policy": null
@@ -294,6 +295,7 @@
"categories": ["Workflow"],
"copyright": null,
"description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.",
"is_learn_dify": true,
"is_listed": true,
"position": 4,
"privacy_policy": null
@@ -326,6 +328,7 @@
"categories": ["Workflow"],
"copyright": null,
"description": "Basic Workflow Template, A chatbot with a knowledge base. ",
"is_learn_dify": true,
"is_listed": true,
"position": 4,
"privacy_policy": null

View File

@@ -1,4 +0,0 @@
TTS_AUTO_PLAY_TIMEOUT = 5
# sleep 20 ms ( 40ms => 1280 byte audio file,20ms => 640 byte audio file)
TTS_AUTO_PLAY_YIELD_CPU_TIME = 0.02

View File

@@ -72,7 +72,6 @@ from .app import (
agent_app_feature,
agent_app_sandbox,
agent_config_inspector,
agent_drive_inspector,
annotation,
app,
audio,
@@ -158,6 +157,7 @@ from .workspace import (
models,
plugin,
rbac,
skills,
snippets,
tool_providers,
trigger_providers,
@@ -176,7 +176,6 @@ __all__ = [
"agent_app_sandbox",
"agent_composer",
"agent_config_inspector",
"agent_drive_inspector",
"agent_providers",
"agent_roster",
"annotation",
@@ -238,6 +237,7 @@ __all__ = [
"saved_message",
"setup",
"site",
"skills",
"snippet_workflow",
"snippet_workflow_draft_variable",
"snippets",

View File

@@ -182,14 +182,7 @@ class WorkflowAgentComposerValidateApi(Resource):
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
findings = AgentComposerService.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=req_data,
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
session=session, tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
),
)
findings = AgentComposerService.collect_validation_findings(payload=req_data)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@@ -413,22 +406,12 @@ class SnippetAgentComposerValidateApi(Resource):
@with_session(write=False)
@model_validate(ComposerSavePayload)
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
app_id = _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
ComposerConfigValidator.validate_publish_payload(req_data)
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
findings = AgentComposerService.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=req_data,
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
session=session,
tenant_id=tenant_id,
app_id=app_id,
node_id=node_id,
),
)
findings = AgentComposerService.collect_validation_findings(payload=req_data)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@@ -580,12 +563,7 @@ class AgentComposerValidateApi(Resource):
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
findings = AgentComposerService.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=req_data,
agent_id=str(agent_id),
)
findings = AgentComposerService.collect_validation_findings(payload=req_data)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})

View File

@@ -1,21 +1,12 @@
from typing import Any
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_models,
)
from controllers.common.schema import query_params_from_model, register_response_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.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -24,41 +15,13 @@ from controllers.console.wraps import (
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from fields.base import ResponseModel
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from models.model import App, AppMode, UploadFile
from services.agent.composer_service import AgentComposerService
from services.agent.skill_package_service import SkillManifest, SkillPackageError
from services.agent.skill_standardize_service import SkillStandardizeService
from services.agent.skill_tool_inference_service import (
SkillToolInferenceError,
SkillToolInferenceResult,
SkillToolInferenceService,
)
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
DriveCommitItem,
DriveFileRef,
normalize_drive_key,
)
from models.model import App, AppMode
from services.agent_service import AgentService
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
_AGENT_SKILL_UPLOAD_PARAMS = {
"file": {
"in": "formData",
"type": "file",
"required": True,
"description": "Skill package (.zip or .skill).",
}
}
class AgentLogQuery(BaseModel):
message_id: str = Field(..., description="Message UUID")
@@ -70,27 +33,6 @@ class AgentLogQuery(BaseModel):
return uuid_value(value)
class AgentDriveFilePayload(BaseModel):
upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
@field_validator("upload_file_id")
@classmethod
def validate_upload_file_id(cls, value: str) -> str:
return uuid_value(value)
class AgentDriveMutationQuery(BaseModel):
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentDriveDeleteFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentLogMetaResponse(ResponseModel):
status: str
executor: str
@@ -128,204 +70,7 @@ class AgentLogResponse(ResponseModel):
files: list[Any] = Field(default_factory=list)
class AgentUploadedSkillResponse(ResponseModel):
name: str
description: str
path: str
skill_md_key: str
archive_key: str | None = None
class AgentSkillUploadResponse(ResponseModel):
skill: AgentUploadedSkillResponse
manifest: SkillManifest
class AgentDriveFileResponse(ResponseModel):
name: str
drive_key: str
file_id: str
size: int | None = None
mime_type: str | None = None
class AgentDriveFileCommitResponse(ResponseModel):
file: AgentDriveFileResponse
class AgentDriveDeleteResponse(ResponseModel):
result: str
removed_keys: list[str] = Field(default_factory=list)
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
register_response_schema_models(
console_ns,
AgentDriveDeleteResponse,
AgentDriveFileCommitResponse,
AgentDriveFileResponse,
AgentLogResponse,
AgentUploadedSkillResponse,
AgentSkillUploadResponse,
SkillToolInferenceResult,
)
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
if node_id and app_model.mode != AppMode.AGENT:
return AgentComposerService.resolve_workflow_node_agent_id(
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
return app_model.bound_agent_id_with_session(session=session)
def _agent_not_bound() -> tuple[dict[str, str], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _upload_skill_for_app(*, session: Session, current_user: Account, app_model: App):
"""Upload one skill package and commit its normalized files into the agent drive."""
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
upload = request.files["file"]
content = upload.stream.read()
try:
result = SkillStandardizeService().standardize(
content=content,
filename=upload.filename or "",
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
session=session,
)
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
return result, 201
def _commit_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
query = query_params_from_request(AgentDriveMutationQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(session, app_model, node_id)
if not agent_id:
return _agent_not_bound()
upload_file = session.scalar(
select(UploadFile).where(
UploadFile.id == payload.upload_file_id,
UploadFile.tenant_id == app_model.tenant_id,
)
)
if upload_file is None:
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
try:
key = normalize_drive_key(f"files/{upload_file.name}")
committed = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(
key=key,
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
# ADD FILE uploads exist solely to live in the drive, so the
# drive owns (and physically cleans) the value on delete.
value_owned_by_drive=True,
)
],
session=session,
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
row = committed[0]
return {
"file": {
"name": upload_file.name,
"drive_key": row["key"],
"file_id": upload_file.id,
"size": row.get("size"),
"mime_type": row.get("mime_type"),
},
}, 201
def _delete_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveDeleteFileQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(session, app_model, node_id)
if not agent_id:
return _agent_not_bound()
try:
key = normalize_drive_key(query.key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
try:
result = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[DriveCommitItem(key=key, file_ref=None)],
session=session,
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
removed_keys = [item["key"] for item in result if item.get("removed")]
return {"result": "success", "removed_keys": removed_keys}
def _delete_skill_for_app(
*, session: Session, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True
):
query = query_params_from_request(AgentDriveMutationQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(session, app_model, node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
result = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
],
session=session,
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
removed_keys = [item["key"] for item in result if item.get("removed")]
return {"result": "success", "removed_keys": removed_keys}
def _infer_skill_tools_for_app(*, session: Session, app_model: App, slug: str):
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
return SkillToolInferenceService().infer(
tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug, session=session
)
except SkillToolInferenceError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
register_response_schema_models(console_ns, AgentLogResponse)
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
@@ -344,209 +89,6 @@ class AgentLogApi(Resource):
@get_app_model(mode=[AppMode.AGENT_CHAT])
@model_validate(AgentLogQuery)
def get(self, req_data: AgentLogQuery, session: Session, app_model: App):
"""Get agent logs"""
"""Get agent logs."""
return AgentService.get_agent_logs(app_model, req_data.conversation_id, req_data.message_id, session)
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
class AgentSkillUploadByAgentApi(Resource):
@console_ns.doc("upload_agent_skill_by_agent")
@console_ns.doc(description="Upload + standardize a Skill into an Agent App drive")
@console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS})
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package or no bound agent")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
class AgentSkillUploadApi(Resource):
@console_ns.doc("upload_agent_skill")
@console_ns.doc(description="Upload + standardize a Skill into the agent drive")
@console_ns.doc(
consumes=["multipart/form-data"],
params={
"app_id": "Application ID",
**query_params_from_model(AgentDriveMutationQuery),
**_AGENT_SKILL_UPLOAD_PARAMS,
},
)
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package or no bound agent")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_session
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def post(self, session: Session, current_user: Account, app_model: App):
"""Upload a Skill, validate it, and commit drive-backed skill files."""
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
@console_ns.route("/agent/<uuid:agent_id>/files")
class AgentDriveFilesByAgentApi(Resource):
@console_ns.doc("commit_agent_drive_file_by_agent")
@console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/<name>")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
@console_ns.response(
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
return _commit_drive_file_for_app(
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
)
@console_ns.doc("delete_agent_drive_file_by_agent")
@console_ns.doc(description="Delete one Agent App drive file by key")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
return _delete_drive_file_for_app(
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
)
@console_ns.route("/apps/<uuid:app_id>/agent/files")
class AgentDriveFilesApi(Resource):
@console_ns.doc("commit_agent_drive_file")
@console_ns.doc(description="Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
@console_ns.response(
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_session
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def post(self, session: Session, current_user: Account, app_model: App):
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
return _commit_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
@console_ns.doc("delete_agent_drive_file")
@console_ns.doc(description="Delete one drive file by key via drive commit-null semantics")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_session
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def delete(self, session: Session, current_user: Account, app_model: App):
return _delete_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>")
class AgentSkillByAgentApi(Resource):
@console_ns.doc("delete_agent_skill_by_agent")
@console_ns.doc(description="Delete a standardized skill from an Agent App drive")
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
return _delete_skill_for_app(
session=session, current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False
)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
class AgentSkillApi(Resource):
@console_ns.doc("delete_agent_skill")
@console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null")
@console_ns.doc(
params={
"app_id": "Application ID",
"slug": "Skill slug (single path segment)",
**query_params_from_model(AgentDriveMutationQuery),
}
)
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_session
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def delete(self, session: Session, current_user: Account, app_model: App, slug: str):
return _delete_skill_for_app(session=session, current_user=current_user, app_model=app_model, slug=slug)
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>/infer-tools")
class AgentSkillInferToolsByAgentApi(Resource):
@console_ns.doc("infer_agent_skill_tools_by_agent")
@console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
@console_ns.response(
200,
"Inference result (draft suggestions, nothing persisted)",
console_ns.models[SkillToolInferenceResult.__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, slug: str):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
class AgentSkillInferToolsApi(Resource):
@console_ns.doc("infer_agent_skill_tools")
@console_ns.doc(
description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
)
@console_ns.doc(
params={
"app_id": "Application ID",
"slug": "Skill slug (single path segment)",
**query_params_from_model(AgentDriveMutationQuery),
}
)
@console_ns.response(
200,
"Inference result (draft suggestions, nothing persisted)",
console_ns.models[SkillToolInferenceResult.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def post(self, session: Session, app_model: App, slug: str):
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)

View File

@@ -10,10 +10,11 @@ from __future__ import annotations
from typing import Literal
from uuid import UUID
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError
from flask_restx import Resource
from pydantic import BaseModel, Field
from clients.agent_backend.errors import backend_error_detail, backend_reported_failure
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
@@ -135,15 +136,10 @@ register_response_schema_models(
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
if isinstance(exc, AgentSandboxInspectorError):
return {"code": exc.code, "message": exc.message}, exc.status_code
if isinstance(exc, DifyAgentHTTPError):
detail = exc.detail
if isinstance(detail, dict):
return {
"code": detail.get("code", "agent_backend_error"),
"message": detail.get("message", str(exc)),
}, exc.status_code
return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code
if isinstance(exc, DifyAgentTimeoutError | DifyAgentClientError):
if isinstance(exc, DifyAgentHTTPError) and backend_reported_failure(exc):
code, message = backend_error_detail(exc)
return {"code": code, "message": message}, exc.status_code
if isinstance(exc, DifyAgentClientError):
return {"code": "agent_backend_unreachable", "message": str(exc)}, 502
raise exc

View File

@@ -1,434 +0,0 @@
"""Console read-only inspector for the agent drive (ENG-624).
``agent-drive`` looks at the *static* drive assets (standardized skills and
committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
sandbox workspace. Unlike the sandbox routes this never proxies to the agent
backend — drive data lives in the API's own DB/storage, served straight from
``AgentDriveService``. Download hands the browser an **external** signed URL
(the inner manifest hands agents internal ones — the two must never mix).
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any
from uuid import UUID
from flask import Response
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_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.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent_drive_service import AgentDriveError, AgentDriveService
class AgentDriveListQuery(BaseModel):
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveListByAgentQuery(BaseModel):
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
class AgentDriveFileQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
class AgentDriveSkillInspectQuery(BaseModel):
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveItemResponse(ResponseModel):
key: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
file_kind: str
created_at: int | None = None
is_skill: bool | None = None
skill_metadata: str | None = None
class AgentDriveListResponse(ResponseModel):
items: list[AgentDriveItemResponse] = Field(default_factory=list)
class AgentDriveSkillItemResponse(ResponseModel):
path: str
skill_md_key: str
archive_key: str | None = None
name: str
description: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
created_at: int | None = None
class AgentDriveSkillListResponse(ResponseModel):
items: list[AgentDriveSkillItemResponse] = Field(default_factory=list)
class AgentDriveSkillFileResponse(ResponseModel):
path: str
name: str
type: str
drive_key: str | None = None
available_in_drive: bool
class AgentDriveSkillMarkdownResponse(ResponseModel):
key: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class AgentDriveSkillInspectResponse(ResponseModel):
path: str
skill_md_key: str
archive_key: str | None = None
name: str
description: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
created_at: int | None = None
source: str
files: list[AgentDriveSkillFileResponse] = Field(default_factory=list)
file_tree: list[dict[str, Any]] = Field(default_factory=list)
skill_md: AgentDriveSkillMarkdownResponse
warnings: list[str] = Field(default_factory=list)
class AgentDrivePreviewResponse(ResponseModel):
key: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class AgentDriveDownloadResponse(ResponseModel):
url: str
register_response_schema_models(
console_ns,
AgentDriveDownloadResponse,
AgentDriveListResponse,
AgentDrivePreviewResponse,
AgentDriveSkillInspectResponse,
AgentDriveSkillListResponse,
)
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
if node_id:
return AgentComposerService.resolve_workflow_node_agent_id(
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
return app_model.bound_agent_id_with_session(session=session)
def _agent_not_bound() -> tuple[dict[str, object], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
def _json_response(data: Mapping[str, Any]):
return Response(
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
content_type="application/json; charset=utf-8",
)
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
@console_ns.route("/agent/<uuid:agent_id>/drive/files")
class AgentDriveListByAgentApi(Resource):
@console_ns.doc("list_agent_drive_files_by_agent")
@console_ns.doc(description="List agent drive entries for an Agent App")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveListByAgentQuery)
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().manifest(
tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix, session=session
)
except AgentDriveError as exc:
return _handle(exc)
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/agent/<uuid:agent_id>/drive/skills")
class AgentDriveSkillListByAgentApi(Resource):
@console_ns.doc("list_agent_drive_skills_by_agent")
@console_ns.doc(description="List drive-backed skills for an Agent App")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id), session=session)
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@console_ns.route("/agent/<uuid:agent_id>/drive/skills/<path:skill_path>/inspect")
class AgentDriveSkillInspectByAgentApi(Resource):
@console_ns.doc("inspect_agent_drive_skill_by_agent")
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
@console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"})
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID, skill_path: str):
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
try:
return _json_response(
AgentDriveService().inspect_skill(
tenant_id=tenant_id,
agent_id=str(agent_id),
skill_path=skill_path,
session=session,
)
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
class AgentDrivePreviewByAgentApi(Resource):
@console_ns.doc("preview_agent_drive_file_by_agent")
@console_ns.doc(description="Truncated text preview of one Agent App drive value")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
try:
return AgentDriveService().preview(
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/agent/<uuid:agent_id>/drive/files/download")
class AgentDriveDownloadByAgentApi(Resource):
@console_ns.doc("download_agent_drive_file_by_agent")
@console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
try:
url = AgentDriveService().download_url(
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
class AgentDriveListApi(Resource):
@console_ns.doc("list_agent_drive_files")
@console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, session: Session, app_model: App):
query = query_params_from_request(AgentDriveListQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().manifest(
tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix, session=session
)
except AgentDriveError as exc:
return _handle(exc)
# the inner manifest exposes file_id for agent-side pulls; the console
# inspector is a pure read surface and does not need value pointers
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills")
class AgentDriveSkillListApi(Resource):
@console_ns.doc("list_agent_drive_skills")
@console_ns.doc(description="List drive-backed skills for the bound agent")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, session: Session, app_model: App):
query = query_params_from_request(AgentDriveListQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id, session=session)
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills/<path:skill_path>/inspect")
class AgentDriveSkillInspectApi(Resource):
@console_ns.doc("inspect_agent_drive_skill")
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
@console_ns.doc(
params={
"app_id": "Application ID",
"skill_path": "Skill path/slug, e.g. tender-analyzer",
**query_params_from_model(AgentDriveSkillInspectQuery),
}
)
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, session: Session, app_model: App, skill_path: str):
query = query_params_from_request(AgentDriveSkillInspectQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
return _json_response(
AgentDriveService().inspect_skill(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
skill_path=skill_path,
session=session,
)
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
class AgentDrivePreviewApi(Resource):
@console_ns.doc("preview_agent_drive_file")
@console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, session: Session, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
return AgentDriveService().preview(
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/download")
class AgentDriveDownloadApi(Resource):
@console_ns.doc("download_agent_drive_file")
@console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, session: Session, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
url = AgentDriveService().download_url(
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
__all__ = [
"AgentDriveDownloadApi",
"AgentDriveDownloadByAgentApi",
"AgentDriveListApi",
"AgentDriveListByAgentApi",
"AgentDrivePreviewApi",
"AgentDrivePreviewByAgentApi",
"AgentDriveSkillInspectApi",
"AgentDriveSkillInspectByAgentApi",
"AgentDriveSkillListApi",
"AgentDriveSkillListByAgentApi",
]

View File

@@ -364,6 +364,7 @@ class InstructionGenerateApi(Resource):
case "llm":
return LLMGenerator.generate_rule_config(
current_tenant_id,
app_id=req_data.flow_id,
args=RuleGeneratePayload(
instruction=req_data.instruction,
model_config=req_data.model_config_data,
@@ -373,6 +374,7 @@ class InstructionGenerateApi(Resource):
case "agent":
return LLMGenerator.generate_rule_config(
current_tenant_id,
app_id=req_data.flow_id,
args=RuleGeneratePayload(
instruction=req_data.instruction,
model_config=req_data.model_config_data,
@@ -382,6 +384,7 @@ class InstructionGenerateApi(Resource):
case "code":
return LLMGenerator.generate_code(
tenant_id=current_tenant_id,
app_id=req_data.flow_id,
args=RuleCodeGeneratePayload(
instruction=req_data.instruction,
model_config=req_data.model_config_data,

View File

@@ -190,7 +190,7 @@ class AppMCPServerRefreshController(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@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):

View File

@@ -20,6 +20,8 @@ from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
from configs import dify_config
from controllers.common.app_access import resolve_app_access_filter
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.errors import InvalidArgumentError
from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse
@@ -53,6 +55,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.file_access import DatabaseFileAccessController
from core.db.session_factory import session_factory
from core.helper import encrypter
from core.helper.trace_id_helper import get_external_trace_id
from core.plugin.impl.exc import PluginInvokeError
@@ -95,7 +98,6 @@ from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError,
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__)
@@ -1303,7 +1305,7 @@ class PublishedWorkflowApi(Resource):
workflow_service = WorkflowService()
with sessionmaker(db.engine).begin() as session:
workflow, retirement_candidates = workflow_service.publish_workflow(
workflow = workflow_service.publish_workflow(
session=session,
app_model=app_model,
account=current_user,
@@ -1320,16 +1322,6 @@ 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,
@@ -1620,10 +1612,11 @@ class WorkflowByIdApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@console_ns.response(204, "Workflow deleted successfully")
def delete(self, app_model: App, workflow_id: str):
def delete(self, current_user: Account, app_model: App, workflow_id: str):
"""
Delete workflow
"""
@@ -1633,7 +1626,7 @@ class WorkflowByIdApi(Resource):
# Create a session and manage the transaction
with sessionmaker(db.engine).begin() as session:
try:
workflow_service.delete_workflow(
retirement_candidates = workflow_service.delete_workflow(
session=session,
workflow_ref=workflow_ref,
)
@@ -1644,6 +1637,11 @@ class WorkflowByIdApi(Resource):
except ValueError as e:
raise NotFound(str(e))
WorkflowAgentRetirementService.retire_unowned(
tenant_id=app_model.tenant_id,
agent_ids=retirement_candidates,
account_id=current_user.id,
)
return None, 204
@@ -1929,8 +1927,9 @@ class WorkflowOnlineUsersApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str):
def post(self, current_tenant_id: str, current_user: Account):
args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {})
app_ids = args.app_ids
@@ -1940,8 +1939,20 @@ class WorkflowOnlineUsersApi(Resource):
if not app_ids:
return {"data": []}
access_filter = None
workflow_service = WorkflowService()
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id, session=db.session())
with session_factory.create_session() as session:
if dify_config.RBAC_ENABLED:
access_filter = resolve_app_access_filter(current_tenant_id, current_user.id, session=session)
app_maintainers = workflow_service.get_tenant_app_maintainers(app_ids, current_tenant_id, session=session)
accessible_app_ids = set(app_maintainers)
if access_filter is not None:
accessible_app_ids = {
app_id
for app_id, maintainer in app_maintainers.items()
if access_filter.is_app_accessible(app_id, maintainer, current_user.id)
}
ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids]
users_json_by_app_id: dict[str, Any] = {}

View File

@@ -3,6 +3,7 @@ from datetime import datetime
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter, computed_field, field_validator
from sqlalchemy.orm import Session
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
@@ -21,6 +22,15 @@ from fields.member_fields import AccountWithRole
from libs.helper import build_avatar_url, dump_response, to_timestamp
from libs.login import login_required
from models import Account, App
from models.comment import (
WorkflowComment as WorkflowCommentModel,
)
from models.comment import (
WorkflowCommentMention as WorkflowCommentMentionModel,
)
from models.comment import (
WorkflowCommentReply as WorkflowCommentReplyModel,
)
from services.account_service import TenantService
from services.workflow_comment_service import WorkflowCommentService
@@ -133,6 +143,73 @@ class WorkflowCommentDetail(ResponseModel):
return to_timestamp(value)
def _workflow_comment_reply_response(reply: WorkflowCommentReplyModel, *, session: Session) -> WorkflowCommentReply:
return WorkflowCommentReply.model_validate(
{
"id": reply.id,
"content": reply.content,
"created_by": reply.created_by,
"created_by_account": reply.created_by_account(session),
"created_at": reply.created_at,
}
)
def _workflow_comment_mention_response(
mention: WorkflowCommentMentionModel, *, session: Session
) -> WorkflowCommentMention:
return WorkflowCommentMention.model_validate(
{
"mentioned_user_id": mention.mentioned_user_id,
"mentioned_user_account": mention.mentioned_user_account(session),
"reply_id": mention.reply_id,
}
)
def _workflow_comment_basic_response(comment: WorkflowCommentModel, *, session: Session) -> WorkflowCommentBasic:
return WorkflowCommentBasic.model_validate(
{
"id": comment.id,
"position_x": comment.position_x,
"position_y": comment.position_y,
"content": comment.content,
"created_by": comment.created_by,
"created_by_account": comment.created_by_account(session),
"created_at": comment.created_at,
"updated_at": comment.updated_at,
"resolved": comment.resolved,
"resolved_at": comment.resolved_at,
"resolved_by": comment.resolved_by,
"resolved_by_account": comment.resolved_by_account(session),
"reply_count": comment.reply_count,
"mention_count": comment.mention_count,
"participants": comment.participants(session),
}
)
def _workflow_comment_detail_response(comment: WorkflowCommentModel, *, session: Session) -> WorkflowCommentDetail:
return WorkflowCommentDetail.model_validate(
{
"id": comment.id,
"position_x": comment.position_x,
"position_y": comment.position_y,
"content": comment.content,
"created_by": comment.created_by,
"created_by_account": comment.created_by_account(session),
"created_at": comment.created_at,
"updated_at": comment.updated_at,
"resolved": comment.resolved,
"resolved_at": comment.resolved_at,
"resolved_by": comment.resolved_by,
"resolved_by_account": comment.resolved_by_account(session),
"replies": [_workflow_comment_reply_response(reply, session=session) for reply in comment.replies],
"mentions": [_workflow_comment_mention_response(mention, session=session) for mention in comment.mentions],
}
)
class WorkflowCommentCreate(ResponseModel):
id: str
created_at: int | None = None
@@ -225,8 +302,11 @@ class WorkflowCommentListApi(Resource):
def get(self, current_tenant_id: str, app_model: App):
"""Get all comments for a workflow."""
comments = WorkflowCommentService.get_comments(tenant_id=current_tenant_id, app_id=app_model.id)
session = db.session()
return WorkflowCommentBasicList.model_validate({"data": comments}).model_dump(mode="json")
return WorkflowCommentBasicList.model_validate(
{"data": [_workflow_comment_basic_response(comment, session=session) for comment in comments]}
).model_dump(mode="json")
@console_ns.doc("create_workflow_comment")
@console_ns.doc(description="Create a new workflow comment")
@@ -281,8 +361,9 @@ class WorkflowCommentDetailApi(Resource):
comment = WorkflowCommentService.get_comment(
tenant_id=current_tenant_id, app_id=app_model.id, comment_id=comment_id
)
session = db.session()
return dump_response(WorkflowCommentDetail, comment)
return dump_response(WorkflowCommentDetail, _workflow_comment_detail_response(comment, session=session))
@console_ns.doc("update_workflow_comment")
@console_ns.doc(description="Update a workflow comment")

View File

@@ -1,8 +1,9 @@
"""Controller decorators for console app resources.
`get_app_model` still supports legacy handlers backed by Flask-SQLAlchemy's
scoped session. Trial app handlers compose `get_app_model_with_trial` under
`controllers.common.session.with_session` and always reuse that request session.
scoped session. Preview handlers compose `get_previewable_app_model` under
`controllers.common.session.with_session`; preview admission finishes before
the request Session loads the accepted App.
"""
from collections.abc import Callable
@@ -16,16 +17,17 @@ from configs import dify_config
from controllers.common.session import with_session
from controllers.common.wraps import RBACPermission, RBACResourceScope, enforce_rbac_access
from controllers.console.app.error import AppNotFoundError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models import App, AppMode, TrialApp
from models import App, AppMode
from models.agent import AgentScope
from services.recommended_app_service import RecommendedAppService
from services.app_service import AppService
__all__ = [
"agent_manage_required_for_agent_app",
"get_app_model",
"get_app_model_with_trial",
"get_previewable_app_model",
"with_session",
]
@@ -48,12 +50,11 @@ def _load_app_model_from_scoped_session(app_id: str) -> App | None:
return app_model
def _load_app_model_with_trial(session: Session, app_id: str) -> App | None:
"""Load a normal app through its trial registration without applying current-tenant scope."""
app_model = session.scalar(
select(App).join(TrialApp, TrialApp.app_id == App.id).where(App.id == app_id, App.status == "normal").limit(1)
)
return app_model
def _load_previewable_app_model(session: Session, app_id: str) -> App | None:
"""Load a normal App after preview admission completes outside the request Session."""
if not application_services().recommended_app_queries.is_previewable(app_id):
return None
return AppService.get_normal_app_by_id(app_id, session)
def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@@ -183,7 +184,7 @@ def get_app_model[**P, R](
@overload
def get_app_model_with_trial[**P, R](
def get_previewable_app_model[**P, R](
view: Callable[P, R],
*,
mode: AppMode | list[AppMode] | None = None,
@@ -191,19 +192,25 @@ def get_app_model_with_trial[**P, R](
@overload
def get_app_model_with_trial[**P, R](
def get_previewable_app_model[**P, R](
view: None = None,
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
def get_app_model_with_trial[**P, R](
def get_previewable_app_model[**P, R](
view: Callable[P, R] | None = None,
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
"""Inject a trial-registered or recommended App using the Session supplied by `with_session`."""
"""Inject an App authorized for read-only template preview.
Preview reads accept either an explicit TrialApp registration or membership
in the recommended catalog. This does not grant trial execution, which is
separately protected by TrialAppResource's feature, registration, and quota
checks.
"""
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func)
@@ -218,10 +225,8 @@ def get_app_model_with_trial[**P, R](
session = _get_injected_session(args)
if session is None:
raise RuntimeError("get_app_model_with_trial requires @with_session")
app_model = _load_app_model_with_trial(session, app_id)
if app_model is None:
app_model = RecommendedAppService.get_app(app_id, session=session)
raise RuntimeError("get_previewable_app_model requires @with_session")
app_model = _load_previewable_app_model(session, app_id)
if not app_model:
raise AppNotFoundError()

View File

@@ -6,11 +6,14 @@ from constants.languages import supported_language
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError, EmailDomainSuspendedError
from extensions.ext_application_services import application_services
from libs.helper import EmailStr, dump_response, timezone
from libs.login import current_account_with_tenant
from libs.token import extract_access_token
from services.account_activation_service import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.account_activation_service import (
FrozenAccountError,
InvalidInvitationError,
@@ -141,6 +144,8 @@ class ActivateApi(Resource):
raise AlreadyActivateError() from None
except InvitationAccountMismatchError:
raise InvitationAccountMismatchHTTPError() from None
except EmailDomainSuspendedRegistrationError:
raise EmailDomainSuspendedError() from None
except FrozenAccountError:
raise AccountInFreezeError() from None

View File

@@ -24,9 +24,15 @@ from libs.password import valid_password
from models import Account
from services.account_service import AccountService
from services.billing_service import BillingService
from services.errors.account import AccountRegisterError, SeatsLimitExceededError
from services.errors.account import (
AccountRegisterError,
SeatsLimitExceededError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from ..error import AccountInFreezeError, EmailSendIpLimitError, SeatsLimitExceeded
from ..error import AccountInFreezeError, EmailDomainSuspendedError, EmailSendIpLimitError, SeatsLimitExceeded
from ..wraps import email_password_login_enabled, email_register_enabled, model_validate, setup_required
@@ -99,10 +105,12 @@ class EmailRegisterSendEmailApi(Resource):
if req_data.language is not None and req_data.language in languages:
language = req_data.language
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
raise AccountInFreezeError()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
@@ -217,5 +225,7 @@ class EmailRegisterResetApi(Resource):
)
except SeatsLimitExceededError:
raise SeatsLimitExceeded()
except AccountRegisterError:
raise AccountInFreezeError()
except EmailDomainSuspendedRegistrationError as exc:
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
raise AccountInFreezeError() from exc

View File

@@ -95,6 +95,12 @@ class EmailCodeError(BaseHTTPException):
code = 400
class EmailCodeLoginServiceUnavailableError(BaseHTTPException):
error_code = "email_code_login_service_unavailable"
description = "Email code verification is temporarily unavailable. Please try again later."
code = 503
class EmailOrPasswordMismatchError(BaseHTTPException):
error_code = "email_or_password_mismatch"
description = "The email or password is mismatched."

View File

@@ -1,4 +1,5 @@
import logging
from uuid import UUID
import flask_login
from flask import make_response, request
@@ -22,6 +23,7 @@ from controllers.console import console_ns
from controllers.console.auth.error import (
AuthenticationFailedError,
EmailCodeError,
EmailCodeLoginServiceUnavailableError,
EmailPasswordLoginLimitError,
InvalidEmailError,
InvalidTokenError,
@@ -32,6 +34,7 @@ from controllers.console.error import (
AccountBannedError,
AccountInFreezeError,
AccountNotFound,
EmailDomainSuspendedError,
EmailSendIpLimitError,
NotAllowedCreateWorkspace,
SeatsLimitExceeded,
@@ -61,6 +64,10 @@ from libs.token import (
from models.account import Account
from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService
from services.billing_service import BillingService
from services.email_code_login_challenge import (
EmailCodeLoginChallengeStatus,
EmailCodeLoginChallengeUnavailableError,
)
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
from services.errors.account import (
AccountRegisterError,
@@ -68,9 +75,13 @@ from services.errors.account import (
RefreshTokenNotFoundError,
SeatsLimitExceededError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.turnstile_service import (
EMAIL_CODE_VERIFY_ACTION,
TurnstileChallengeRejectedError,
TurnstileService,
TurnstileUpstreamError,
@@ -92,14 +103,20 @@ class EmailPayload(BaseModel):
class EmailCodeSendPayload(EmailPayload):
turnstile_token: str | None = Field(
default=None,
max_length=2048,
description="Cloudflare Turnstile token. Required at runtime for Dify Cloud.",
)
class EmailCodeLoginPayload(BaseModel):
email: EmailStr = Field(...)
code: str = Field(...)
token: str = Field(...)
code: str
token: UUID
turnstile_token: str | None = Field(
default=None,
max_length=2048,
description="Cloudflare Turnstile token for email-code verification.",
)
language: str | None = Field(default=None)
timezone: str | None = Field(default=None)
@@ -136,11 +153,13 @@ class LoginApi(Resource):
request_email = req_data.email
normalized_email = request_email.lower()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(normalized_email)
if is_login_error_rate_limit:
@@ -242,8 +261,10 @@ class ResetPasswordSendEmailApi(Resource):
language = "en-US"
try:
account = _get_account_with_case_fallback(req_data.email)
except AccountRegisterError:
raise AccountInFreezeError()
except EmailDomainSuspendedRegistrationError as exc:
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
raise AccountInFreezeError() from exc
token = AccountService.send_reset_password_email(
email=normalized_email,
@@ -284,8 +305,10 @@ class EmailCodeLoginSendEmailApi(Resource):
language = "en-US"
try:
account = _get_account_with_case_fallback(req_data.email)
except AccountRegisterError:
raise AccountInFreezeError()
except EmailDomainSuspendedRegistrationError as exc:
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
raise AccountInFreezeError() from exc
if account is None:
if FeatureService.get_system_features().is_allow_register:
@@ -310,31 +333,66 @@ class EmailCodeLoginApi(Resource):
original_email = req_data.email
user_email = original_email.lower()
language = req_data.language
ip_address = extract_remote_ip(request)
token_data = AccountService.get_email_code_login_data(req_data.token)
if token_data is None:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
raise InvalidTokenError()
token_email = token_data.get("email")
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
if normalized_token_email != user_email:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
raise InvalidEmailError()
if token_data["code"] != req_data.code:
# ``code`` is Base64 on the wire and is decoded by
# ``decrypt_code_field`` before model validation reaches this handler.
if len(req_data.code) != 6 or not req_data.code.isascii() or not req_data.code.isdigit():
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
raise EmailCodeError()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED or req_data.turnstile_token
):
try:
TurnstileService.verify(
token=req_data.turnstile_token,
remote_ip=ip_address,
expected_action=EMAIL_CODE_VERIFY_ACTION,
)
except TurnstileChallengeRejectedError as exc:
logger.info("Turnstile rejected an email-code verification challenge")
raise TurnstileVerificationFailedError() from exc
except TurnstileUpstreamError as exc:
logger.warning("Turnstile verification is unavailable", exc_info=True)
raise TurnstileServiceUnavailableError() from exc
try:
verification = AccountService.verify_email_code_login_challenge(
email=user_email,
code=req_data.code,
token=str(req_data.token),
)
except EmailCodeLoginChallengeUnavailableError as exc:
logger.warning("Email-code challenge verification is unavailable", exc_info=True)
raise EmailCodeLoginServiceUnavailableError() from exc
if verification.status == EmailCodeLoginChallengeStatus.INVALID_TOKEN:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
raise InvalidTokenError()
if verification.status == EmailCodeLoginChallengeStatus.EMAIL_MISMATCH:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
raise InvalidEmailError()
if verification.status in {
EmailCodeLoginChallengeStatus.INVALID_CODE,
EmailCodeLoginChallengeStatus.EXHAUSTED,
}:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
raise EmailCodeError()
AccountService.revoke_email_code_login_token(req_data.token)
try:
account = _get_account_with_case_fallback(original_email)
except Unauthorized as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_BANNED)
raise AccountBannedError() from exc
except AccountRegisterError:
except EmailDomainSuspendedRegistrationError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError() from exc
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
@@ -346,7 +404,6 @@ class EmailCodeLoginApi(Resource):
else:
TenantService.create_owner_tenant(account, session=db.session())
ip_address = extract_remote_ip(request)
if account is None:
try:
account = AccountService.create_account_and_tenant(
@@ -361,9 +418,12 @@ class EmailCodeLoginApi(Resource):
raise NotAllowedCreateWorkspace()
except SeatsLimitExceededError:
raise SeatsLimitExceeded()
except AccountRegisterError:
except EmailDomainSuspendedRegistrationError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError() from exc
except WorkspacesLimitExceededError:
raise WorkspacesLimitExceeded()
token_pair = AccountService.login(account, session=db.session(), ip_address=ip_address)

View File

@@ -12,6 +12,7 @@ from configs import dify_config
from constants.languages import languages
from controllers.common.fields import RedirectResponse
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
from enums import DeploymentEdition
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
@@ -26,7 +27,14 @@ from libs.token import (
from models import Account, AccountStatus
from services.account_service import AccountService, RegisterService, TenantService
from services.billing_service import BillingService
from services.errors.account import AccountNotFoundError, AccountRegisterError, SeatsLimitExceededError
from services.errors.account import (
AccountNotFoundError,
AccountRegisterError,
SeatsLimitExceededError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
from services.feature_service import FeatureService
@@ -249,8 +257,10 @@ class OAuthCallback(Resource):
)
except SeatsLimitExceededError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Licensed seats limit exceeded.")
except AccountRegisterError as e:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={e.description}")
except EmailDomainSuspendedRegistrationError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={EmailDomainSuspendedError.description}")
except AccountRegisterError as exc:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={exc.description}")
# Check account status
if account.status == AccountStatus.BANNED:
@@ -309,15 +319,12 @@ def _generate_account(
normalized_email = user_info.email.lower()
oauth_new_user = True
if not FeatureService.get_system_features().is_allow_register:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
"30 days and is temporarily unavailable for new account registration"
)
)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedRegistrationError()
raise AccountRegisterError(description=AccountInFreezeError.description or "")
raise AccountRegisterError(description=("Invalid email or password"))
account_name = user_info.name or "Dify"
interface_language = _preferred_interface_language(language)

View File

@@ -7,20 +7,23 @@ from werkzeug.exceptions import BadRequest
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.wraps import (
account_initialization_required,
model_validate,
only_edition_cloud,
setup_required,
with_current_tenant_id,
with_current_user,
from controllers.console.billing.error import (
BillingOperationFailedErrorResponse,
BillingUnavailableErrorResponse,
BillingUnprocessableEntityErrorResponse,
to_billing_request_error,
)
from enums import CloudPlan
from extensions.ext_database import db
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from enums import CloudPlan, DeploymentEdition
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.login import login_required
from models import Account
from services.billing_service import BillingService
from libs.helper import dump_response
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.errors.billing import BillingError
_BILLING_PORTAL_ALLOWED_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
class SubscriptionQuery(BaseModel):
@@ -45,37 +48,78 @@ class BillingSubscriptionResponse(ResponseModel):
register_schema_models(console_ns, SubscriptionQuery, PartnerTenantsPayload)
register_response_schema_models(console_ns, BillingResponse, BillingInvoiceResponse, BillingSubscriptionResponse)
register_response_schema_models(
console_ns,
BillingOperationFailedErrorResponse,
BillingUnprocessableEntityErrorResponse,
BillingUnavailableErrorResponse,
BillingResponse,
BillingInvoiceResponse,
BillingSubscriptionResponse,
)
@console_ns.route("/billing/subscription")
class Subscription(Resource):
@console_ns.doc(params=query_params_from_model(SubscriptionQuery))
@console_ns.response(200, "Success", console_ns.models[BillingSubscriptionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@with_current_user
@with_current_tenant_id
@console_ns.response(403, "Forbidden")
@console_ns.response(
422,
"Invalid subscription query",
console_ns.models[BillingUnprocessableEntityErrorResponse.__name__],
)
@console_ns.response(
502,
"Billing operation failed",
console_ns.models[BillingOperationFailedErrorResponse.__name__],
)
@console_ns.response(
503,
"Billing unavailable",
console_ns.models[BillingUnavailableErrorResponse.__name__],
)
@console_account_admission(
editions=frozenset({DeploymentEdition.CLOUD}),
allowed_roles=_BILLING_PORTAL_ALLOWED_ROLES,
)
@model_validate(SubscriptionQuery)
def get(self, req_data: SubscriptionQuery, current_tenant_id: str, current_user: Account):
BillingService.is_tenant_owner_or_admin(current_user, session=db.session())
return BillingService.get_subscription(req_data.plan, req_data.interval, current_user.email, current_tenant_id)
def get(self, req_data: SubscriptionQuery, request_context: RequestContext):
try:
data = application_services().billing_portal.get_subscription(
request_context,
plan=req_data.plan,
interval=req_data.interval,
)
except BillingError as error:
raise to_billing_request_error(error) from error
return dump_response(BillingSubscriptionResponse, data)
@console_ns.route("/billing/invoices")
class Invoices(Resource):
@console_ns.response(200, "Success", console_ns.models[BillingInvoiceResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
BillingService.is_tenant_owner_or_admin(current_user, session=db.session())
return BillingService.get_invoices(current_user.email, current_tenant_id)
@console_ns.response(403, "Forbidden")
@console_ns.response(
502,
"Billing operation failed",
console_ns.models[BillingOperationFailedErrorResponse.__name__],
)
@console_ns.response(
503,
"Billing unavailable",
console_ns.models[BillingUnavailableErrorResponse.__name__],
)
@console_account_admission(
editions=frozenset({DeploymentEdition.CLOUD}),
allowed_roles=_BILLING_PORTAL_ALLOWED_ROLES,
)
def get(self, request_context: RequestContext):
try:
data = application_services().billing_portal.get_invoices(request_context)
except BillingError as error:
raise to_billing_request_error(error) from error
return dump_response(BillingInvoiceResponse, data)
@console_ns.route("/billing/partners/<string:partner_key>/tenants")
@@ -86,20 +130,20 @@ class PartnerTenants(Resource):
@console_ns.expect(console_ns.models[PartnerTenantsPayload.__name__])
@console_ns.response(200, "Tenants synced to partner successfully", console_ns.models[BillingResponse.__name__])
@console_ns.response(400, "Invalid partner information")
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@with_current_user
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
@model_validate(PartnerTenantsPayload)
def put(self, req_data: PartnerTenantsPayload, current_user: Account, partner_key: str):
def put(self, req_data: PartnerTenantsPayload, request_context: RequestContext, partner_key: str):
try:
click_id = req_data.click_id
decoded_partner_key = base64.b64decode(partner_key).decode("utf-8")
except Exception as e:
raise BadRequest("Invalid partner_key") from e
if not click_id or not decoded_partner_key or not current_user.id:
if not click_id or not decoded_partner_key:
raise BadRequest("Invalid partner information")
return BillingService.sync_partner_tenants_bindings(current_user.id, decoded_partner_key, click_id)
return application_services().partner_tenant_bindings.sync(
account_id=request_context.account_id,
partner_key=decoded_partner_key,
click_id=click_id,
)

View File

@@ -0,0 +1,47 @@
from typing import Literal
from fields.base import ResponseModel
from libs.exception import BaseHTTPException
from services.errors.billing import (
BillingError,
BillingUpstreamInvalidResponseError,
BillingUpstreamUnavailableError,
)
class BillingUnprocessableEntityErrorResponse(ResponseModel):
code: Literal["unprocessable_entity"]
message: str
status: Literal[422]
class BillingOperationFailedErrorResponse(ResponseModel):
code: Literal["billing_operation_failed"]
message: str
status: Literal[502]
class BillingUnavailableErrorResponse(ResponseModel):
code: Literal["billing_unavailable"]
message: str
status: Literal[503]
class BillingOperationFailedError(BaseHTTPException):
error_code = "billing_operation_failed"
description = "We couldn't complete this request. Please try again. If the problem persists, contact support."
code = 502
class BillingUnavailableError(BaseHTTPException):
error_code = "billing_unavailable"
description = "This operation is temporarily unavailable. Please try again later."
code = 503
def to_billing_request_error(error: BillingError) -> BaseHTTPException:
if isinstance(error, BillingUpstreamInvalidResponseError):
return BillingOperationFailedError()
if isinstance(error, BillingUpstreamUnavailableError):
return BillingUnavailableError()
raise TypeError(f"Unsupported billing error: {type(error).__name__}")

View File

@@ -139,6 +139,8 @@ register_response_schema_models(
class DataSourceApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[DataSourceIntegrateListResponse.__name__])
@with_current_tenant_id

View File

@@ -668,10 +668,12 @@ class DatasetApi(Resource):
dataset = DatasetService.get_dataset(dataset_id_str, session)
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
if not dify_config.RBAC_ENABLED:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
current_tenant_id,
current_user.id,

View File

@@ -631,12 +631,16 @@ class DatasetDocumentSegmentBatchImportApi(Resource):
):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
if not dataset:
raise NotFound("Dataset not found.")
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=session)
document_ref = DatasetRefService.create_document_ref_from_id(
DatasetRefService.create_dataset_ref(dataset), document_id_str
)
document = DatasetRefService.get_document_by_ref(document_ref, session=session)
if not document:
raise NotFound("Document not found.")

View File

@@ -336,6 +336,8 @@ class DatasourceAuth(Resource):
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account, provider_id: str):

View File

@@ -61,6 +61,7 @@ from models import Account
from models.dataset import Pipeline
from models.model import EndUser
from models.workflow import Workflow
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.dataset_service import DatasetService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
@@ -770,8 +771,9 @@ class RagPipelineByIdApi(Resource):
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_current_user
@get_rag_pipeline
def delete(self, pipeline: Pipeline, workflow_id: str):
def delete(self, current_user: Account, pipeline: Pipeline, workflow_id: str):
"""
Delete a published workflow version that is not currently active on the pipeline.
"""
@@ -783,7 +785,7 @@ class RagPipelineByIdApi(Resource):
with sessionmaker(db.engine).begin() as session:
try:
workflow_service.delete_workflow(
retirement_candidates = workflow_service.delete_workflow(
session=session,
workflow_ref=workflow_ref,
)
@@ -794,6 +796,11 @@ class RagPipelineByIdApi(Resource):
except ValueError as e:
raise NotFound(str(e))
WorkflowAgentRetirementService.retire_unowned(
tenant_id=pipeline.tenant_id,
agent_ids=retirement_candidates,
account_id=current_user.id,
)
return None, 204

View File

@@ -92,11 +92,17 @@ class AccountInFreezeError(BaseHTTPException):
error_code = "account_in_freeze"
code = 400
description = (
"This email account has been deleted within the past 30 days"
"This email account has been deleted within the past 30 days "
"and is temporarily unavailable for new account registration."
)
class EmailDomainSuspendedError(BaseHTTPException):
error_code = "email_domain_suspended"
code = 400
description = "This email domain has been suspended."
class EducationVerifyLimitError(BaseHTTPException):
error_code = "education_verify_limit"
description = "Rate limit exceeded"

View File

@@ -31,6 +31,12 @@ class AppAccessDeniedError(BaseHTTPException):
code = 403
class RecommendedAppNotFoundError(BaseHTTPException):
error_code = "recommended_app_not_found"
description = "Recommended app not found."
code = 404
class TrialAppNotAllowed(BaseHTTPException):
"""*403* `Trial App Not Allowed`
@@ -51,3 +57,9 @@ class TrialAppLimitExceeded(BaseHTTPException):
error_code = "trial_app_limit_exceeded"
code = 403
description = "The user has exceeded the trial app limit."
class TrialAppFeatureDisabledError(BaseHTTPException):
error_code = "trial_app_feature_disabled"
code = 403
description = "Trial app feature is not enabled."

View File

@@ -2,18 +2,18 @@ from typing import Any
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, computed_field, field_validator
from pydantic import BaseModel, Field, computed_field, field_validator
from constants.languages import languages
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.explore.error import RecommendedAppNotFoundError
from controllers.console.wraps import account_initialization_required, model_validate, with_current_user
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import build_icon_url, dump_response
from libs.login import login_required
from models import Account
from services.recommended_app_service import RecommendedAppService
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
class RecommendedAppsQuery(BaseModel):
@@ -79,10 +79,6 @@ class RecommendedAppDetailResponse(ResponseModel):
can_trial: bool
class RecommendedAppDetailNullableResponse(RootModel[RecommendedAppDetailResponse | None]):
pass
register_schema_models(
console_ns,
RecommendedAppsQuery,
@@ -94,18 +90,9 @@ register_response_schema_models(
RecommendedAppListResponse,
LearnDifyAppListResponse,
RecommendedAppDetailResponse,
RecommendedAppDetailNullableResponse,
)
def _resolve_language(language: str | None, user: Account) -> str:
if language and language in languages:
return language
if user.interface_language:
return user.interface_language
return languages[0]
@console_ns.route("/explore/apps")
class RecommendedAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@@ -115,12 +102,12 @@ class RecommendedAppListApi(Resource):
@with_current_user
@model_validate(RecommendedAppsQuery)
def get(self, req_data: RecommendedAppsQuery, current_user: Account):
# language args
language_prefix = _resolve_language(req_data.language, current_user)
return dump_response(
RecommendedAppListResponse,
RecommendedAppService.get_recommended_apps_and_categories(language_prefix, session=db.session()),
application_services().recommended_app_queries.list_recommended(
requested_language=req_data.language,
interface_language=current_user.interface_language,
),
)
@@ -133,19 +120,24 @@ class LearnDifyAppListApi(Resource):
@with_current_user
@model_validate(RecommendedAppsQuery)
def get(self, req_data: RecommendedAppsQuery, current_user: Account):
language_prefix = _resolve_language(req_data.language, current_user)
return dump_response(
LearnDifyAppListResponse,
RecommendedAppService.get_learn_dify_apps(language_prefix, session=db.session()),
application_services().recommended_app_queries.list_learn_dify(
requested_language=req_data.language,
interface_language=current_user.interface_language,
),
)
@console_ns.route("/explore/apps/<uuid:app_id>")
class RecommendedAppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailNullableResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
@console_ns.response(404, "Recommended app not found")
@login_required
@account_initialization_required
def get(self, app_id: UUID):
result = RecommendedAppService.get_recommend_app_detail(str(app_id), session=db.session())
return RecommendedAppDetailNullableResponse.model_validate(result).model_dump(mode="json")
try:
result = application_services().recommended_app_queries.get_detail(str(app_id))
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
return dump_response(RecommendedAppDetailResponse, result)

View File

@@ -38,7 +38,7 @@ from controllers.console.app.error import (
SpeechToTextDisabledError,
UnsupportedAudioTypeError,
)
from controllers.console.app.wraps import get_app_model_with_trial, with_session
from controllers.console.app.wraps import get_previewable_app_model, with_session
from controllers.console.explore.error import (
AppSuggestedQuestionsAfterAnswerDisabledError,
NotChatAppError,
@@ -96,7 +96,6 @@ from services.errors.message import (
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.message_service import MessageService
from services.recommended_app_service import RecommendedAppService
logger = logging.getLogger(__name__)
@@ -511,7 +510,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
)
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except ProviderTokenNotInitError as ex:
@@ -589,7 +588,7 @@ class TrialChatApi(TrialAppResource):
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
)
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
@@ -675,7 +674,7 @@ class TrialChatAudioApi(TrialAppResource):
session=db.session(),
end_user=None,
)
RecommendedAppService.add_trial_app_record(app_id, user_id, session=db.session())
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
@@ -736,7 +735,7 @@ class TrialChatTextApi(TrialAppResource):
voice=voice,
message_ref=message_ref,
)
RecommendedAppService.add_trial_app_record(app_id, user_id, session=db.session())
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
@@ -794,7 +793,7 @@ class TrialCompletionApi(TrialAppResource):
streaming=streaming,
)
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
@@ -824,7 +823,7 @@ class TrialSitApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__])
@with_session(write=False)
@get_app_model_with_trial(None)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
"""Retrieve app site info.
@@ -848,7 +847,7 @@ class TrialAppParameterApi(Resource):
@console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__])
@with_session(write=False)
@get_app_model_with_trial(None)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
"""Retrieve app parameters."""
@@ -866,7 +865,7 @@ class TrialAppParameterApi(Resource):
class AppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialAppDetailResponse.__name__])
@with_session(write=False)
@get_app_model_with_trial(None)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
"""Get app detail"""
@@ -882,7 +881,7 @@ class AppApi(Resource):
class AppWorkflowApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialWorkflowResponse.__name__])
@with_session(write=False)
@get_app_model_with_trial(None)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
"""Get workflow detail"""
if not app_model.workflow_id:
@@ -902,7 +901,7 @@ class DatasetListApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialDatasetListQuery))
@console_ns.response(200, "Success", console_ns.models[TrialDatasetListResponse.__name__])
@with_session(write=False)
@get_app_model_with_trial(None)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)

View File

@@ -2,19 +2,23 @@ from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from flask import abort
from flask_restx import Resource
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from controllers.console.explore.error import AppAccessDeniedError, TrialAppLimitExceeded, TrialAppNotAllowed
from controllers.console.explore.error import (
AppAccessDeniedError,
TrialAppFeatureDisabledError,
TrialAppLimitExceeded,
TrialAppNotAllowed,
)
from controllers.console.wraps import account_initialization_required
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.recommended_app_service import RecommendedAppService
def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None):
@@ -107,8 +111,8 @@ def trial_app_required[**P, R](view: Callable[Concatenate[App, P], R] | None = N
def trial_feature_enable[**P, R](view: Callable[P, R]):
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if not RecommendedAppService.is_trial_app_enabled():
abort(403, "Trial app feature is not enabled.")
if not application_services().recommended_app_queries.is_trial_enabled():
raise TrialAppFeatureDisabledError()
return view(*args, **kwargs)
return decorated

View File

@@ -75,18 +75,18 @@ class FeatureVectorSpaceApi(Resource):
@console_ns.route("/trial-models")
class TrialModelsApi(Resource):
@console_ns.doc("get_trial_models")
@console_ns.doc(description="Get hosted trial model provider configuration")
@console_ns.doc(description="Get hosted credit model provider configuration for the current workspace")
@console_ns.response(
200,
"Success",
console_ns.models[TrialModelsResponse.__name__],
)
@console_account_admission()
def get(self, _request_context: RequestContext):
"""Get hosted trial model provider configuration for model-provider pages."""
def get(self, request_context: RequestContext):
"""Get hosted credit provider configuration for the current workspace."""
return dump_response(
TrialModelsResponse,
{"trial_models": application_services().feature_queries.get_trial_models()},
{"trial_models": application_services().feature_queries.get_trial_models(request_context)},
)

View File

@@ -9,7 +9,12 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.wraps import enforce_rbac_access
from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
from controllers.console.wraps import (
account_initialization_required,
enable_change_email,
enterprise_license_required,
setup_required,
)
from core.logging.context import get_request_id, get_trace_id
from core.rbac import RBACPermission, RBACResourceScope
from enums import DeploymentEdition
@@ -22,6 +27,8 @@ from models.account import TenantAccountRole
def console_account_admission[T, **P, R](
*,
editions: frozenset[DeploymentEdition] | None = None,
require_change_email_enabled: bool = False,
require_initialized: bool = True,
require_valid_enterprise_license: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac_resource_scope: RBACResourceScope | None = None,
@@ -34,9 +41,9 @@ def console_account_admission[T, **P, R](
"""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, role/RBAC checks, then context
construction.
the execution order stays fixed: edition, setup, login/CSRF, optional
account initialization, optional enterprise license, role/RBAC checks, then
context construction.
"""
if (rbac_resource_scope is None) != (rbac_permission is None):
@@ -70,9 +77,12 @@ def console_account_admission[T, **P, R](
return view(self, request_context, *args, **kwargs)
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
if require_change_email_enabled:
admitted = enable_change_email(admitted)
if require_valid_enterprise_license:
admitted = enterprise_license_required(admitted)
admitted = account_initialization_required(admitted)
if require_initialized:
admitted = account_initialization_required(admitted)
admitted = login_required(admitted)
admitted = setup_required(admitted)

View File

@@ -167,3 +167,9 @@ class IncludeSecretQuery(BaseModel):
"""Query parameter for including secret variables in export."""
include_secret: str = Field(default="false", description="Whether to include secret variables")
class SnippetExportQuery(IncludeSecretQuery):
"""Query parameters for exporting a snippet workflow as DSL."""
workflow_id: str | None = Field(default=None, description="Specific published workflow version to export")

View File

@@ -57,12 +57,11 @@ 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.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
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__)
@@ -298,9 +297,8 @@ class SnippetPublishedWorkflowApi(Resource):
with Session(db.engine) as session:
snippet = session.merge(snippet)
tenant_id = snippet.tenant_id
try:
workflow, retirement_candidates = snippet_service.publish_workflow(
workflow = snippet_service.publish_workflow(
session=session,
snippet=snippet,
account=current_user,
@@ -310,16 +308,6 @@ 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,
@@ -480,6 +468,39 @@ class SnippetWorkflowByIdApi(Resource):
response["input_fields"] = snippet.input_fields_list
return response
@console_ns.doc("delete_snippet_workflow_by_id")
@console_ns.doc(description="Delete a published snippet workflow version")
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Workflow ID"})
@console_ns.response(204, "Workflow deleted successfully")
@console_ns.response(400, "Workflow is in use")
@console_ns.response(404, "Workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def delete(self, snippet: CustomizedSnippet, workflow_id: str):
"""Delete a published snippet workflow version."""
snippet_service = _snippet_service()
with _snippet_session_maker().begin() as session:
try:
snippet_service.delete_workflow(
session=session,
snippet=snippet,
workflow_id=workflow_id,
)
except WorkflowInUseError as e:
raise BadRequest(str(e))
except DraftWorkflowDeletionError as e:
raise BadRequest(str(e))
except ValueError as e:
raise NotFound(str(e))
return None, 204
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
class SnippetWorkflowRunsApi(Resource):

View File

@@ -3,37 +3,32 @@ from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, field_validator
from sqlalchemy import select
from werkzeug.exceptions import Forbidden
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.wraps import enforce_rbac_access
from controllers.console import console_ns
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
model_validate,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from models import Account
from libs.login import current_account_with_tenant
from machinery.context import RequestContext
from models.enums import TagType
from models.model import Tag
from services.tag_service import (
SaveTagPayload,
TagBindingCreatePayload,
TagBindingDeletePayload,
TagService,
UpdateTagPayload,
from services.tag_application_service import (
CreateTagInput,
TagBindingInput,
TagBindingTargetNotFoundError,
TagNameConflictError,
TagNotFoundError,
UpdateTagInput,
)
@@ -59,7 +54,7 @@ class TagBindingRemovePayload(BaseModel):
class TagListQueryParam(BaseModel):
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
type: Literal["knowledge", "app", "snippet", "skill"] = Field(description="Tag type filter")
keyword: str | None = Field(None, description="Search keyword")
@@ -101,143 +96,158 @@ register_schema_models(
register_response_schema_models(console_ns, SimpleResultResponse, TagResponse, TagListResponse)
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None) -> None:
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None, context: RequestContext) -> None:
if tag_type != TagType.SNIPPET:
return
if not dify_config.RBAC_ENABLED:
return
current_user, current_tenant_id = current_account_with_tenant()
enforce_rbac_access(
tenant_id=current_tenant_id,
account_id=current_user.id,
tenant_id=_workspace_id(context),
account_id=context.account_id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str) -> None:
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str, context: RequestContext) -> None:
if not dify_config.RBAC_ENABLED:
return
_, current_tenant_id = current_account_with_tenant()
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id, Tag.tenant_id == current_tenant_id).limit(1))
_enforce_snippet_tag_rbac_if_needed(tag_type)
tag_type = application_services().tags.get_tag_type(context, tag_id)
_enforce_snippet_tag_rbac_if_needed(tag_type, context)
def _workspace_id(context: RequestContext) -> str:
if context.active_workspace_id is None:
raise RuntimeError("Console account admission did not resolve an active workspace")
return context.active_workspace_id
def _require_tag_edit_permission(*, allow_dataset_editor: bool) -> None:
current_user, _ = current_account_with_tenant()
if current_user.has_edit_permission:
return
if allow_dataset_editor and current_user.is_dataset_editor:
return
raise Forbidden()
@console_ns.route("/tags")
class TagListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_account_admission()
@console_ns.doc(params=query_params_from_model(TagListQueryParam))
@console_ns.response(200, "Success", console_ns.models[TagListResponse.__name__])
@with_current_tenant_id
@model_validate(TagListQueryParam)
def get(self, req_data: TagListQueryParam, current_tenant_id: str):
tags = TagService.get_tags(req_data.type, current_tenant_id, req_data.keyword, session=db.session())
def get(self, req_data: TagListQueryParam, request_context: RequestContext):
tags = application_services().tags.list_tags(request_context, req_data.type, req_data.keyword)
return dump_response(TagListResponse, tags), 200
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[TagResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@console_account_admission()
@model_validate(TagBasePayload)
def post(self, req_data: TagBasePayload, current_user: Account):
def post(self, req_data: TagBasePayload, request_context: RequestContext):
# Allow users with edit permission, or dataset editors (including dataset operators).
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
raise Forbidden()
_require_tag_edit_permission(allow_dataset_editor=True)
_enforce_snippet_tag_rbac_if_needed(req_data.type)
tag = TagService.save_tags(SaveTagPayload(name=req_data.name, type=req_data.type), db.session())
_enforce_snippet_tag_rbac_if_needed(req_data.type, request_context)
try:
tag = application_services().tags.create_tag(
request_context,
CreateTagInput(name=req_data.name, type=req_data.type.value),
)
except TagNameConflictError as error:
raise ValueError(str(error)) from None
return dump_response(TagResponse, {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}), 200
return dump_response(TagResponse, tag), 200
@console_ns.route("/tags/<uuid:tag_id>")
class TagUpdateDeleteApi(Resource):
@console_ns.expect(console_ns.models[TagUpdateRequestPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[TagResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@console_account_admission()
@model_validate(TagUpdateRequestPayload)
def patch(self, req_data: TagUpdateRequestPayload, current_user: Account, tag_id: UUID):
def patch(self, req_data: TagUpdateRequestPayload, request_context: RequestContext, tag_id: UUID):
tag_id_str = str(tag_id)
# The role of the current user in the ta table must be admin, owner, or editor
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
raise Forbidden()
_require_tag_edit_permission(allow_dataset_editor=True)
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
tag = TagService.update_tags(UpdateTagPayload(name=req_data.name), tag_id_str, db.session())
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str, request_context)
try:
tag = application_services().tags.update_tag(
request_context,
tag_id_str,
UpdateTagInput(name=req_data.name),
)
except TagNameConflictError as error:
raise ValueError(str(error)) from None
except TagNotFoundError as error:
raise NotFound(str(error)) from None
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session())
return dump_response(TagResponse, tag), 200
return (
dump_response(
TagResponse,
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count},
),
200,
)
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@console_account_admission()
@console_ns.response(204, "Tag deleted successfully")
def delete(self, tag_id: UUID):
def delete(self, request_context: RequestContext, tag_id: UUID):
tag_id_str = str(tag_id)
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
TagService.delete_tag(tag_id_str, db.session())
_require_tag_edit_permission(allow_dataset_editor=False)
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str, request_context)
try:
application_services().tags.delete_tag(request_context, tag_id_str)
except TagNotFoundError as error:
raise NotFound(str(error)) from None
return "", 204
def _require_tag_binding_edit_permission(current_user: Account) -> None:
def _require_tag_binding_edit_permission() -> None:
"""
Ensure the current account can edit tag bindings.
Tag binding operations are allowed for users who can edit resources (app/dataset) within the current tenant.
"""
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
raise Forbidden()
_require_tag_edit_permission(allow_dataset_editor=True)
def _create_tag_bindings(current_user: Account, payload: TagBindingPayload) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
def _create_tag_bindings(context: RequestContext, payload: TagBindingPayload) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission()
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.save_tag_binding(
TagBindingCreatePayload(
tag_ids=payload.tag_ids,
target_id=payload.target_id,
type=payload.type,
),
db.session(),
)
_enforce_snippet_tag_rbac_if_needed(payload.type, context)
try:
application_services().tags.create_bindings(
context,
TagBindingInput(
tag_ids=tuple(payload.tag_ids),
target_id=payload.target_id,
type=payload.type.value,
),
)
except TagBindingTargetNotFoundError as error:
raise NotFound(str(error)) from None
return {"result": "success"}, 200
def _remove_tag_bindings(current_user: Account, payload: TagBindingRemovePayload) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
def _remove_tag_bindings(context: RequestContext, payload: TagBindingRemovePayload) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission()
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.delete_tag_binding(
TagBindingDeletePayload(
tag_ids=payload.tag_ids,
target_id=payload.target_id,
type=payload.type,
),
db.session(),
)
_enforce_snippet_tag_rbac_if_needed(payload.type, context)
try:
application_services().tags.delete_bindings(
context,
TagBindingInput(
tag_ids=tuple(payload.tag_ids),
target_id=payload.target_id,
type=payload.type.value,
),
)
except TagBindingTargetNotFoundError as error:
raise NotFound(str(error)) from None
return {"result": "success"}, 200
@@ -248,13 +258,10 @@ class TagBindingCollectionApi(Resource):
@console_ns.doc("create_tag_binding")
@console_ns.expect(console_ns.models[TagBindingPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@console_account_admission()
@model_validate(TagBindingPayload)
def post(self, req_data: TagBindingPayload, current_user: Account):
return _create_tag_bindings(current_user, req_data)
def post(self, req_data: TagBindingPayload, request_context: RequestContext):
return _create_tag_bindings(request_context, req_data)
@console_ns.route("/tag-bindings/remove")
@@ -265,10 +272,7 @@ class TagBindingRemoveApi(Resource):
@console_ns.doc(description="Remove one or more tag bindings from a target.")
@console_ns.expect(console_ns.models[TagBindingRemovePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@console_account_admission()
@model_validate(TagBindingRemovePayload)
def post(self, req_data: TagBindingRemovePayload, current_user: Account):
return _remove_tag_bindings(current_user, req_data)
def post(self, req_data: TagBindingRemovePayload, request_context: RequestContext):
return _remove_tag_bindings(request_context, req_data)

View File

@@ -2,16 +2,14 @@ from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
from typing import Literal
from typing import Annotated, Literal
import pytz
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.json_schema import SkipJsonSchema
from werkzeug.exceptions import NotFound
from configs import dify_config
from constants.languages import supported_language
from controllers.common.fields import (
AvatarUrlResponse,
@@ -24,48 +22,39 @@ from controllers.console import console_ns
from controllers.console.auth.error import (
EmailAlreadyInUseError,
EmailChangeLimitError,
EmailChangeRateLimitExceededError,
EmailCodeAccountDeletionRateLimitExceededError,
EmailCodeError,
InvalidEmailError,
InvalidTokenError,
)
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
from controllers.console.error import (
AccountInFreezeError,
AccountNotFound,
EducationActivateLimitError,
EducationVerifyLimitError,
EmailDomainSuspendedError,
EmailSendIpLimitError,
)
from controllers.console.flask_admission import console_account_admission
from controllers.console.workspace.error import (
AccountAlreadyInitedError,
CurrentPasswordIncorrectError,
InvalidAccountDeletionCodeError,
InvalidAccountPasswordRequestError,
InvalidInvitationCodeError,
MissingInvitationCodeRequestError,
RepeatPasswordNotMatchError,
)
from controllers.console.wraps import (
account_initialization_required,
enable_change_email,
enterprise_license_required,
model_validate,
only_edition_cloud,
setup_required,
with_current_user,
)
from controllers.console.wraps import model_validate, setup_required
from enums import DeploymentEdition
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from fields.member_fields import AccountResponse
from graphon.file import helpers as file_helpers
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp
from libs.login import login_required
from models import Account, AccountIntegrate, InvitationCode
from models.account import AccountStatus, InvitationCodeStatus
from models.enums import CreatorUserRole
from models.model import UploadFile
from services.account_service import AccountService
from services.billing_service import BillingService
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
ChangeEmailNewEmailVerifiedToken,
ChangeEmailOldEmailToken,
ChangeEmailOldEmailVerifiedToken,
)
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
from machinery.context import RequestContext
from services import account_errors
from services.entities.account_entities import AccountProfileChanges
class AccountInitPayload(BaseModel):
@@ -118,6 +107,42 @@ class AccountTimezonePayload(BaseModel):
return timezone(value)
class AccountProfilePatchPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
name: Annotated[str, Field(min_length=3, max_length=30)] | SkipJsonSchema[None] = None
avatar: str | SkipJsonSchema[None] = None
interface_language: str | SkipJsonSchema[None] = None
interface_theme: Literal["light", "dark"] | SkipJsonSchema[None] = None
timezone: str | SkipJsonSchema[None] = None
@field_validator("*", mode="before")
@classmethod
def reject_null(cls, value: object) -> object:
if value is None:
raise ValueError("Account profile fields cannot be null")
return value
@field_validator("interface_language")
@classmethod
def validate_language(cls, value: str) -> str:
return supported_language(value)
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
return timezone(value)
def to_changes(self) -> AccountProfileChanges:
return AccountProfileChanges(
name=self.name,
avatar=self.avatar,
interface_language=self.interface_language,
interface_theme=self.interface_theme,
timezone=self.timezone,
)
class AccountPasswordPayload(BaseModel):
password: str | None = None
new_password: str
@@ -183,6 +208,7 @@ register_schema_models(
AccountInterfaceLanguagePayload,
AccountInterfaceThemePayload,
AccountTimezonePayload,
AccountProfilePatchPayload,
AccountPasswordPayload,
AccountDeletePayload,
AccountDeletionFeedbackPayload,
@@ -215,6 +241,10 @@ class EducationVerifyResponse(ResponseModel):
token: str | None = None
class EducationActivateResponse(ResponseModel):
message: str
class EducationStatusResponse(ResponseModel):
result: bool | None = None
is_student: bool | None = None
@@ -239,6 +269,7 @@ register_response_schema_models(
AccountIntegrateResponse,
AccountIntegrateListResponse,
AvatarUrlResponse,
EducationActivateResponse,
EducationVerifyResponse,
EducationStatusResponse,
EducationAutocompleteResponse,
@@ -248,78 +279,75 @@ register_response_schema_models(
)
def _update_account_profile(request_context: RequestContext, changes: AccountProfileChanges) -> dict[str, object]:
try:
account = application_services().accounts.profile.update(request_context, changes)
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
return dump_response(AccountResponse, account)
@console_ns.route("/account/init")
class AccountInitApi(Resource):
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@with_current_user
def post(self, account: Account):
if account.status == "active":
raise AccountAlreadyInitedError()
@console_account_admission(require_initialized=False)
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInitPayload.model_validate(payload)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if not args.invitation_code:
raise ValueError("invitation_code is required")
# check invitation code
invitation_code = db.session.scalar(
select(InvitationCode)
.where(
InvitationCode.code == args.invitation_code,
InvitationCode.status == InvitationCodeStatus.UNUSED,
)
.limit(1)
try:
application_services().accounts.initialization.initialize(
request_context,
interface_language=args.interface_language,
timezone=args.timezone,
invitation_code=args.invitation_code,
)
if not invitation_code:
raise InvalidInvitationCodeError()
invitation_code.status = InvitationCodeStatus.USED
invitation_code.used_at = naive_utc_now()
invitation_code.used_by_tenant_id = account.current_tenant_id
invitation_code.used_by_account_id = account.id
account.interface_language = args.interface_language
account.timezone = args.timezone
account.interface_theme = "light"
account.status = AccountStatus.ACTIVE
account.initialized_at = naive_utc_now()
db.session.commit()
except account_errors.AccountAlreadyInitializedError as error:
raise AccountAlreadyInitedError() from error
except account_errors.MissingInvitationCodeError as error:
raise MissingInvitationCodeRequestError() from error
except account_errors.InvalidInvitationCodeError as error:
raise InvalidInvitationCodeError() from error
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
return SimpleResultResponse(result="success").model_dump(mode="json")
@console_ns.route("/account/profile")
class AccountProfileApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@enterprise_license_required
@with_current_user
def get(self, current_user: Account):
return dump_response(AccountResponse, current_user)
@console_account_admission(require_valid_enterprise_license=True)
def get(self, request_context: RequestContext):
try:
account = application_services().accounts.profile.get(request_context)
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
return dump_response(AccountResponse, account)
@console_ns.expect(console_ns.models[AccountProfilePatchPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_account_admission()
@model_validate(AccountProfilePatchPayload)
def patch(self, args: AccountProfilePatchPayload, request_context: RequestContext):
return _update_account_profile(request_context, args.to_changes())
@console_ns.route("/account/name")
class AccountNameApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_name_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountNamePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountNamePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session(), name=args.name)
return dump_response(AccountResponse, updated_account)
return _update_account_profile(request_context, AccountProfileChanges(name=args.name))
@console_ns.route("/account/avatar")
@@ -328,169 +356,139 @@ class AccountAvatarApi(Resource):
@console_ns.doc(description="Get account avatar url")
@console_ns.doc(params=query_params_from_model(AccountAvatarQuery))
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AvatarUrlResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@console_account_admission()
@model_validate(AccountAvatarQuery)
def get(self, req_data: AccountAvatarQuery, current_user: Account):
avatar = req_data.avatar
if avatar.startswith(("http://", "https://")):
return AvatarUrlResponse(avatar_url=avatar).model_dump(mode="json")
upload_file = db.session.scalar(select(UploadFile).where(UploadFile.id == avatar).limit(1))
if upload_file is None:
raise NotFound("Avatar file not found")
if upload_file.created_by_role != CreatorUserRole.ACCOUNT or upload_file.created_by != current_user.id:
raise NotFound("Avatar file not found")
avatar_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id)
def get(self, args: AccountAvatarQuery, request_context: RequestContext):
try:
avatar_url = application_services().accounts.avatar.resolve(request_context, args.avatar)
except account_errors.AvatarFileNotFoundError as error:
raise NotFound("Avatar file not found") from error
return AvatarUrlResponse(avatar_url=avatar_url).model_dump(mode="json")
@console_ns.expect(console_ns.models[AccountAvatarPayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.doc("update_account_avatar_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountAvatarPayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session(), avatar=args.avatar)
return dump_response(AccountResponse, updated_account)
return _update_account_profile(request_context, AccountProfileChanges(avatar=args.avatar))
@console_ns.route("/account/interface-language")
class AccountInterfaceLanguageApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_interface_language_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountInterfaceLanguagePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInterfaceLanguagePayload.model_validate(payload)
updated_account = AccountService.update_account(
current_user, session=db.session(), interface_language=args.interface_language
return _update_account_profile(
request_context,
AccountProfileChanges(interface_language=args.interface_language),
)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/interface-theme")
class AccountInterfaceThemeApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_interface_theme_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountInterfaceThemePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInterfaceThemePayload.model_validate(payload)
updated_account = AccountService.update_account(
current_user, session=db.session(), interface_theme=args.interface_theme
return _update_account_profile(
request_context,
AccountProfileChanges(interface_theme=args.interface_theme),
)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/timezone")
class AccountTimezoneApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_timezone_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountTimezonePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountTimezonePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session(), timezone=args.timezone)
return dump_response(AccountResponse, updated_account)
return _update_account_profile(request_context, AccountProfileChanges(timezone=args.timezone))
@console_ns.route("/account/password")
class AccountPasswordApi(Resource):
@console_ns.expect(console_ns.models[AccountPasswordPayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountPasswordPayload.model_validate(payload)
try:
assert args.password is not None
AccountService.update_account_password(current_user, args.password, args.new_password, session=db.session())
except ServiceCurrentPasswordIncorrectError:
raise CurrentPasswordIncorrectError()
account = application_services().accounts.password.change(
request_context,
current_password=args.password,
new_password=args.new_password,
)
except account_errors.CurrentAccountPasswordIncorrectError as error:
raise CurrentPasswordIncorrectError() from error
except account_errors.InvalidAccountPasswordError as error:
raise InvalidAccountPasswordRequestError(description=str(error)) from error
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
return dump_response(AccountResponse, current_user)
return dump_response(AccountResponse, account)
@console_ns.route("/account/integrates")
class AccountIntegrateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountIntegrateListResponse.__name__])
@with_current_user
def get(self, account: Account):
account_integrates = db.session.scalars(
select(AccountIntegrate).where(AccountIntegrate.account_id == account.id)
).all()
@console_account_admission()
def get(self, request_context: RequestContext):
base_url = request.url_root.rstrip("/")
oauth_base_path = "/console/api/oauth/login"
providers = ["github", "google"]
integrate_data: list[AccountIntegrateResponse] = []
for provider in providers:
existing_integrate = next((ai for ai in account_integrates if ai.provider == provider), None)
if existing_integrate:
integrate_data.append(
AccountIntegrateResponse(
provider=provider,
created_at=to_timestamp(existing_integrate.created_at),
is_bound=True,
link=None,
)
)
else:
integrate_data.append(
AccountIntegrateResponse(
provider=provider,
created_at=None,
is_bound=False,
link=f"{base_url}{oauth_base_path}/{provider}",
)
)
integrations = application_services().accounts.integrations.list(request_context)
integrate_data = [
AccountIntegrateResponse(
provider=integration.provider,
created_at=to_timestamp(integration.created_at),
is_bound=integration.is_bound,
link=(None if integration.is_bound else f"{base_url}{oauth_base_path}/{integration.provider}"),
)
for integration in integrations
]
return AccountIntegrateListResponse(data=integrate_data).model_dump(mode="json")
@console_ns.route("/account/delete/verify")
class AccountDeleteVerifyApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@with_current_user
def get(self, account: Account):
token, code = AccountService.generate_account_deletion_verification_code(account)
AccountService.send_account_deletion_verification_email(account, code)
@console_account_admission()
def get(self, request_context: RequestContext):
try:
token = application_services().accounts.deletion.issue_verification(request_context)
except account_errors.AccountDeletionRateLimitError as error:
raise EmailCodeAccountDeletionRateLimitExceededError(error.retry_after_minutes) from None
except account_errors.AccountNotFoundError:
raise AccountNotFound() from None
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@@ -499,18 +497,19 @@ class AccountDeleteVerifyApi(Resource):
class AccountDeleteApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
def post(self, account: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountDeletePayload.model_validate(payload)
if not AccountService.verify_account_deletion_code(args.token, args.code):
raise InvalidAccountDeletionCodeError()
AccountService.delete_account(account, session=db.session())
try:
application_services().accounts.deletion.request_deletion(
request_context,
token=args.token,
code=args.code,
)
except account_errors.InvalidAccountDeletionVerificationError:
raise InvalidAccountDeletionCodeError() from None
return SimpleResultResponse(result="success").model_dump(mode="json")
@@ -524,71 +523,69 @@ class AccountDeleteUpdateFeedbackApi(Resource):
payload = console_ns.payload or {}
args = AccountDeletionFeedbackPayload.model_validate(payload)
BillingService.update_account_deletion_feedback(args.email, args.feedback)
application_services().accounts.deletion_feedback.submit(email=args.email, feedback=args.feedback)
return SimpleResultResponse(result="success").model_dump(mode="json")
@console_ns.route("/account/education/verify")
class EducationVerifyApi(Resource):
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationVerifyResponse.__name__])
@with_current_user
def get(self, account: Account):
return dump_response(
EducationVerifyResponse, BillingService.EducationIdentity.verify(account.id, account.email) or {}
)
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
def get(self, request_context: RequestContext):
try:
verification = application_services().accounts.education.verify(request_context)
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
except account_errors.EducationRateLimitExceededError as error:
raise EducationVerifyLimitError() from error
return dump_response(EducationVerifyResponse, verification)
@console_ns.route("/account/education")
class EducationApi(Resource):
@console_ns.expect(console_ns.models[EducationActivatePayload.__name__])
# response-contract:ignore billing-service activation payload; TODO: model education activation result.
@console_ns.response(HTTPStatus.OK, "Success")
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@with_current_user
def post(self, account: Account):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationActivateResponse.__name__])
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = EducationActivatePayload.model_validate(payload)
try:
activation = application_services().accounts.education.activate(
request_context,
token=args.token,
institution=args.institution,
role=args.role,
)
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
except account_errors.EducationRateLimitExceededError as error:
raise EducationActivateLimitError() from error
return dump_response(EducationActivateResponse, activation)
result = BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role)
return result
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__])
@with_current_user
def get(self, account: Account):
res = BillingService.EducationIdentity.status(account.id) or {}
# convert expire_at to UTC timestamp from isoformat
if res and "expire_at" in res:
res["expire_at"] = datetime.fromisoformat(res["expire_at"]).astimezone(pytz.utc)
return dump_response(EducationStatusResponse, res)
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
def get(self, request_context: RequestContext):
return dump_response(EducationStatusResponse, application_services().accounts.education.status(request_context))
@console_ns.route("/account/education/autocomplete")
class EducationAutoCompleteApi(Resource):
@console_ns.doc(params=query_params_from_model(EducationAutocompleteQuery))
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
def get(self):
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
def get(self, request_context: RequestContext):
payload = request.args.to_dict(flat=True)
args = EducationAutocompleteQuery.model_validate(payload)
return dump_response(
EducationAutocompleteResponse,
BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) or {},
application_services().accounts.education.autocomplete(
request_context,
keywords=args.keywords,
page=args.page,
limit=args.limit,
),
)
@@ -596,59 +593,32 @@ class EducationAutoCompleteApi(Resource):
class ChangeEmailSendEmailApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailSendPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@enable_change_email
@setup_required
@login_required
@account_initialization_required
@with_current_user
def post(self, current_user: Account):
@console_account_admission(require_change_email_enabled=True)
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = ChangeEmailSendPayload.model_validate(payload)
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
raise EmailSendIpLimitError()
if args.language is not None and args.language == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
account = current_user
user_email = current_user.email
email_for_sending = args.email.lower()
# Default to the initial phase; any legacy/unexpected client input is
# coerced back to `old_email` so we never trust the caller to declare
# later phases without a verified predecessor token.
send_phase = AccountService.CHANGE_EMAIL_PHASE_OLD
if args.phase is not None and args.phase == AccountService.CHANGE_EMAIL_PHASE_NEW:
send_phase = AccountService.CHANGE_EMAIL_PHASE_NEW
if args.token is None:
raise InvalidTokenError()
reset_data = AccountService.get_change_email_data(args.token)
if reset_data is None:
raise InvalidTokenError()
if not isinstance(reset_data, ChangeEmailOldEmailVerifiedToken):
raise InvalidTokenError()
if not reset_data.is_bound_to_account(current_user.id):
raise InvalidTokenError()
user_email = reset_data.email
if user_email.lower() != current_user.email.lower():
raise InvalidEmailError()
else:
if email_for_sending != current_user.email.lower():
raise InvalidEmailError()
email_for_sending = current_user.email
token = AccountService.send_change_email_email(
account=account,
email=email_for_sending,
old_email=user_email,
language=language,
phase=send_phase,
)
language = "zh-Hans" if args.language == "zh-Hans" else "en-US"
try:
token = application_services().accounts.change_email.send_code(
request_context,
requested_email=args.email,
language=language,
phase=args.phase,
predecessor_token=args.token,
ip_address=ip_address,
)
except account_errors.ChangeEmailSendIPLimitedError:
raise EmailSendIpLimitError() from None
except account_errors.ChangeEmailSendRateLimitError as error:
raise EmailChangeRateLimitExceededError(error.retry_after_minutes) from None
except account_errors.InvalidChangeEmailTokenError:
raise InvalidTokenError() from None
except account_errors.InvalidChangeEmailAddressError:
raise InvalidEmailError() from None
except account_errors.AccountNotFoundError:
raise AccountNotFound() from None
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@@ -656,47 +626,27 @@ class ChangeEmailSendEmailApi(Resource):
class ChangeEmailCheckApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailValidityPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
@enable_change_email
@setup_required
@login_required
@account_initialization_required
@with_current_user
def post(self, current_user: Account):
@console_account_admission(require_change_email_enabled=True)
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = ChangeEmailValidityPayload.model_validate(payload)
user_email = args.email.lower()
is_change_email_error_rate_limit = AccountService.is_change_email_error_rate_limit(user_email)
if is_change_email_error_rate_limit:
raise EmailChangeLimitError()
token_data = AccountService.get_change_email_data(args.token)
if token_data is None:
raise InvalidTokenError()
if not token_data.is_bound_to_account(current_user.id):
raise InvalidTokenError()
normalized_token_email = token_data.email.lower()
if user_email != normalized_token_email:
raise InvalidEmailError()
if args.code != token_data.code:
AccountService.add_change_email_error_rate_limit(user_email)
raise EmailCodeError()
if isinstance(token_data, ChangeEmailOldEmailToken | ChangeEmailNewEmailToken):
refreshed_token_data = token_data.promote()
else:
raise InvalidTokenError()
# Verified, revoke the first token
AccountService.revoke_change_email_token(args.token)
new_token = AccountService.generate_change_email_token(refreshed_token_data, current_user)
AccountService.reset_change_email_error_rate_limit(user_email)
return VerificationTokenResponse(is_valid=True, email=normalized_token_email, token=new_token).model_dump(
try:
verification = application_services().accounts.change_email.verify_code(
request_context,
email=args.email,
code=args.code,
token=args.token,
)
except account_errors.ChangeEmailVerificationLimitError:
raise EmailChangeLimitError() from None
except account_errors.InvalidChangeEmailTokenError:
raise InvalidTokenError() from None
except account_errors.InvalidChangeEmailAddressError:
raise InvalidEmailError() from None
except account_errors.InvalidChangeEmailCodeError:
raise EmailCodeError() from None
return VerificationTokenResponse(is_valid=True, email=verification.email, token=verification.token).model_dump(
mode="json"
)
@@ -704,51 +654,27 @@ class ChangeEmailCheckApi(Resource):
@console_ns.route("/account/change-email/reset")
class ChangeEmailResetApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailResetPayload.__name__])
@enable_change_email
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission(require_change_email_enabled=True)
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = ChangeEmailResetPayload.model_validate(payload)
normalized_new_email = args.new_email.lower()
if AccountService.is_account_in_freeze(normalized_new_email):
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_new_email, session=db.session()):
raise EmailAlreadyInUseError()
reset_data = AccountService.get_change_email_data(args.token)
if not reset_data:
raise InvalidTokenError()
if not reset_data.is_bound_to_account(current_user.id):
raise InvalidTokenError()
if not isinstance(reset_data, ChangeEmailNewEmailVerifiedToken):
raise InvalidTokenError()
# Bind the new email to the token that was mailed and verified, so a
# verified token cannot be reused with a different `new_email` value.
if reset_data.email.lower() != normalized_new_email:
raise InvalidTokenError()
if current_user.email.lower() != reset_data.old_email.lower():
raise AccountNotFound()
# Revoke only after all checks pass so failed attempts don't burn a
# legitimately verified token.
AccountService.revoke_change_email_token(args.token)
updated_account = AccountService.update_account_email(
current_user, email=normalized_new_email, session=db.session()
)
AccountService.send_change_email_completed_notify_email(
email=normalized_new_email,
)
try:
updated_account = application_services().accounts.change_email.reset(
request_context,
new_email=args.new_email,
token=args.token,
)
except account_errors.AccountEmailDomainSuspendedError:
raise EmailDomainSuspendedError() from None
except account_errors.AccountEmailFrozenError:
raise AccountInFreezeError() from None
except account_errors.AccountEmailAlreadyInUseError:
raise EmailAlreadyInUseError() from None
except account_errors.InvalidChangeEmailTokenError:
raise InvalidTokenError() from None
except account_errors.AccountNotFoundError:
raise AccountNotFound() from None
return dump_response(AccountResponse, updated_account)
@@ -761,9 +687,12 @@ class CheckEmailUnique(Resource):
def post(self):
payload = console_ns.payload or {}
args = CheckEmailUniquePayload.model_validate(payload)
normalized_email = args.email.lower()
if AccountService.is_account_in_freeze(normalized_email):
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_email, session=db.session()):
raise EmailAlreadyInUseError()
try:
application_services().accounts.change_email.ensure_available(args.email)
except account_errors.AccountEmailDomainSuspendedError:
raise EmailDomainSuspendedError() from None
except account_errors.AccountEmailFrozenError:
raise AccountInFreezeError() from None
except account_errors.AccountEmailAlreadyInUseError:
raise EmailAlreadyInUseError() from None
return SimpleResultResponse(result="success").model_dump(mode="json")

View File

@@ -290,6 +290,8 @@ class EndpointListApi(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -318,6 +320,8 @@ class EndpointListForSinglePluginApi(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id

View File

@@ -19,12 +19,24 @@ class CurrentPasswordIncorrectError(BaseHTTPException):
code = 400
class InvalidAccountPasswordRequestError(BaseHTTPException):
error_code = "invalid_account_password"
description = "The password does not satisfy the account password policy."
code = 400
class InvalidInvitationCodeError(BaseHTTPException):
error_code = "invalid_invitation_code"
description = "Invalid invitation code."
code = 400
class MissingInvitationCodeRequestError(BaseHTTPException):
error_code = "missing_invitation_code"
description = "Invitation code is required."
code = 400
class AccountAlreadyInitedError(BaseHTTPException):
error_code = "account_already_inited"
description = "The account has been initialized. Please refresh the page."

View File

@@ -20,7 +20,6 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
@@ -209,6 +208,8 @@ class ModelProviderCredentialApi(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, provider: str):
@@ -401,6 +402,7 @@ class PreferredProviderTypeUpdateApi(Resource):
@console_ns.route("/workspaces/current/model-providers/<path:provider>/checkout-url")
class ModelProviderPaymentCheckoutUrlApi(Resource):
@console_ns.doc(deprecated=True)
@console_ns.response(
200,
"Model provider checkout URL retrieved successfully",
@@ -408,13 +410,14 @@ class ModelProviderPaymentCheckoutUrlApi(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, provider: str):
if provider != "anthropic":
raise ValueError(f"provider name {provider} is invalid")
BillingService.is_tenant_owner_or_admin(current_user, session=db.session())
# pyrefly: ignore [deprecated]
data = BillingService.get_model_provider_payment_link(
provider_name=provider,
tenant_id=current_tenant_id,

View File

@@ -210,6 +210,8 @@ class DefaultModelApi(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
@account_initialization_required
@with_current_tenant_id
@model_validate(ParserGetDefault)
@@ -263,6 +265,8 @@ class ModelProviderModelApi(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, provider: str):
@@ -349,6 +353,8 @@ class ModelProviderModelCredentialApi(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_user
@with_current_tenant_id

View File

@@ -0,0 +1,883 @@
"""Console API for workspace-level Skill Management."""
from __future__ import annotations
import io
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from sqlalchemy.orm import Session
from controllers.common.fields import BinaryFileResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
edit_permission_required,
)
from fields.base import ResponseModel
from libs import helper
from libs.helper import dump_response
from machinery.context import RequestContext
from services.skill_management_service import (
SkillAssistMessagePayload,
SkillCreatePayload,
SkillDraftFileCheckPayload,
SkillDraftFileOperationPayload,
SkillDraftTreePayload,
SkillImportPayload,
SkillManagementService,
SkillManagementServiceError,
SkillMetadataPayload,
SkillPublishPayload,
SkillRestorePayload,
SkillVersionUpdatePayload,
)
_FILE_UPLOAD_PARAMS = {
"file": {
"description": "Skill draft file payload",
"in": "formData",
"type": "file",
"required": True,
},
}
class WorkspaceSkillsQuery(BaseModel):
keyword: str | None = Field(default=None, description="Search keyword matching skill name or description.")
page: int = Field(default=1, ge=1, le=99999, description="Page number.")
limit: int = Field(default=20, ge=1, le=100, description="Number of items per page.")
tag: list[str] = Field(
default_factory=list,
description="Skill tag filters. Repeat the parameter for multiple tags.",
)
class SkillDeletePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
confirmation_name: str | None = Field(
default=None,
description="Required when deleting a referenced Skill. Must match the Skill display name.",
)
class AgentSkillBindingsPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
skill_ids: list[str] = Field(default_factory=list, description="Ordered Skill IDs bound to the Agent.")
class SkillFileQuery(BaseModel):
path: str = Field(description="Skill file path relative to the Skill root.")
version_id: str | None = Field(default=None, description="Optional published version ID. Omit for current draft.")
class SkillResponse(ResponseModel):
id: str
name: str
display_name: str
icon: str
description: str
tags: list[str] = Field(default_factory=list)
name_manually_edited: bool = False
visibility: str
latest_published_version_id: str | None = None
latest_published_version_number: int | None = None
latest_published_at: int | None = None
reference_count: int = 0
created_by: str | None = None
created_by_name: str | None = None
updated_by: str | None = None
updated_by_name: str | None = None
created_at: int
updated_at: int
class SkillFileResponse(ResponseModel):
id: str | None = None
path: str
kind: str
storage: str | None = None
mime_type: str | None = None
content: str | None = None
tool_file_id: str | None = None
size: int | None = None
hash: str | None = None
class SkillFilePreviewResponse(ResponseModel):
path: str
mime_type: str
content: str
size: int
hash: str
class SkillFileUploadResponse(ResponseModel):
id: str
name: str
mime_type: str
size: int
hash: str
class SkillFileCheckErrorResponse(ResponseModel):
code: str
message: str
class SkillFileCheckItemResponse(ResponseModel):
path: str
filename: str
extension: str
mime_type: str
size: int
errors: list[SkillFileCheckErrorResponse] = Field(default_factory=list)
class SkillFileCheckResponse(ResponseModel):
data: dict[str, SkillFileCheckItemResponse] = Field(default_factory=dict)
class SkillDetailResponse(SkillResponse):
files: list[SkillFileResponse] = Field(default_factory=list)
class SkillListResponse(ResponseModel):
data: list[SkillResponse] = Field(default_factory=list)
has_more: bool = False
limit: int = 20
page: int = 1
total: int = 0
class SkillTagResponse(ResponseModel):
tag: str
count: int
class SkillTagListResponse(ResponseModel):
data: list[SkillTagResponse] = Field(default_factory=list)
class SkillVersionResponse(ResponseModel):
id: str
skill_id: str
version_number: int
version_name: str
publish_note: str
hash_code: str
archive_size: int
published_by: str | None = None
published_by_name: str | None = None
is_latest: bool = False
created_at: int
class SkillVersionListResponse(ResponseModel):
data: list[SkillVersionResponse] = Field(default_factory=list)
class SkillVersionDetailResponse(SkillVersionResponse):
files: list[SkillFileResponse] = Field(default_factory=list)
class SkillVersionDeleteResponse(ResponseModel):
id: str
deleted: bool
latest_published_version_id: str | None = None
class SkillReferenceResponse(ResponseModel):
type: str
agent_id: str
agent_icon: str | None = None
agent_icon_background: str | None = None
agent_icon_type: str | None = None
app_id: str | None = None
name: str
display_name: str
workflow_id: str | None = None
workflow_name: str | None = None
workflow_icon: str | None = None
workflow_icon_background: str | None = None
workflow_icon_type: str | None = None
workflow_version: str | None = None
node_id: str | None = None
node_name: str | None = None
class SkillReferenceListResponse(ResponseModel):
data: list[SkillReferenceResponse] = Field(default_factory=list)
class SkillDeleteResponse(ResponseModel):
id: str
deleted: bool
class AgentSkillBindingItemResponse(ResponseModel):
id: str
priority: int
name: str
display_name: str
icon: str
description: str
tags: list[str] = Field(default_factory=list)
status: str
file_count: int
latest_published_version_id: str | None = None
latest_published_at: int | None = None
updated_at: int
class AgentSkillBindingsResponse(ResponseModel):
agent_id: str
skill_ids: list[str] = Field(default_factory=list)
data: list[AgentSkillBindingItemResponse] = Field(default_factory=list)
register_schema_models(
console_ns,
WorkspaceSkillsQuery,
SkillCreatePayload,
SkillAssistMessagePayload,
SkillMetadataPayload,
SkillDraftFileCheckPayload,
SkillDraftFileOperationPayload,
SkillDraftTreePayload,
SkillPublishPayload,
SkillRestorePayload,
SkillVersionUpdatePayload,
SkillDeletePayload,
SkillFileQuery,
AgentSkillBindingsPayload,
)
register_response_schema_models(
console_ns,
SkillResponse,
SkillFileResponse,
SkillFilePreviewResponse,
SkillFileUploadResponse,
SkillFileCheckErrorResponse,
SkillFileCheckItemResponse,
SkillFileCheckResponse,
SkillDetailResponse,
SkillListResponse,
SkillTagResponse,
SkillTagListResponse,
SkillVersionResponse,
SkillVersionListResponse,
SkillVersionDetailResponse,
SkillVersionDeleteResponse,
SkillReferenceResponse,
SkillReferenceListResponse,
SkillDeleteResponse,
AgentSkillBindingItemResponse,
AgentSkillBindingsResponse,
BinaryFileResponse,
)
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, object], int]:
body: dict[str, object] = {"code": exc.code, "message": exc.message}
if exc.details:
body["details"] = exc.details
return body, exc.status_code
def _workspace_id(context: RequestContext) -> str:
if context.active_workspace_id is None:
raise RuntimeError("Console account admission did not resolve an active workspace")
return context.active_workspace_id
@console_ns.route("/workspaces/current/skills")
class WorkspaceSkillsApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkspaceSkillsQuery))
@console_ns.response(200, "Workspace skills", console_ns.models[SkillListResponse.__name__])
@console_account_admission(
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.SKILL_VIEW,
rbac_resource_required=False,
)
@with_session(write=False)
def get(self, session: Session, request_context: RequestContext):
query_input: dict[str, object] = {
"keyword": request.args.get("keyword"),
"tag": request.args.getlist("tag"),
}
if "limit" in request.args:
query_input["limit"] = request.args.get("limit")
if "page" in request.args:
query_input["page"] = request.args.get("page")
query = WorkspaceSkillsQuery.model_validate(query_input)
result = SkillManagementService(session=session).list_skills(
tenant_id=_workspace_id(request_context),
keyword=query.keyword,
page=query.page,
limit=query.limit,
tags=[tag for tag in query.tag if tag],
)
return dump_response(SkillListResponse, result)
@console_ns.expect(console_ns.models[SkillCreatePayload.__name__])
@console_ns.response(201, "Skill created", console_ns.models[SkillDetailResponse.__name__])
@console_account_admission(
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.SKILL_EDIT,
rbac_resource_required=False,
)
@edit_permission_required
@with_session
def post(self, session: Session, request_context: RequestContext):
try:
payload = SkillCreatePayload.model_validate(console_ns.payload or {})
result = SkillManagementService(session=session).create_skill(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result), 201
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/files/upload")
class WorkspaceSkillFileUploadApi(Resource):
@console_ns.doc(consumes=["multipart/form-data"], params=_FILE_UPLOAD_PARAMS)
@console_ns.response(201, "Skill draft file uploaded", console_ns.models[SkillFileUploadResponse.__name__])
@console_account_admission()
@edit_permission_required
@with_session
def post(self, session: Session, request_context: RequestContext):
if "file" not in request.files:
return {"code": "no_file_uploaded", "message": "no file uploaded"}, 400
file = request.files["file"]
if not file.filename:
return {"code": "filename_missing", "message": "filename is required"}, 400
try:
result = SkillManagementService(session=session).upload_file(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
filename=file.filename,
content=file.stream.read(),
mime_type=file.mimetype,
)
return dump_response(SkillFileUploadResponse, result), 201
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/tags")
class WorkspaceSkillTagsApi(Resource):
@console_ns.response(200, "Workspace Skill tags", console_ns.models[SkillTagListResponse.__name__])
@console_account_admission()
@with_session(write=False)
def get(self, session: Session, request_context: RequestContext):
result = SkillManagementService(session=session).list_tags(tenant_id=_workspace_id(request_context))
return dump_response(SkillTagListResponse, result)
@console_ns.route("/workspaces/current/skills/import")
class WorkspaceSkillImportApi(Resource):
@console_ns.doc(description="Import a Skill zip package from multipart form field `file`.")
@console_ns.response(201, "Skill imported", console_ns.models[SkillDetailResponse.__name__])
@console_account_admission(
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.SKILL_EDIT,
rbac_resource_required=False,
)
@edit_permission_required
@with_session
def post(self, session: Session, request_context: RequestContext):
upload = request.files.get("file")
if upload is None:
return {"code": "invalid_request", "message": "file is required"}, 400
try:
payload = SkillImportPayload(content=upload.read(), filename=upload.filename or "skill.zip")
result = SkillManagementService(session=session).import_skill(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result), 201
except (ValidationError, ValueError) as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>")
class WorkspaceSkillApi(Resource):
@console_ns.response(200, "Skill detail", console_ns.models[SkillDetailResponse.__name__])
@console_account_admission(
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.SKILL_VIEW,
rbac_resource_required=False,
)
@with_session(write=False)
def get(self, session: Session, request_context: RequestContext, skill_id: str):
try:
result = SkillManagementService(session=session).get_skill(
tenant_id=_workspace_id(request_context), skill_id=skill_id
)
return dump_response(SkillDetailResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillMetadataPayload.__name__])
@console_ns.response(200, "Skill updated", console_ns.models[SkillResponse.__name__])
@console_account_admission(
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.SKILL_EDIT,
rbac_resource_required=False,
)
@edit_permission_required
@with_session
def patch(self, session: Session, request_context: RequestContext, skill_id: str):
try:
payload = SkillMetadataPayload.model_validate(console_ns.payload or {})
result = SkillManagementService(session=session).update_metadata(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillDeletePayload.__name__])
@console_ns.response(200, "Skill deleted", console_ns.models[SkillDeleteResponse.__name__])
@console_account_admission(
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.SKILL_DELETE,
rbac_resource_required=False,
)
@edit_permission_required
@with_session
def delete(self, session: Session, request_context: RequestContext, skill_id: str):
try:
payload = SkillDeletePayload.model_validate(console_ns.payload or {})
result = SkillManagementService(session=session).delete_skill(
tenant_id=_workspace_id(request_context),
skill_id=skill_id,
confirmation_name=payload.confirmation_name,
)
return dump_response(SkillDeleteResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/duplicate")
class WorkspaceSkillDuplicateApi(Resource):
@console_ns.response(201, "Skill duplicated", console_ns.models[SkillDetailResponse.__name__])
@console_account_admission(
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.SKILL_EDIT,
rbac_resource_required=False,
)
@edit_permission_required
@with_session
def post(self, session: Session, request_context: RequestContext, skill_id: str):
try:
result = SkillManagementService(session=session).duplicate_skill(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
skill_id=skill_id,
)
return dump_response(SkillDetailResponse, result), 201
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/export")
class WorkspaceSkillExportApi(Resource):
@console_ns.response(200, "Published Skill zip archive")
@console_account_admission(
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.SKILL_VIEW,
rbac_resource_required=False,
)
@with_session(write=False)
def get(self, session: Session, request_context: RequestContext, skill_id: str):
try:
result = SkillManagementService(session=session).pull_published_archive(
tenant_id=_workspace_id(request_context), skill_id=skill_id
)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=True,
download_name=result.filename,
)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/assist/messages")
class WorkspaceSkillAssistMessageApi(Resource):
"""Stream read-only Skill Authoring suggestions from the default workspace model."""
@console_ns.expect(console_ns.models[SkillAssistMessagePayload.__name__])
@console_ns.response(200, "Skill Authoring assistant event stream")
@console_account_admission()
@with_session
def post(self, session: Session, request_context: RequestContext, skill_id: str):
try:
payload = SkillAssistMessagePayload.model_validate(console_ns.payload or {})
response = SkillManagementService(session=session).create_assistant_action_stream(
tenant_id=_workspace_id(request_context),
skill_id=skill_id,
user_id=request_context.account_id,
message=payload.message,
attachments=payload.attachments,
history=payload.history,
model_payload=payload.model,
target_path=payload.target_path,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
return helper.compact_generate_response(response)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/check")
class WorkspaceSkillFilesCheckApi(Resource):
@console_ns.expect(console_ns.models[SkillDraftFileCheckPayload.__name__])
@console_ns.response(200, "Draft files checked", console_ns.models[SkillFileCheckResponse.__name__])
@console_account_admission()
@edit_permission_required
@with_session
def post(self, session: Session, request_context: RequestContext, skill_id: str):
try:
payload = SkillDraftFileCheckPayload.model_validate(console_ns.payload or {})
result = SkillManagementService(session=session).check_draft_files(
tenant_id=_workspace_id(request_context),
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillFileCheckResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files")
class WorkspaceSkillFilesApi(Resource):
@console_ns.expect(console_ns.models[SkillDraftFileOperationPayload.__name__])
@console_ns.response(200, "Draft file operation applied", console_ns.models[SkillDetailResponse.__name__])
@console_account_admission()
@edit_permission_required
@with_session
def patch(self, session: Session, request_context: RequestContext, skill_id: str):
try:
payload = SkillDraftFileOperationPayload.model_validate(console_ns.payload or {})
result = SkillManagementService(session=session).apply_draft_file_operation(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillDraftTreePayload.__name__])
@console_ns.response(200, "Draft files replaced", console_ns.models[SkillDetailResponse.__name__])
@console_account_admission()
@edit_permission_required
@with_session
def put(self, session: Session, request_context: RequestContext, skill_id: str):
try:
payload = SkillDraftTreePayload.model_validate(console_ns.payload or {})
result = SkillManagementService(session=session).replace_draft_tree(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/preview")
class WorkspaceSkillFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(SkillFileQuery))
@console_ns.response(200, "Skill file text preview", console_ns.models[SkillFilePreviewResponse.__name__])
@console_account_admission()
@with_session
def get(self, session: Session, request_context: RequestContext, skill_id: str):
try:
query = SkillFileQuery.model_validate(
{
"path": request.args.get("path"),
"version_id": request.args.get("version_id"),
}
)
result = SkillManagementService(session=session).preview_file(
tenant_id=_workspace_id(request_context),
skill_id=skill_id,
path=query.path,
version_id=query.version_id,
)
return dump_response(SkillFilePreviewResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/content")
class WorkspaceSkillFileContentApi(Resource):
@console_ns.doc(params={**query_params_from_model(SkillFileQuery), "download": "Return as an attachment when 1."})
@console_ns.response(200, "Skill file content", console_ns.models[BinaryFileResponse.__name__])
@console_account_admission()
@with_session
def get(self, session: Session, request_context: RequestContext, skill_id: str):
try:
query = SkillFileQuery.model_validate(
{
"path": request.args.get("path"),
"version_id": request.args.get("version_id"),
}
)
result = SkillManagementService(session=session).pull_file(
tenant_id=_workspace_id(request_context),
skill_id=skill_id,
path=query.path,
version_id=query.version_id,
)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=request.args.get("download") == "1",
download_name=result.filename,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/publish")
class WorkspaceSkillPublishApi(Resource):
@console_ns.expect(console_ns.models[SkillPublishPayload.__name__])
@console_ns.response(200, "Skill published", console_ns.models[SkillVersionResponse.__name__])
@console_account_admission(
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.SKILL_PUBLISH,
rbac_resource_required=False,
)
@edit_permission_required
@with_session
def post(self, session: Session, request_context: RequestContext, skill_id: str):
try:
payload = SkillPublishPayload.model_validate(console_ns.payload or {})
result = SkillManagementService(session=session).publish_skill(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/restore")
class WorkspaceSkillRestoreApi(Resource):
@console_ns.expect(console_ns.models[SkillRestorePayload.__name__])
@console_ns.response(200, "Skill version restored to draft", console_ns.models[SkillDetailResponse.__name__])
@console_account_admission(
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.SKILL_PUBLISH,
rbac_resource_required=False,
)
@edit_permission_required
@with_session
def post(self, session: Session, request_context: RequestContext, skill_id: str):
try:
payload = SkillRestorePayload.model_validate(console_ns.payload or {})
result = SkillManagementService(session=session).restore_version(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/references")
class WorkspaceSkillReferencesApi(Resource):
@console_ns.response(200, "Skill references", console_ns.models[SkillReferenceListResponse.__name__])
@console_account_admission()
@with_session
def get(self, session: Session, request_context: RequestContext, skill_id: str):
try:
result = SkillManagementService(session=session).list_skill_references(
tenant_id=_workspace_id(request_context), skill_id=skill_id
)
return dump_response(SkillReferenceListResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions")
class WorkspaceSkillVersionsApi(Resource):
@console_ns.response(200, "Skill versions", console_ns.models[SkillVersionListResponse.__name__])
@console_account_admission()
@with_session
def get(self, session: Session, request_context: RequestContext, skill_id: str):
try:
result = SkillManagementService(session=session).list_versions(
tenant_id=_workspace_id(request_context), skill_id=skill_id
)
return dump_response(SkillVersionListResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions/<string:version_id>")
class WorkspaceSkillVersionApi(Resource):
@console_ns.response(200, "Skill version detail", console_ns.models[SkillVersionDetailResponse.__name__])
@console_account_admission()
@with_session
def get(self, session: Session, request_context: RequestContext, skill_id: str, version_id: str):
try:
result = SkillManagementService(session=session).get_version(
tenant_id=_workspace_id(request_context),
skill_id=skill_id,
version_id=version_id,
)
return dump_response(SkillVersionDetailResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillVersionUpdatePayload.__name__])
@console_ns.response(200, "Skill version updated", console_ns.models[SkillVersionResponse.__name__])
@console_account_admission()
@edit_permission_required
@with_session
def patch(self, session: Session, request_context: RequestContext, skill_id: str, version_id: str):
try:
payload = SkillVersionUpdatePayload.model_validate(console_ns.payload or {})
result = SkillManagementService(session=session).update_version(
tenant_id=_workspace_id(request_context),
skill_id=skill_id,
version_id=version_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.response(200, "Skill version deleted", console_ns.models[SkillVersionDeleteResponse.__name__])
@console_account_admission()
@edit_permission_required
@with_session
def delete(self, session: Session, request_context: RequestContext, skill_id: str, version_id: str):
try:
result = SkillManagementService(session=session).delete_version(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
skill_id=skill_id,
version_id=version_id,
)
return dump_response(SkillVersionDeleteResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/agents/<string:agent_id>/skills")
class WorkspaceAgentSkillBindingsApi(Resource):
@console_ns.response(200, "Agent Skill bindings", console_ns.models[AgentSkillBindingsResponse.__name__])
@console_account_admission()
@with_session
def get(self, session: Session, request_context: RequestContext, agent_id: str):
result = SkillManagementService(session=session).list_agent_bindings(
tenant_id=_workspace_id(request_context), agent_id=agent_id
)
return dump_response(AgentSkillBindingsResponse, result)
@console_ns.expect(console_ns.models[AgentSkillBindingsPayload.__name__])
@console_ns.response(200, "Agent Skill bindings replaced", console_ns.models[AgentSkillBindingsResponse.__name__])
@console_account_admission()
@edit_permission_required
@with_session
def put(self, session: Session, request_context: RequestContext, agent_id: str):
try:
payload = AgentSkillBindingsPayload.model_validate(console_ns.payload or {})
result = SkillManagementService(session=session).replace_agent_bindings(
tenant_id=_workspace_id(request_context),
user_id=request_context.account_id,
agent_id=agent_id,
skill_ids=payload.skill_ids,
)
return dump_response(AgentSkillBindingsResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
__all__ = [
"WorkspaceAgentSkillBindingsApi",
"WorkspaceSkillApi",
"WorkspaceSkillDuplicateApi",
"WorkspaceSkillExportApi",
"WorkspaceSkillFilesApi",
"WorkspaceSkillImportApi",
"WorkspaceSkillPublishApi",
"WorkspaceSkillReferencesApi",
"WorkspaceSkillRestoreApi",
"WorkspaceSkillTagsApi",
"WorkspaceSkillVersionApi",
"WorkspaceSkillVersionsApi",
"WorkspaceSkillsApi",
]

View File

@@ -17,7 +17,7 @@ from controllers.console import console_ns
from controllers.console.app.wraps import with_session
from controllers.console.snippets.payloads import (
CreateSnippetPayload,
IncludeSecretQuery,
SnippetExportQuery,
SnippetImportPayload,
SnippetListQuery,
UpdateSnippetPayload,
@@ -89,7 +89,7 @@ register_schema_models(
CreateSnippetPayload,
UpdateSnippetPayload,
SnippetImportPayload,
IncludeSecretQuery,
SnippetExportQuery,
)
register_response_schema_models(
console_ns,
@@ -289,7 +289,7 @@ class CustomizedSnippetExportApi(Resource):
@console_ns.doc("export_customized_snippet")
@console_ns.doc(description="Export snippet configuration as DSL")
@console_ns.doc(params={"snippet_id": "Snippet ID to export"})
@console_ns.doc(params=query_params_from_model(IncludeSecretQuery))
@console_ns.doc(params=query_params_from_model(SnippetExportQuery))
@console_ns.response(200, "Snippet exported successfully", console_ns.models[TextFileResponse.__name__])
@console_ns.response(404, "Snippet not found")
@setup_required
@@ -312,11 +312,18 @@ class CustomizedSnippetExportApi(Resource):
raise NotFound("Snippet not found")
# Get include_secret parameter
query = IncludeSecretQuery.model_validate(request.args.to_dict())
query = SnippetExportQuery.model_validate(request.args.to_dict())
with Session(db.engine) as session:
export_service = SnippetDslService(session)
result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true")
try:
result = export_service.export_snippet_dsl(
snippet=snippet,
include_secret=query.include_secret == "true",
workflow_id=query.workflow_id,
)
except ValueError as exc:
raise NotFound(str(exc)) from exc
# Set filename with .snippet extension
filename = f"{snippet.name}.snippet"

View File

@@ -1296,6 +1296,8 @@ class ToolOAuthCustomClient(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, provider: str):

View File

@@ -54,6 +54,10 @@ class TriggerSubscriptionBuilderCreatePayload(BaseModel):
class TriggerSubscriptionBuilderVerifyPayload(BaseModel):
credentials: dict[str, Any] | None = None
class TriggerSubscriptionVerifyPayload(BaseModel):
credentials: dict[str, Any]
@@ -120,6 +124,7 @@ register_schema_models(
TriggerSubscriptionBuilderCreatePayload,
TriggerSubscriptionBuilderVerifyPayload,
TriggerSubscriptionBuilderUpdatePayload,
TriggerSubscriptionVerifyPayload,
TriggerOAuthClientPayload,
)
register_response_schema_models(
@@ -812,7 +817,7 @@ class TriggerOAuthClientManageApi(Resource):
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/verify/<path:subscription_id>",
)
class TriggerSubscriptionVerifyApi(Resource):
@console_ns.expect(console_ns.models[TriggerSubscriptionBuilderVerifyPayload.__name__])
@console_ns.expect(console_ns.models[TriggerSubscriptionVerifyPayload.__name__])
@console_ns.response(
200,
"Trigger subscription verified successfully",
@@ -825,10 +830,10 @@ class TriggerSubscriptionVerifyApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
@model_validate(TriggerSubscriptionBuilderVerifyPayload)
@model_validate(TriggerSubscriptionVerifyPayload)
def post(
self,
req_data: TriggerSubscriptionBuilderVerifyPayload,
req_data: TriggerSubscriptionVerifyPayload,
tenant_id: str,
user: Account,
provider: str,

View File

@@ -14,12 +14,11 @@ api = ExternalApi(
files_ns = Namespace("files", description="File operations", path="/")
from . import agent_drive_archive, image_preview, tool_files, upload
from . import image_preview, tool_files, upload
api.add_namespace(files_ns)
__all__ = [
"agent_drive_archive",
"api",
"bp",
"files_ns",

View File

@@ -1,69 +0,0 @@
from urllib.parse import quote
from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from controllers.common.file_response import enforce_download_for_html
from controllers.common.schema import register_schema_models
from controllers.files import files_ns
from extensions.ext_database import db
from models.agent import AgentDriveFileKind
from services.agent_drive_service import AgentDriveError, AgentDriveService
class AgentDriveArchiveMemberQuery(BaseModel):
tenant_id: str = Field(..., description="Tenant ID")
agent_id: str = Field(..., description="Agent ID")
key: str = Field(..., description="Virtual drive key")
archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind")
archive_file_id: str = Field(..., description="Archive file id")
member_path: str = Field(..., description="Zip member path")
timestamp: str = Field(..., description="Unix timestamp")
nonce: str = Field(..., description="Random nonce")
sign: str = Field(..., description="HMAC signature")
as_attachment: bool = Field(default=False, description="Download as attachment")
register_schema_models(files_ns, AgentDriveArchiveMemberQuery)
@files_ns.route("/agent-drive/archive-member")
class AgentDriveArchiveMemberApi(Resource):
@files_ns.doc("get_agent_drive_archive_member")
@files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters")
def get(self):
args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True))
if not AgentDriveService.verify_archive_member_signature(
tenant_id=args.tenant_id,
agent_id=args.agent_id,
key=args.key,
archive_file_kind=args.archive_file_kind,
archive_file_id=args.archive_file_id,
member_path=args.member_path,
timestamp=args.timestamp,
nonce=args.nonce,
sign=args.sign,
):
raise Forbidden("Invalid request.")
try:
payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request(
tenant_id=args.tenant_id,
agent_id=args.agent_id,
key=args.key,
archive_file_kind=args.archive_file_kind,
archive_file_id=args.archive_file_id,
member_path=args.member_path,
session=db.session(),
)
except AgentDriveError as exc:
raise NotFound(exc.message) from exc
response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
response.headers["Content-Length"] = str(len(payload))
if args.as_attachment and filename:
encoded_filename = quote(filename)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
return response

View File

@@ -8,7 +8,7 @@ from werkzeug.exceptions import Forbidden
import services
from core.db.session_factory import session_factory
from core.tools.signature import verify_plugin_file_signature
from core.tools.signature import sign_tool_file, verify_plugin_file_signature
from core.tools.tool_file_manager import ToolFileManager, resolve_extension
from core.workflow.file_reference import build_file_reference
from fields.file_fields import FileResponse
@@ -136,7 +136,7 @@ class PluginUploadFileApi(Resource):
)
extension = resolve_extension(filename=tool_file.name, mimetype=tool_file.mimetype)
preview_url = ToolFileManager.sign_file(tool_file_id=tool_file.id, extension=extension)
preview_url = sign_tool_file(tool_file_id=tool_file.id, extension=extension, for_external=True)
# Create a dictionary with all the necessary attributes
result = FileResponse(

View File

@@ -23,15 +23,14 @@ from .agent import tools as _agent_tools
from .app import dsl as _app_dsl
from .knowledge import retrieval as _knowledge_retrieval
from .plugin import agent_config as _agent_config
from .plugin import agent_drive as _agent_drive
from .plugin import plugin as _plugin
from .plugin import skills as _skills
from .workspace import workspace as _workspace
api.add_namespace(inner_api_ns)
__all__ = [
"_agent_config",
"_agent_drive",
"_agent_files",
"_agent_llm",
"_agent_tools",
@@ -40,6 +39,7 @@ __all__ = [
"_mail",
"_plugin",
"_runtime_credentials",
"_skills",
"_workspace",
"api",
"bp",

View File

@@ -1,105 +0,0 @@
"""Inner API for the agent drive (agent 网盘) control plane.
These endpoints are called by the dify-agent server (not the sandbox) with the
inner API key. The drive ref is the URL segment ``agent-<agent_id>``; the
path-like file key travels in the query/body, never as a URL path segment (so
its ``/`` characters do not collide with routing). Drive-owned semantics:
tenant scoped, no user-level FileAccessScope. Commit still canonicalizes the
trusted execution-context user through the same EndUser lookup as plugin file
upload before validating ToolFile ownership.
"""
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, ValidationError
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.plugin.wraps import get_user
from controllers.inner_api.wraps import plugin_inner_api_only
from extensions.ext_database import db
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
DriveCommitItem,
parse_agent_drive_ref,
)
class _CommitRequest(BaseModel):
tenant_id: str
user_id: str
items: list[DriveCommitItem]
def _error_response(exc: AgentDriveError) -> tuple[dict[str, str], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
@inner_api_ns.route("/drive/<string:drive_ref>/manifest")
class AgentDriveManifestApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_drive_manifest")
@inner_api_ns.doc(description="List an agent drive (optionally with download URLs)")
def get(self, drive_ref: str):
try:
agent_id = parse_agent_drive_ref(drive_ref)
tenant_id = (request.args.get("tenant_id") or "").strip()
if not tenant_id:
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
include_download_url = (request.args.get("include_download_url") or "").lower() in ("1", "true", "yes")
items = AgentDriveService().manifest(
tenant_id=tenant_id,
agent_id=agent_id,
prefix=request.args.get("prefix", ""),
include_download_url=include_download_url,
session=db.session(),
)
except AgentDriveError as exc:
return _error_response(exc)
return {"items": items}
@inner_api_ns.route("/drive/<string:drive_ref>/skills")
class AgentDriveSkillsApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_drive_skills")
@inner_api_ns.doc(description="List the skill catalog of an agent drive")
def get(self, drive_ref: str):
try:
agent_id = parse_agent_drive_ref(drive_ref)
tenant_id = (request.args.get("tenant_id") or "").strip()
if not tenant_id:
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id, session=db.session())
except AgentDriveError as exc:
return _error_response(exc)
return {"items": items}
@inner_api_ns.route("/drive/<string:drive_ref>/commit")
class AgentDriveCommitApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_drive_commit")
@inner_api_ns.doc(description="Commit a batch of file refs into an agent drive")
def post(self, drive_ref: str):
try:
agent_id = parse_agent_drive_ref(drive_ref)
try:
body = _CommitRequest.model_validate(request.get_json(silent=True) or {})
except ValidationError as exc:
raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc
user = get_user(body.tenant_id, body.user_id)
items = AgentDriveService().commit(
tenant_id=body.tenant_id,
user_id=user.id,
agent_id=agent_id,
items=body.items,
session=db.session(),
)
except AgentDriveError as exc:
return _error_response(exc)
return {"items": items}

View File

@@ -0,0 +1,59 @@
"""Inner API for published workspace Skills.
These endpoints are called by trusted runtime services. They expose only
published Skill artifacts, never draft files or editable metadata.
"""
from __future__ import annotations
import io
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ValidationError
from sqlalchemy.orm import Session
from controllers.common.session import with_session
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import plugin_inner_api_only
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
class _SkillTargetQuery(BaseModel):
tenant_id: str
def _target_query_from_request() -> _SkillTargetQuery:
return _SkillTargetQuery.model_validate({"tenant_id": request.args.get("tenant_id")})
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, str], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
@inner_api_ns.route("/skills/<string:skill_id>/pull")
class PublishedSkillPullApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("published_skill_pull")
@with_session(write=False)
def get(self, session: Session, skill_id: str):
try:
query = _target_query_from_request()
result = SkillManagementService(session=session).pull_published_archive(
tenant_id=query.tenant_id, skill_id=skill_id
)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=True,
download_name=result.filename,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
__all__ = ["PublishedSkillPullApi"]

View File

@@ -4,6 +4,7 @@ from typing import cast
from flask_restx import Resource
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from controllers.common.wraps import RBACPermission, RBACResourceScope
from controllers.openapi import openapi_ns
@@ -17,6 +18,7 @@ from models import Account, App
from models.account import TenantAccountRole
from services.app_dsl_service import AppDslService, Import
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.errors.account import NoPermissionError
from services.errors.app import WorkflowNotFoundError
@@ -53,18 +55,21 @@ class AppDslImportApi(Resource):
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.import_app(
account=account,
import_mode=body.mode,
yaml_content=body.yaml_content,
yaml_url=body.yaml_url,
name=body.name,
description=body.description,
icon_type=body.icon_type,
icon=body.icon,
icon_background=body.icon_background,
app_id=body.app_id,
)
try:
result = service.import_app(
account=account,
import_mode=body.mode,
yaml_content=body.yaml_content,
yaml_url=body.yaml_url,
name=body.name,
description=body.description,
icon_type=body.icon_type,
icon=body.icon,
icon_background=body.icon_background,
app_id=body.app_id,
)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
if result.status == ImportStatus.FAILED:
session.rollback()
else:
@@ -108,7 +113,10 @@ class AppDslImportConfirmApi(Resource):
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.confirm_import(import_id=import_id, account=account)
try:
result = service.confirm_import(import_id=import_id, account=account)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
if result.status == ImportStatus.FAILED:
session.rollback()
else:

View File

@@ -151,8 +151,9 @@ class TextApi(Resource):
tags=["TTS"],
responses={
200: (
"Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; "
"otherwise the provider output is returned directly."
"Returns the generated audio. The `Content-Type` header reflects the provider audio container, "
"verified from the response bytes when recognizable. The binary response can be AAC, FLAC, MP4, "
"MP3, Ogg, WAV, or WebM."
),
400: (
"- `app_unavailable` : App unavailable or misconfigured.\n"
@@ -165,7 +166,8 @@ class TextApi(Resource):
},
)
@expect_with_user(service_api_ns, TextToAudioPayload)
@binary_response(service_api_ns, "audio/mpeg")
# OpenAPI 2 cannot express a runtime-selected response MIME type, so document the body as generic binary.
@binary_response(service_api_ns, "application/octet-stream")
@service_api_ns.doc("text_to_audio")
@service_api_ns.doc(description="Convert text to audio using text-to-speech")
@service_api_ns.doc(

View File

@@ -1,14 +1,14 @@
from flask_restx import Resource
from sqlalchemy import select
from werkzeug.exceptions import Forbidden
from controllers.common.fields import Site as SiteResponse
from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.wraps import validate_app_token
from extensions.ext_database import db
from models.account import TenantStatus
from models.model import App, Site
from extensions.ext_application_services import application_services
from libs.helper import dump_response
from models.model import App
from services.app_definition_query_service import AppDefinitionUnavailableError
register_response_schema_models(service_api_ns, SiteResponse)
@@ -49,13 +49,9 @@ class AppSiteApi(Resource):
Returns the site configuration for the application including theme, icons, and text.
"""
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
try:
configuration = application_services().app_definitions.get_site_configuration(app_model.id)
except AppDefinitionUnavailableError:
raise Forbidden() from None
if not site:
raise Forbidden()
assert app_model.tenant
if app_model.tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()
return SiteResponse.model_validate(site).model_dump(mode="json")
return dump_response(SiteResponse, configuration)

View File

@@ -7,24 +7,29 @@ from pydantic import BaseModel, ConfigDict, Field
from werkzeug.exceptions import Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.common import fields
from controllers.common.fields import Parameters
from controllers.common.errors import InvalidArgumentError
from controllers.common.fields import AccessModeResponse, BooleanResultResponse, Parameters
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import (
AgentNotPublishedError,
AppUnavailableError,
WebAppAccessServiceUnavailableError,
WebAppAuthRequiredError,
WebAppNotFoundError,
)
from controllers.web.wraps import WebApiResource
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.helper import dump_response
from libs.passport import PassportService
from libs.token import extract_webapp_passport
from models.model import App, EndUser
from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.webapp_auth_service import WebAppAuthService
from . import web_ns
from .error import AgentNotPublishedError, AppUnavailableError
from .wraps import WebApiResource
from services.webapp_access_query_service import (
WebAppAccessAppNotFoundError,
WebAppAccessReferenceRequiredError,
WebAppAccessUnavailableError,
)
logger = logging.getLogger(__name__)
@@ -54,8 +59,8 @@ register_response_schema_models(
web_ns,
Parameters,
AppMetaResponse,
fields.AccessModeResponse,
fields.BooleanResultResponse,
AccessModeResponse,
BooleanResultResponse,
)
@@ -122,28 +127,27 @@ class AppAccessMode(Resource):
responses={
200: "Success",
400: "Bad Request",
404: "App Not Found",
500: "Internal Server Error",
503: "Web App Access Service Unavailable",
}
)
@web_ns.response(200, "Success", web_ns.models[fields.AccessModeResponse.__name__])
@web_ns.response(200, "Success", web_ns.models[AccessModeResponse.__name__])
def get(self):
raw_args = request.args.to_dict()
args = AppAccessModeQuery.model_validate(raw_args)
features = FeatureService.get_system_features()
if not features.webapp_auth.enabled:
return {"accessMode": "public"}
app_id = args.app_id
if args.app_code:
app_id = AppService.get_app_id_by_code(args.app_code, session=db.session())
if not app_id:
raise ValueError("appId or appCode must be provided")
res = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
return {"accessMode": res.access_mode}
try:
access_mode = application_services().webapp_access.get_access_mode(
app_id=args.app_id,
app_code=args.app_code,
)
except WebAppAccessReferenceRequiredError as e:
raise InvalidArgumentError(description=str(e)) from None
except WebAppAccessAppNotFoundError:
raise WebAppNotFoundError() from None
except WebAppAccessUnavailableError:
raise WebAppAccessServiceUnavailableError() from None
return dump_response(AccessModeResponse, {"access_mode": access_mode})
@web_ns.route("/webapp/permission")
@@ -157,21 +161,23 @@ class AppWebAuthPermission(Resource):
400: "Bad Request",
401: "Unauthorized",
500: "Internal Server Error",
503: "Web App Access Service Unavailable",
}
)
@web_ns.response(200, "Success", web_ns.models[fields.BooleanResultResponse.__name__])
@web_ns.response(200, "Success", web_ns.models[BooleanResultResponse.__name__])
def get(self):
user_id = "visitor"
app_code = request.headers.get(HEADER_NAME_APP_CODE)
app_id = request.args.get("appId")
if not app_id or not app_code:
raise ValueError("appId must be provided")
require_permission_check = WebAppAuthService.is_app_require_permission_check(
app_id=app_id, session=db.session()
)
if not require_permission_check:
return {"result": True}
webapp_access = application_services().webapp_access
try:
requires_permission_check = webapp_access.requires_permission_check(app_id)
except WebAppAccessUnavailableError:
raise WebAppAccessServiceUnavailableError() from None
if not requires_permission_check:
return dump_response(BooleanResultResponse, {"result": True})
try:
tk = extract_webapp_passport(app_code, request)
@@ -180,16 +186,13 @@ class AppWebAuthPermission(Resource):
decoded = PassportService().verify(tk)
user_id = decoded.get("user_id", "visitor")
except Unauthorized:
raise
raise WebAppAuthRequiredError() from None
except Exception:
logger.exception("Unexpected error during auth verification")
raise
features = FeatureService.get_system_features()
if not features.webapp_auth.enabled:
return {"result": True}
res = True
if WebAppAuthService.is_app_require_permission_check(app_id=app_id, session=db.session()):
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_id)
return {"result": res}
try:
is_allowed = webapp_access.is_user_allowed(user_id=str(user_id), app_id=app_id)
except WebAppAccessUnavailableError:
raise WebAppAccessServiceUnavailableError() from None
return dump_response(BooleanResultResponse, {"result": is_allowed})

View File

@@ -121,6 +121,18 @@ class WebAppAuthAccessDeniedError(BaseHTTPException):
code = 401
class WebAppNotFoundError(BaseHTTPException):
error_code = "app_not_found"
description = "App not found."
code = 404
class WebAppAccessServiceUnavailableError(BaseHTTPException):
error_code = "web_app_access_unavailable"
description = "Web app access service is unavailable."
code = 503
class InvokeRateLimitError(BaseHTTPException):
"""Raised when the Invoke returns rate limit error."""

View File

@@ -1,23 +1,19 @@
from typing import Any, Self
from pydantic import AliasChoices, Field
from sqlalchemy import select
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.schema import register_response_schema_models
from controllers.web import web_ns
from controllers.web.wraps import WebApiResource
from enums import DeploymentEdition
from extensions.ext_database import db
from extensions.storage.storage_type import StorageType
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import build_icon_url
from models.account import Tenant, TenantStatus
from models.model import App, AppMode, EndUser, IconType, Site
from libs.helper import build_icon_url, dump_response
from models.account import Tenant
from models.model import App, AppMode, EndUser, Site
from services.entities.feature_entities import FeatureModel
from services.feature_service import FeatureService
from services.file_service import FileService
from services.web_app_runtime_query_service import WebAppRuntimeUnavailableError
class WebSiteResponse(ResponseModel):
@@ -128,17 +124,6 @@ register_response_schema_models(
)
def _build_site_icon_url(*, site: Site, tenant_id: str) -> str | None:
"""Use direct S3 URLs only in Cloud Mode and preserve preview URLs elsewhere."""
if site.icon_type != IconType.IMAGE or not site.icon:
return None
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
StorageType(dify_config.STORAGE_TYPE) == StorageType.S3
):
return FileService(db.engine).get_file_presigned_url(file_id=site.icon, tenant_id=tenant_id)
return build_icon_url(site.icon_type, site.icon)
@web_ns.route("/site")
class AppSiteApi(WebApiResource):
@web_ns.doc("Get App Site Info")
@@ -156,25 +141,21 @@ class AppSiteApi(WebApiResource):
@web_ns.response(200, "Success", web_ns.models[WebAppSiteResponse.__name__])
def get(self, app_model: App, end_user: EndUser):
"""Retrieve app site info."""
# get site
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
try:
bootstrap = application_services().web_app_runtime.get_bootstrap(app_model.id)
except WebAppRuntimeUnavailableError:
raise Forbidden() from None
if site is None:
raise Forbidden()
tenant = app_model.tenant
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
return WebAppSiteResponse.from_app_site(
tenant=tenant,
app_model=app_model,
mode=AppMode.value_of(app_model.mode_compatible_with_agent_with_session(session=db.session())),
site=site,
end_user_id=end_user.id,
features=features,
can_replace_logo=features.can_replace_logo,
icon_url=_build_site_icon_url(site=site, tenant_id=tenant.id),
).model_dump(mode="json")
return dump_response(
WebAppSiteResponse,
{
"app_id": bootstrap.app_id,
"mode": bootstrap.mode,
"end_user_id": end_user.id,
"enable_site": bootstrap.enable_site,
"site": bootstrap.site,
"plan": bootstrap.plan,
"can_replace_logo": bootstrap.can_replace_logo,
"custom_config": bootstrap.custom_config,
},
)

View File

@@ -6,11 +6,11 @@ from typing import Concatenate
from flask import request
from flask_restx import Resource
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequiredError
from core.db.session_factory import session_factory
from core.logging.context import set_identity_context
from extensions.ext_database import db
from libs.passport import PassportService
@@ -54,7 +54,7 @@ def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) ->
decoded = PassportService().verify(tk)
app_code = decoded.get("app_code")
app_id = decoded.get("app_id")
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
with session_factory.create_session() as session:
app_model = session.scalar(select(App).where(App.id == app_id))
site = session.scalar(select(Site).where(Site.code == app_code))
if not app_model:

View File

@@ -12,6 +12,8 @@ from core.agent.errors import AgentMaxIterationError
from core.agent.output_parser.cot_output_parser import CotAgentOutputParser
from core.app.apps.base_app_queue_manager import PublishFrom
from core.app.entities.queue_entities import QueueAgentThoughtEvent, QueueMessageEndEvent, QueueMessageFileEvent
from core.credit_usage import CreditUsageAppType, CreditUsageCreatedBy
from core.model_context import use_credit_usage_metadata
from core.ops.ops_trace_manager import TraceQueueManager
from core.prompt.agent_history_prompt_transform import AgentHistoryPromptTransform
from core.tools.__base.tool import Tool
@@ -135,6 +137,12 @@ class CotAgentRunner(BaseAgentRunner, ABC):
session.close()
# invoke model
request_metadata: dict[str, object] = {
"app_id": self.app_config.app_id,
"app_type": CreditUsageAppType.AGENT,
"created_by": CreditUsageCreatedBy.APP,
}
chunks = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters=app_generate_entity.model_conf.parameters,
@@ -142,7 +150,7 @@ class CotAgentRunner(BaseAgentRunner, ABC):
stop=app_generate_entity.model_conf.stop,
stream=True,
callbacks=[],
request_metadata={"app_id": self.app_config.app_id},
request_metadata=request_metadata,
)
usage_dict: dict[str, LLMUsage | None] = {}
@@ -331,17 +339,18 @@ class CotAgentRunner(BaseAgentRunner, ABC):
pass
# invoke tool
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke(
session=session,
tool=tool_instance,
tool_parameters=tool_call_args,
user_id=self.user_id,
tenant_id=self.tenant_id,
message=self.message,
invoke_from=self.application_generate_entity.invoke_from,
agent_tool_callback=self.agent_callback,
trace_manager=trace_manager,
)
with use_credit_usage_metadata({"app_type": CreditUsageAppType.AGENT}):
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke(
session=session,
tool=tool_instance,
tool_parameters=tool_call_args,
user_id=self.user_id,
tenant_id=self.tenant_id,
message=self.message,
invoke_from=self.application_generate_entity.invoke_from,
agent_tool_callback=self.agent_callback,
trace_manager=trace_manager,
)
session.commit()
session.close()

View File

@@ -13,6 +13,8 @@ from core.agent.errors import AgentMaxIterationError
from core.app.apps.base_app_queue_manager import PublishFrom
from core.app.entities.queue_entities import QueueAgentThoughtEvent, QueueMessageEndEvent, QueueMessageFileEvent
from core.app.file_access import grant_upload_file_access
from core.credit_usage import CreditUsageAppType, CreditUsageCreatedBy
from core.model_context import use_credit_usage_metadata
from core.prompt.agent_history_prompt_transform import AgentHistoryPromptTransform
from core.tools.entities.tool_entities import ToolInvokeMeta
from core.tools.signature import sign_upload_file_preview_url
@@ -167,6 +169,12 @@ class FunctionCallAgentRunner(BaseAgentRunner):
session.close()
# invoke model
request_metadata: dict[str, object] = {
"app_id": self.app_config.app_id,
"app_type": CreditUsageAppType.AGENT,
"created_by": CreditUsageCreatedBy.APP,
}
chunks: Union[Generator[LLMResultChunk, None, None], LLMResult] = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters=app_generate_entity.model_conf.parameters,
@@ -174,7 +182,7 @@ class FunctionCallAgentRunner(BaseAgentRunner):
stop=app_generate_entity.model_conf.stop,
stream=self.stream_tool_call,
callbacks=[],
request_metadata={"app_id": self.app_config.app_id},
request_metadata=request_metadata,
)
tool_calls: list[tuple[str, str, dict[str, Any]]] = []
@@ -315,20 +323,21 @@ class FunctionCallAgentRunner(BaseAgentRunner):
}
else:
# invoke tool
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke(
session=session,
tool=tool_instance,
tool_parameters=tool_call_args,
user_id=self.user_id,
tenant_id=self.tenant_id,
message=self.message,
invoke_from=self.application_generate_entity.invoke_from,
agent_tool_callback=self.agent_callback,
trace_manager=trace_manager,
app_id=self.application_generate_entity.app_config.app_id,
message_id=self.message.id,
conversation_id=self.conversation.id,
)
with use_credit_usage_metadata({"app_type": CreditUsageAppType.AGENT}):
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke(
session=session,
tool=tool_instance,
tool_parameters=tool_call_args,
user_id=self.user_id,
tenant_id=self.tenant_id,
message=self.message,
invoke_from=self.application_generate_entity.invoke_from,
agent_tool_callback=self.agent_callback,
trace_manager=trace_manager,
app_id=self.application_generate_entity.app_config.app_id,
message_id=self.message.id,
conversation_id=self.conversation.id,
)
session.commit()
session.close()
# publish files

View File

@@ -11,13 +11,16 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.workflow.command_channels import (
CelerySignalCommandChannel,
CombinedCommandChannel,
StopFlagCommandChannel,
)
from core.app.apps.workflow.stop_aware_ready_queue import attach_stop_aware_ready_queue
from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
from core.app.entities.app_invoke_entities import (
AdvancedChatAppGenerateEntity,
AppGenerateEntity,
DifyRunContext,
InvokeFrom,
get_credit_usage_app_type,
)
from core.app.entities.queue_entities import (
QueueAnnotationReplyEvent,
@@ -139,6 +142,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
user_id=self.application_generate_entity.user_id,
invoke_from=invoke_from,
user_from=user_from,
app_type=get_credit_usage_app_type(app_config.app_mode),
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
@@ -148,6 +152,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
single_iteration_run=self.application_generate_entity.single_iteration_run,
single_loop_run=self.application_generate_entity.single_loop_run,
user_id=self.application_generate_entity.user_id,
app_type=get_credit_usage_app_type(app_config.app_mode),
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
else:
@@ -218,6 +223,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
user_from=user_from,
invoke_from=invoke_from,
root_node_id=root_node_id,
app_type=get_credit_usage_app_type(app_config.app_mode),
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
@@ -229,9 +235,11 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
shutdown_state_getter=celery_warm_shutdown_started,
abort_reason=WORKFLOW_WARM_SHUTDOWN_ABORT_REASON,
)
attach_stop_aware_ready_queue(graph_runtime_state, task_id=task_id)
command_channel = CombinedCommandChannel(
(
RedisChannel(redis_client, channel_key),
StopFlagCommandChannel(task_id=task_id),
celery_signal_channel,
)
)
@@ -276,6 +284,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
user_id=self.application_generate_entity.user_id,
user_from=user_from,
invoke_from=invoke_from,
app_type=get_credit_usage_app_type(app_config.app_mode),
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
)

View File

@@ -11,7 +11,6 @@ from typing import Any, Union
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.common.graph_runtime_state_support import GraphRuntimeStateSupport
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
@@ -19,6 +18,7 @@ from core.app.apps.draft_variable_saver import DraftVariableSaverFactory
from core.app.entities.app_invoke_entities import (
AdvancedChatAppGenerateEntity,
InvokeFrom,
get_credit_usage_app_type,
)
from core.app.entities.queue_entities import (
MessageQueueMessage,
@@ -70,9 +70,10 @@ from core.app.entities.task_entities import (
)
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.base.tts import AppGeneratorTTSPublisher
from core.db.session_factory import session_factory
from core.ops.ops_trace_manager import TraceQueueManager
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
from core.repositories.human_input_repository import HumanInputFormRepositoryImpl
from core.workflow.file_reference import resolve_file_record_id
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
@@ -228,7 +229,9 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
:return:
"""
self._conversation_name_generate_thread = self._message_cycle_manager.generate_conversation_name(
conversation_id=self._conversation_id, query=self._application_generate_entity.query
conversation_id=self._conversation_id,
query=self._application_generate_entity.query,
message_id=self._message_id,
)
generator = self._wrapper_process_stream_response(trace_manager=self._application_generate_entity.trace_manager)
@@ -346,9 +349,13 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
if not publisher:
return None
audio_msg = publisher.check_and_get_audio()
if audio_msg and isinstance(audio_msg, AudioTrunk) and audio_msg.status != "finish":
return MessageAudioStreamResponse(audio=audio_msg.audio, task_id=task_id)
return None
if audio_msg is None:
return None
if audio_msg.status == "responding":
return MessageAudioStreamResponse(audio=audio_msg.audio, audio_type=audio_msg.audio_type, task_id=task_id)
if audio_msg.status in {"finish", "error"}:
return None
raise RuntimeError(f"TTS publisher returned an unknown status: {audio_msg.status}")
def _wrapper_process_stream_response(
self, trace_manager: TraceQueueManager | None = None
@@ -359,42 +366,53 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
features_dict = self._workflow_features_dict
if (
features_dict.get("text_to_speech")
self._base_task_pipeline.stream
and features_dict.get("text_to_speech")
and features_dict["text_to_speech"].get("enabled")
and features_dict["text_to_speech"].get("autoPlay") == "enabled"
):
tts_publisher = AppGeneratorTTSPublisher(
tenant_id, features_dict["text_to_speech"].get("voice"), features_dict["text_to_speech"].get("language")
tenant_id,
features_dict["text_to_speech"].get("voice"),
features_dict["text_to_speech"].get("language"),
get_credit_usage_app_type(self._application_generate_entity.app_config.app_mode),
)
for response in self._process_stream_response(tts_publisher=tts_publisher, trace_manager=trace_manager):
while True:
audio_response = self._listen_audio_msg(publisher=tts_publisher, task_id=task_id)
if audio_response:
try:
for response in self._process_stream_response(tts_publisher=tts_publisher, trace_manager=trace_manager):
while audio_response := self._listen_audio_msg(publisher=tts_publisher, task_id=task_id):
yield audio_response
else:
break
yield response
if tts_publisher and isinstance(response, ErrorStreamResponse):
tts_publisher.cancel()
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
yield response
return
yield response
start_listener_time = time.time()
while (time.time() - start_listener_time) < TTS_AUTO_PLAY_TIMEOUT:
try:
if not tts_publisher:
break
audio_trunk = tts_publisher.check_and_get_audio()
if audio_trunk is None:
time.sleep(TTS_AUTO_PLAY_YIELD_CPU_TIME)
if tts_publisher is None:
return
tts_publisher.publish(None)
while True:
audio_trunk = tts_publisher.check_and_get_audio(block=True)
assert audio_trunk is not None
if audio_trunk.status == "responding":
yield MessageAudioStreamResponse(
audio=audio_trunk.audio, audio_type=audio_trunk.audio_type, task_id=task_id
)
continue
if audio_trunk.status == "finish":
break
else:
start_listener_time = time.time()
yield MessageAudioStreamResponse(audio=audio_trunk.audio, task_id=task_id)
except Exception:
logger.exception("Failed to listen audio message, task_id: %s", task_id)
break
if tts_publisher:
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
if audio_trunk.status not in {"finish", "error"}:
raise RuntimeError(f"TTS publisher returned an unknown status: {audio_trunk.status}")
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
if audio_trunk.status == "error":
if audio_trunk.error is None:
raise RuntimeError("TTS publisher returned an error terminal without an exception")
yield ErrorStreamResponse(err=audio_trunk.error, task_id=task_id)
return
finally:
if tts_publisher:
tts_publisher.cancel()
@contextmanager
def _database_session(self):
@@ -779,6 +797,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
with self._database_session() as session:
# Save message
self._save_message(session=session, graph_runtime_state=resolved_state)
self._emit_message_trace()
yield workflow_finish_resp
elif event.stopped_by in (
@@ -789,6 +808,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
with self._database_session() as session:
# Save message
self._save_message(session=session)
self._emit_message_trace()
yield self._message_end_to_stream_response()
@@ -816,6 +836,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
if not self._message_saved_on_pause:
with self._database_session() as session:
self._save_message(session=session, graph_runtime_state=resolved_state)
self._emit_message_trace()
yield self._message_end_to_stream_response()
@@ -1036,9 +1057,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
):
yield from responses
if tts_publisher:
tts_publisher.publish(None)
if self._conversation_name_generate_thread:
logger.debug("Conversation name generation running as daemon thread")
@@ -1101,6 +1119,18 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
)
session.add_all(message_files)
def _emit_message_trace(self) -> None:
trace_manager = self._application_generate_entity.trace_manager
if trace_manager:
trace_manager.add_trace_task(
TraceTask(
TraceTaskName.MESSAGE_TRACE,
conversation_id=self._conversation_id,
message_id=self._message_id,
trace_session_id=self._application_generate_entity.extras.get("trace_session_id"),
)
)
def _seed_graph_runtime_state_from_queue_manager(self) -> None:
"""Bootstrap the cached runtime state from the queue manager when present."""
candidate = self._base_task_pipeline.queue_manager.graph_runtime_state

View File

@@ -45,6 +45,7 @@ from core.app.entities.app_invoke_entities import (
InvokeFrom,
UserFrom,
)
from core.credit_usage import CreditUsageAppType
from core.db.session_factory import session_factory
from core.ops.ops_trace_manager import TraceQueueManager
from core.workflow.file_reference import build_file_reference, is_canonical_file_reference
@@ -498,6 +499,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
user_id=application_generate_entity.user_id,
user_from=user_from,
invoke_from=application_generate_entity.invoke_from,
app_type=CreditUsageAppType.AGENT_V2,
)
with session_factory.create_session() as session:
agent, config_version, agent_soul = self._resolve_agent_by_id(

View File

@@ -44,6 +44,7 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
build_config_layer_config,
build_knowledge_layer_config,
build_shell_layer_config,
load_runtime_agent_skill_configs,
)
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
from models.provider_ids import ModelProviderID
@@ -121,14 +122,22 @@ class AgentAppRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
runtime_config_skills = load_runtime_agent_skill_configs(
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent_id,
)
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent_id,
config_version_id=context.agent_config_snapshot_id,
config_version_kind=context.agent_config_version_kind,
runtime_config_skills=runtime_config_skills,
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
agent_soul,
runtime_config_skills=runtime_config_skills,
)
knowledge_config = build_knowledge_layer_config(agent_soul)
context_window_tokens = resolve_model_context_window(
run_context=context.dify_context,

View File

@@ -9,6 +9,8 @@ from core.app.apps.base_app_runner import AppRunner
from core.app.apps.chat.app_config_manager import ChatAppConfig
from core.app.entities.app_invoke_entities import (
ChatAppGenerateEntity,
get_credit_usage_app_type,
get_credit_usage_created_by,
)
from core.app.entities.queue_entities import QueueAnnotationReplyEvent
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
@@ -231,12 +233,16 @@ class ChatAppRunner(AppRunner):
model=application_generate_entity.model_conf.model,
)
request_metadata: dict[str, object] = {"app_id": app_config.app_id}
request_metadata["app_type"] = get_credit_usage_app_type(app_config.app_mode)
request_metadata["created_by"] = get_credit_usage_created_by(app_config.app_mode)
invoke_result = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters=application_generate_entity.model_conf.parameters,
stop=stop,
stream=application_generate_entity.stream,
request_metadata={"app_id": app_config.app_id},
request_metadata=request_metadata,
)
# handle invoke result

View File

@@ -9,6 +9,8 @@ from core.app.apps.base_app_runner import AppRunner
from core.app.apps.completion.app_config_manager import CompletionAppConfig
from core.app.entities.app_invoke_entities import (
CompletionAppGenerateEntity,
get_credit_usage_app_type,
get_credit_usage_created_by,
)
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
from core.db.session_factory import create_session
@@ -192,12 +194,16 @@ class CompletionAppRunner(AppRunner):
model=application_generate_entity.model_conf.model,
)
request_metadata: dict[str, object] = {"app_id": app_config.app_id}
request_metadata["app_type"] = get_credit_usage_app_type(app_config.app_mode)
request_metadata["created_by"] = get_credit_usage_created_by(app_config.app_mode)
invoke_result = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters=application_generate_entity.model_conf.parameters,
stop=stop,
stream=application_generate_entity.stream,
request_metadata={"app_id": app_config.app_id},
request_metadata=request_metadata,
)
# handle invoke result

View File

@@ -26,11 +26,24 @@ def app_task_command_channel_key(task_id: str) -> str:
return f"workflow:{task_id}:commands"
def app_task_stop_flag_key(task_id: str) -> str:
"""Redis key of the legacy generate-task stop flag."""
return f"generate_task_stopped:{task_id}"
def set_app_task_stop_flag(task_id: str) -> None:
if not task_id:
return
redis_client.setex(f"generate_task_stopped:{task_id}", 600, 1)
redis_client.setex(app_task_stop_flag_key(task_id), 600, 1)
def is_app_task_stop_flag_set(task_id: str) -> bool:
"""Return whether the legacy Redis stop flag is currently armed."""
if not task_id:
return False
return redis_client.get(app_task_stop_flag_key(task_id)) is not None
def clear_app_task_cancellation_signals(task_id: str) -> None:
@@ -47,7 +60,7 @@ def clear_app_task_cancellation_signals(task_id: str) -> None:
return
try:
redis_client.delete(f"generate_task_stopped:{task_id}")
redis_client.delete(app_task_stop_flag_key(task_id))
except Exception:
logger.exception("Failed to clear stop flag for app task %s", task_id)

View File

@@ -31,7 +31,7 @@ from core.prompt.utils.prompt_template_parser import PromptTemplateParser
from core.workflow.file_reference import resolve_file_record_id
from extensions.ext_database import db
from extensions.ext_redis import get_pubsub_broadcast_channel
from libs.broadcast_channel.channel import Topic
from libs.broadcast_channel.channel import SupportsPreparedSubscription, Topic
from libs.datetime_utils import naive_utc_now
from models import Account
from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo
@@ -323,8 +323,14 @@ class MessageBasedAppGenerator(BaseAppGenerator):
on_subscribe: Callable[[], None] | None = None,
) -> Generator[Mapping | str, None, None]:
topic = cls.get_response_topic(app_mode, workflow_run_id)
subscriber = topic.as_subscriber()
subscription = (
subscriber.prepare_subscription()
if isinstance(subscriber, SupportsPreparedSubscription)
else subscriber.subscribe()
)
return stream_topic_events(
topic=topic,
subscription=subscription,
idle_timeout=idle_timeout,
on_subscribe=on_subscribe,
)

View File

@@ -3,7 +3,7 @@ from collections.abc import Callable, Generator, Iterable, Mapping
from core.app.apps.streaming_utils import stream_topic_events
from core.app.entities.task_entities import StreamEvent
from extensions.ext_redis import get_pubsub_broadcast_channel
from libs.broadcast_channel.channel import Topic
from libs.broadcast_channel.channel import SupportsPreparedSubscription, Topic
from models.model import AppMode
@@ -30,8 +30,14 @@ class MessageGenerator:
terminal_events: Iterable[str | StreamEvent] | None = None,
) -> Generator[Mapping | str, None, None]:
topic = cls.get_response_topic(app_mode, workflow_run_id)
subscriber = topic.as_subscriber()
subscription = (
subscriber.prepare_subscription()
if isinstance(subscriber, SupportsPreparedSubscription)
else subscriber.subscribe()
)
return stream_topic_events(
topic=topic,
subscription=subscription,
idle_timeout=idle_timeout,
ping_interval=ping_interval,
on_subscribe=on_subscribe,

Some files were not shown because too many files have changed in this diff Show More