Compare commits

...

380 Commits

Author SHA1 Message Date
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
yyh
b78a3462c9 chore(web): align browser support targets (#40938) 2026-08-18 14:21:18 +00:00
zyssyz123
c38fb16efa fix(agent): retain usage for incomplete runs (#40937) 2026-08-18 13:11:22 +00:00
Asuka Minato
2400c6c59d test: migrate dataset service sessions and ORM models to SQLite (#40534) 2026-08-18 12:35:57 +00:00
yyh
324ddb361d fix(web): gate system features requests by connection (#40939) 2026-08-18 12:34:34 +00:00
Asuka Minato
4f81005662 test: migrate app runtime sessions and ORM models to SQLite (#40590) 2026-08-18 12:27:32 +00:00
Asuka Minato
f2dfa462e1 test: migrate agent runtime sessions and ORM models to SQLite (#40587) 2026-08-18 12:26:58 +00:00
Asuka Minato
14bc28ffbe test: migrate residual controller sessions and ORM models to SQLite (#40579) 2026-08-18 12:26:33 +00:00
Asuka Minato
5afce305e7 test: migrate residual service and task ORM models to SQLite (#40576) 2026-08-18 12:26:01 +00:00
Asuka Minato
beecf5730a test: migrate telemetry and core entity ORM models to SQLite (#40574) 2026-08-18 12:25:42 +00:00
Asuka Minato
eeda983b17 test: migrate service boundary sessions and ORM models to SQLite (#40533)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-18 12:23:33 +00:00
Asuka Minato
e2e3521874 test: migrate residual model sessions and ORM models to SQLite (#40531) 2026-08-18 12:23:02 +00:00
Asuka Minato
303ce70ac4 test: migrate app model sessions and ORM models to SQLite (#40530) 2026-08-18 12:22:33 +00:00
Asuka Minato
5fde1d096e test: eliminate remaining whole config mocks (#40855) 2026-08-18 12:01:49 +00:00
Asuka Minato
3bcd97b643 test: scope plugin and datasource config overrides (#40854) 2026-08-18 12:01:29 +00:00
Asuka Minato
20fdbcf36f test: centralize enterprise config overrides (#40853) 2026-08-18 12:01:01 +00:00
Asuka Minato
c7f4422333 test: keep integration config overrides typed (#40850) 2026-08-18 12:00:32 +00:00
Asuka Minato
da6ca23b50 test: use typed config overrides in service coverage (#40848) 2026-08-18 11:59:58 +00:00
yyh
b064a13aa4 refactor(web): remove legacy embedded API docs (#40929) 2026-08-18 09:53:55 +00:00
yyh
45e2d81be4 fix(ci): align Oxlint suppression formatting (#40934) 2026-08-18 09:41:48 +00:00
yyh
fe41e80f33 refactor(web): remove legacy tag component (#40928) 2026-08-18 07:58:47 +00:00
yyh
a2ac8ad106 refactor(web): migrate clearable search inputs (#40924) 2026-08-18 07:55:08 +00:00
yyh
bd4cfd5a7a refactor(web): migrate retry units to number field (#40923) 2026-08-18 07:55:07 +00:00
chenshiyang
0e975f6305 refactor(models): dep-inject Session on remaining @property accessors (#40797)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 07:23:09 +00:00
dependabot[bot]
c94846ee2a chore(deps): bump sqlparse from 0.5.4 to 0.6.0 in /api (#40904)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 06:50:31 +00:00
yyh
9ce463fc24 refactor(web): compose search prefixes with input group (#40922) 2026-08-18 06:04:37 +00:00
zyssyz123
5a0a12cb44 fix: preserve Agent tool-generated file outputs (#40917) 2026-08-18 05:59:43 +00:00
Byron.wang
ba61ab4f9f refactor(api): extract account activation application service (#40421)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-18 05:27:40 +00:00
Byron.wang
891627b210 refactor(api): decouple data source API key auth flow (#40452)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-18 04:30:17 +00:00
蓝破碎半圆/破碎蓝调
ba5e7d76fd fix: accept legacy plugin daemon list responses (#38265) 2026-08-18 04:11:49 +00:00
yyh
809a6cff1e refactor(web): standardize label search inputs (#40914) 2026-08-18 04:05:26 +00:00
yyh
32c11fd06e refactor(web): standardize dataset search inputs (#40913) 2026-08-18 04:05:25 +00:00
yyh
04d2d9ff19 refactor(web): standardize app log search inputs (#40912) 2026-08-18 04:05:25 +00:00
yyh
05499b9011 test(dify-ui): stabilize dropdown menu story (#40916) 2026-08-18 04:05:24 +00:00
Harsh Kashyap
ca32e87683 fix(api): bound connect phase to 5.0s in two api/core/ outbound timeouts (#40807)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-18 03:38:59 +00:00
盐粒 Yanli
122368f5e0 chore(dify-agent): disable Landlock for E2B templates (#40892) 2026-08-18 03:30:00 +00:00
盐粒 Yanli
c267e73ca6 fix(dify-agent): use E2B traffic access header (#40891) 2026-08-18 03:29:50 +00:00
盐粒 Yanli
c649b84bf6 fix(dify-agent): isolate cancellation intent observer (#40896) 2026-08-18 03:29:42 +00:00
yyh
09f8f43d20 fix(ci): use default Ubuntu mirror for Playwright (#40909) 2026-08-18 02:55:17 +00:00
mesrop2008
50d6c19ef4 fix(api): clear stale cancellation signals when resuming a paused workflow (#40905) 2026-08-18 02:27:43 +00:00
yyh
6d89557355 feat(dify-ui): add input group composition (#40890) 2026-08-18 02:14:39 +00:00
Amogh Bhat Nagri
30c304c6a7 fix(api): repair two malformed logger format strings (#40901) 2026-08-18 02:10:59 +00:00
yyh
76a91eb433 refactor(dify-ui): expose autocomplete popup anatomy (#40895) 2026-08-18 02:06:03 +00:00
Chengxiao
394b43376a fix: suggest deepseek-v4-flash for Agent mode (#40852) 2026-08-18 01:57:21 +00:00
Asuka Minato
02f0e8cffa test: isolate config inputs from process environment (#40847) 2026-08-17 13:09:17 +00:00
Asuka Minato
3f0a9c5060 test: migrate RAG indexing sessions and ORM models to SQLite (#40513) 2026-08-17 12:13:02 +00:00
Asuka Minato
ce1aed8df5 test: migrate RAG pipeline sessions and ORM models to SQLite (#40511) 2026-08-17 12:12:46 +00:00
Asuka Minato
c3d090fe4e test: migrate Service API dataset metadata sessions and ORM models to SQLite (#40518) 2026-08-17 12:12:06 +00:00
Asuka Minato
d92cf5d198 test: migrate Service API dataset document sessions and ORM models to SQLite (#40519) 2026-08-17 12:11:53 +00:00
Asuka Minato
ccf4d25321 test: migrate console workflow controller sessions and ORM models to SQLite (#40527) 2026-08-17 12:11:34 +00:00
Asuka Minato
4e02075f8d test: migrate console app MCP sessions and ORM models to SQLite (#40526) 2026-08-17 12:11:12 +00:00
Asuka Minato
0f358f5bb3 test: migrate console conversation sessions and ORM models to SQLite (#40528) 2026-08-17 12:10:57 +00:00
Asuka Minato
a575ae5ef2 test: migrate controller auth wrapper sessions and ORM models to SQLite (#40529) 2026-08-17 12:10:44 +00:00
盐粒 Yanli
2aaac4dadb feat(dify-agent): enforce signed upload size limits (#40883) 2026-08-17 11:59:42 +00:00
盐粒 Yanli
bec3890689 fix(dify-agent): enforce short-lived Agent Stub authorization (#40884) 2026-08-17 11:48:12 +00:00
非法操作
e3ab88f6de feat: dispatch New Agent Beta eligibility checks (#40725)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-17 10:33:50 +00:00
盐粒 Yanli
a679ee5644 fix(dify-agent): preserve snapshots for failed and cancelled runs (#40876) 2026-08-17 10:11:22 +00:00
盐粒 Yanli
96809473fc feat(dify-agent): add context-aware history compaction (#40872) 2026-08-17 09:36:21 +00:00
林玮 (Jade Lin)
5ef8becd25 feat: create accounts with client IP (#40875) 2026-08-17 09:29:57 +00:00
盐粒 Yanli
4249a0d052 fix(dify-agent): require E2B traffic authentication (#40871) 2026-08-17 08:35:53 +00:00
zyssyz123
64587eae43 fix(workflow): preserve historical Agent node compatibility (#40859) 2026-08-17 07:31:38 +00:00
yyh
4ce681d42f test(e2e): align agent model selector locators (#40862) 2026-08-17 06:48:00 +00:00
yyh
59c6a470bb fix(web): update agent model request limit copy (#40863) 2026-08-17 06:37:23 +00:00
无能的迪拜巧克力
b02072baee fix(web): let multimodal guidance height fit content (#40846) 2026-08-17 06:10:06 +00:00
Bond Zhu
f6a472d948 feat(ci): sync E2B template on release tag push (#40858) 2026-08-17 06:06:46 +00:00
Weike Zhang
45be4620fe refactor(controllers/web): dep-inject login and forgot-password payloads (#36659) (#40856) 2026-08-17 05:58:40 +00:00
dependabot[bot]
08993dd622 chore(deps): bump the github-actions-dependencies group with 2 updates (#40834)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 02:50:41 +00:00
YungLe
219596c4df feat(plugin-auth): visibility picker for OAuth credentials (#39840)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Wu Tianwei <30284043+WTW0313@users.noreply.github.com>
Co-authored-by: twwu <twwu@dify.ai>
2026-08-17 02:41:26 +00:00
yyh
facb60abfe fix(web): align combobox interaction semantics (#40826) 2026-08-17 02:13:08 +00:00
zy
141cbf9832 fix(api): support audio/x-m4a for ASR (#40776)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-16 02:47:31 +00:00
Asuka Minato
8fb5dd8587 test: migrate agent controller sessions and ORM models to SQLite (#40600)
Co-authored-by: Byron Wang <byron@dify.ai>
2026-08-15 15:15:28 +00:00
Asuka Minato
1651bcc774 test: migrate residual controller sessions and ORM models to SQLite (#40606)
Co-authored-by: Byron Wang <byron@dify.ai>
2026-08-15 15:15:11 +00:00
mesrop2008
c2e68335d4 docs: fix pipeline_generator docstrings to reference pipeline instead of app_model (#40813) 2026-08-15 12:09:16 +00:00
Asuka Minato
177edaa1c6 test: migrate annotation security sessions and ORM models to SQLite (#40604) 2026-08-15 09:54:29 +00:00
Asuka Minato
edb32fba92 test: migrate RAG processor residuals to SQLite ORM models (#40608) 2026-08-15 09:54:09 +00:00
Asuka Minato
959cacdc00 test: migrate agent surface sessions and ORM models to SQLite (#40602) 2026-08-15 09:53:48 +00:00
Asuka Minato
582c73bc3b test: migrate residual user ORM stand-ins to real models (#40625) 2026-08-15 09:52:59 +00:00
Asuka Minato
6eca353804 test: migrate file controller ORM models to real entities (#40632) 2026-08-15 09:52:38 +00:00
Harsh Kashyap
d43685bc79 fix(extension): chain original exception in APIBasedExtensionRequestor per PEP 3134 (#40810)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-15 08:48:28 +00:00
Valor
56ebccf14d fix: narrow bare except in storage exists() to ClientError (#40420)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-08-15 07:12:04 +00:00
chenshiyang
d31a7d47bf refactor(controllers/web): dep-inject payloads with @model_validate (#40796)
Co-authored-by: chenshiyang.10 <chenshiyang.10@jd.com>
2026-08-15 06:55:06 +00:00
Dream
8031ea6d73 fix: replace bare except with except Exception in core agent, storage and vector backends (#40801) 2026-08-15 00:56:09 +00:00
zyssyz123
dfac3e524e fix: reclaim conversation resources on deletion (#40792)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-14 14:30:04 +00:00
WH-2099
fc7b51634d fix(api): protect agent sandbox reads (#40794) 2026-08-14 13:51:41 +00:00
WH-2099
96bfcf20cd fix(api): protect workspace credential mutations (#40790) 2026-08-14 12:03:54 +00:00
Mahbub Souad
557fbec45f refactor: add Mapped type annotations to model fields (#40373) (#40380) 2026-08-14 12:03:17 +00:00
AlexMultiAgent
eaa9d877f1 test(api): make session decorator path assertion platform-agnostic (#39895)
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-08-14 11:52:40 +00:00
chelsealong
b537fed907 fix(api): point SSRF-blocked error message at the correct dify issue (#40605) 2026-08-14 11:46:50 +00:00
酒诗
b6319c819c chore: dep inject for model in dataset document actions (#40787)
Co-authored-by: geneachina <geneachina@gmail.com>
2026-08-14 11:08:21 +00:00
WH-2099
b6694d462f fix(api): protect custom OAuth client deletion (#40786) 2026-08-14 10:59:49 +00:00
L1nSn0w
ea6770c7a1 feat(oauth): drive silent authorization from oauth_provider_apps.auto_authorize (#40783)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-14 10:29:39 +00:00
Joel
b0c5172e10 fix: show accurate copy for agent run limit errors (#40784) 2026-08-14 10:07:36 +00:00
WH-2099
62a544a146 fix(api): enforce RAG draft variable access boundaries (#40745) 2026-08-14 10:07:22 +00:00
非法操作
eec092f93d refactor(api): reuse app definition queries for service API info (#40476) 2026-08-14 09:38:35 +00:00
非法操作
e0fd3b264a refactor(api): reuse app definition queries for app parameters (#40465) 2026-08-14 09:38:34 +00:00
非法操作
17c8ff6a33 refactor(api): reuse app definition queries in web and service APIs (#40463) 2026-08-14 09:38:33 +00:00
非法操作
c425e4cdd5 refactor(api): extract installed app definition queries (#40462) 2026-08-14 09:38:32 +00:00
yyh
de9b456e82 fix(ci): align staged checks with Vite+ (#40774) 2026-08-14 09:09:33 +00:00
Joel
62d06a3c73 fix: preserve successful last run status after page restore (#40775) 2026-08-14 08:59:18 +00:00
zyssyz123
09f20b7a14 fix(agent): return conflict for duplicate creation (#40771)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-14 08:57:13 +00:00
Asuka Minato
0a1e52598f test: migrate controller identity ORM models to SQLite (#40626) 2026-08-14 08:54:48 +00:00
Asuka Minato
640bf56859 test: migrate workflow tool users to SQLite ORM models (#40628) 2026-08-14 08:54:31 +00:00
Asuka Minato
a958c2cadb test: migrate RAG residual entities to real ORM models (#40629) 2026-08-14 08:53:56 +00:00
Asuka Minato
f058e36084 test: migrate account tenant ORM stand-ins to SQLite (#40627) 2026-08-14 08:53:32 +00:00
Asuka Minato
9f364339cc test: migrate residual namespace ORM entities to real models (#40633) 2026-08-14 08:52:53 +00:00
盐粒 Yanli
3acc9470d4 feat(dify-agent): expand and enforce agent run limits (#40641) 2026-08-14 08:27:19 +00:00
yyh
9ddbc23b16 fix(dify-ui): refine segmented control focus feedback (#40769) 2026-08-14 08:17:14 +00:00
Myshkin451
c903e93e8b fix: preserve file preview content type (#37211)
Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-14 07:45:21 +00:00
盐粒 Yanli
0b36a0bbc0 fix(dify-agent): clarify shell working spaces (#40623)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-14 07:39:32 +00:00
林玮 (Jade Lin)
4e44ade07f fix: handle long text URLs in webscraper (#40757) 2026-08-14 07:34:53 +00:00
Joel
35132d6892 chore: reopen the education application flow (#40760)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-14 07:16:21 +00:00
dependabot[bot]
6edc8ba147 chore(deps): bump pydantic-ai-slim from 1.102.0 to 1.106.0 in /dify-agent (#40736)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 06:26:00 +00:00
yyh
12b3b62a7f chore: update workspace dependencies (#40764) 2026-08-14 06:17:11 +00:00
Crazywoola
58653f2cdb feat(workflow-generator): prefer installed tools (#40611) 2026-08-14 05:38:37 +00:00
Asuka Minato
ef176f130b test: migrate workflow event ORM models to real entities (#40634) 2026-08-14 05:35:10 +00:00
Asuka Minato
c36161a73f test: migrate OpenAPI and tool-file ORM entities (#40637) 2026-08-14 05:34:56 +00:00
Asuka Minato
4b189777c4 test: migrate web controller identity ORM models to real entities (#40639) 2026-08-14 05:34:43 +00:00
Asuka Minato
d08dc6162c test: migrate trace workflow run ORM model to real entity (#40642) 2026-08-14 05:34:29 +00:00
Asuka Minato
b5e05516f4 test: migrate annotation controller ORM models to real entities (#40643) 2026-08-14 05:34:16 +00:00
Asuka Minato
9f048cd89b test: migrate load balancing account ORM model to real entity (#40644) 2026-08-14 05:34:00 +00:00
Asuka Minato
78dfce3b63 test: migrate expired log workflow ORM models to real entities (#40645) 2026-08-14 05:33:46 +00:00
Asuka Minato
97d34ae6be test: migrate agent drive end-user ORM models to real entities (#40654) 2026-08-14 05:33:29 +00:00
Asuka Minato
ea0ee680dc test: migrate app service accounts and commit failures to real ORM (#40655) 2026-08-14 05:33:16 +00:00
yyh
9acdcbe2f8 fix(web): skip lifecycle scripts in Docker install (#40763) 2026-08-14 05:12:14 +00:00
yyh
0f63a05d04 chore: upgrade Vite+ to 0.2.9 (#40762) 2026-08-14 04:42:28 +00:00
WH-2099
3cd23d4ef8 fix(agent): authenticate agent control plane (#40737) 2026-08-14 04:32:09 +00:00
Pranav Agarwal
e2852524be fix(api): enforce edit and RBAC permissions on dataset API key GET endpoint (#40740) (#40743) 2026-08-14 04:27:37 +00:00
yyh
f8e05ebfc0 docs(web): consolidate maintainer guidance (#40758) 2026-08-14 03:40:05 +00:00
Joel
0ac005eb98 fix: add semantic document titles across pages (#40396) 2026-08-14 03:29:16 +00:00
林玮 (Jade Lin)
755c154b1f refactor: resolve completion model via manager (#40756) 2026-08-14 02:52:42 +00:00
Jingyi
e3b3165e9a feat(web): embed marketplace in integration categories (#40744) 2026-08-14 01:55:51 +00:00
Xiyuan Chen
bf044cf19c chore(deps): bump wandb to 0.28.2 (go-git, grpc, x/text, Go stdlib CVEs) (#40753) 2026-08-14 01:36:14 +00:00
yyh
1cf8db8395 refactor(web): clarify model selector trigger ownership (#40732)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-14 01:03:50 +00:00
WH-2099
c1bde25bcc fix(api): remove unsafe Bedrock test endpoint (#40742) 2026-08-13 19:47:13 +00:00
Harsh Kashyap
8238684334 fix(api): chain original exception in 13 except blocks per PEP 3134 (#40738)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-13 17:50:05 +00:00
zyssyz123
c9c7fb9962 fix(agent): meter internal LLM calls through API (#40613)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-08-13 12:15:44 +00:00
林玮 (Jade Lin)
b9248690b0 refactor: generalize hosted model quota lifecycle (#40722)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-13 08:10:02 +00:00
yyh
36dfc6c216 refactor(ui): enforce single-choice segmented controls (#40726) 2026-08-13 08:09:10 +00:00
yyh
7a6f380f2c fix(dify-ui): refine pagination navigation semantics (#40723) 2026-08-13 07:54:31 +00:00
Joel
84e348c52b fix: prevent cross-tab chat conversation switching (#40709) 2026-08-13 07:26:38 +00:00
盐粒 Yanli
79b8d7a797 fix(agent): normalize plugin provider identities (#40619)
Co-authored-by: Joel <iamjoel007@gmail.com>
2026-08-13 07:22:41 +00:00
yyh
fb30dd2d58 refactor(web): migrate app card icon controls (#40652) 2026-08-13 07:04:17 +00:00
yyh
a0ca3d2e81 refactor(web): remove legacy action button references (#40651) 2026-08-13 07:04:16 +00:00
yyh
204a906de8 refactor(web): migrate help menu icon trigger (#40650) 2026-08-13 07:04:15 +00:00
yyh
146f402a8c refactor(web): migrate workflow icon commands (#40649) 2026-08-13 06:44:03 +00:00
yyh
d6f32a0267 refactor(web): migrate plugin icon commands (#40648) 2026-08-13 06:44:02 +00:00
yyh
288766171d refactor(web): migrate dataset icon commands (#40647) 2026-08-13 06:44:01 +00:00
yyh
7ab368f4a4 refactor(web): migrate app icon commands (#40646) 2026-08-13 06:44:00 +00:00
yyh
d0e2723e45 refactor(web): migrate semantic icon controls (#40624) 2026-08-13 06:43:59 +00:00
Joel
c085142e66 chore: remove scope selection from the agent environment editor (#40701) 2026-08-13 06:24:51 +00:00
林玮 (Jade Lin)
c647cfd6a3 refactor: resolve summary models through manager (#40696) 2026-08-13 06:22:22 +00:00
yyh
29a4897e73 fix(ci): prevent false failures from superseded runs (#40699) 2026-08-13 06:18:22 +00:00
盐粒 Yanli
8df220c2b3 fix(agent): propagate resource collection failures (#40697) 2026-08-13 06:16:12 +00:00
zyssyz123
6e80f42f38 fix(api): decouple response listeners from execution lifecycle (#39813) 2026-08-13 05:50:43 +00:00
Asuka Minato
211344d0b0 test: migrate conversation lifecycle sessions and ORM models to SQLite (#40656) 2026-08-13 05:20:10 +00:00
Asuka Minato
6b5c04e7d3 test: migrate workflow node trace ORM models to real entities (#40657) 2026-08-13 05:19:52 +00:00
Asuka Minato
6ea89dc7cb test: migrate workflow tool-file ORM model to real entity (#40658) 2026-08-13 05:19:34 +00:00
Asuka Minato
ff46c3d7d7 test: migrate reranker upload-file sessions and ORM models to SQLite (#40659) 2026-08-13 05:19:19 +00:00
Asuka Minato
2f969afaf2 test: migrate app runner transaction sessions and ORM models to SQLite (#40660) 2026-08-13 05:19:00 +00:00
Asuka Minato
fbd38753fa test: migrate residual controller ORM identities to real models (#40661) 2026-08-13 05:18:44 +00:00
Asuka Minato
2d1fb61885 test: migrate RAG dataset ORM contracts to real models (#40662) 2026-08-13 05:18:25 +00:00
Asuka Minato
7e3381e8ad test: migrate OpenTelemetry ORM identities to real models (#40663) 2026-08-13 05:17:53 +00:00
Joel
190780e125 fix(e2e): skip hidden Agent knowledge E2E scenarios (#40691) 2026-08-13 05:03:15 +00:00
yyh
1f96da5e6d fix(ci): always report required style checks (#40684) 2026-08-13 04:05:45 +00:00
Joel
a8d5679a1c fix: update Agent advanced settings copy (#40682) 2026-08-13 03:25:43 +00:00
Xiyuan Chen
5bacb68115 fix(ops): keep the path of self-hosted LangSmith and Weave endpoints (#40584) 2026-08-13 03:18:22 +00:00
Asuka Minato
af2fac75db test: migrate workflow variable ORM model to real entity (#40664) 2026-08-13 03:05:31 +00:00
Asuka Minato
616736e0f1 test: migrate residual session factories to shared SQLite (#40665) 2026-08-13 03:04:59 +00:00
Asuka Minato
94323c0838 test: migrate residual dataset and archive ORM models (#40668) 2026-08-13 03:04:17 +00:00
Asuka Minato
223998bd48 test: migrate residual ORM model stand-ins to real mappings (#40666) 2026-08-13 03:03:46 +00:00
Joel
b3d770a69b fix: hide knowledge retrieval from Agent V2 configuration (#40679) 2026-08-13 03:03:12 +00:00
Asuka Minato
9fb51b7e67 test: migrate API workflow pause ORM models to real entities (#40669) 2026-08-13 03:03:10 +00:00
yyh
67366dd35c feat(ui): expose base toggle composition (#40622) 2026-08-13 02:29:37 +00:00
yyh
71b31e17b0 refactor(web): migrate domain icon commands (#40621) 2026-08-13 02:29:36 +00:00
yyh
312bc4912d refactor(web): migrate shared icon commands (#40620) 2026-08-13 02:29:36 +00:00
yyh
3925b5fa1b refactor(ui): adopt icon button in close controls (#40617) 2026-08-13 02:29:35 +00:00
yyh
d4d30148bd feat(ui): add icon button primitive (#40616) 2026-08-13 02:29:34 +00:00
林玮 (Jade Lin)
12e6838403 fix: meter hosted LLM invocations (#40589) 2026-08-12 12:33:21 +00:00
WH-2099
489772908b fix: enforce API key list permissions (#40516) 2026-08-12 11:44:27 +00:00
Benjamin
6a37e1538a fix(rbac): grant member policies when a dataset is opened to the work… (#39735)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 09:02:03 +00:00
wangxiaolei
eeaaf11a51 fix: fix create dataset with document miss rbac operation (#40599)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-12 08:57:43 +00:00
Wu Tianwei
6899c7720f refactor(web): remove deprecated deployments pages (#40593) 2026-08-12 08:49:51 +00:00
Byron.wang
51a007e84e refactor(api): extract init validation into layered application service (#40046) 2026-08-12 08:42:09 +00:00
yyh
6d185b6f40 test(web): remove brittle publisher lock test (#40592) 2026-08-12 07:55:46 +00:00
github-actions[bot]
7358c0f735 chore(i18n): sync translations with en-US (#40573)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-08-12 07:49:39 +00:00
yyh
01460f681e fix(web): render workspace plan badge during SSR (#40581)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-12 07:45:39 +00:00
Crazywoola
750fce7e52 fix(web): restore app deletion autofill and knowledge scope label (#40583) 2026-08-12 07:31:59 +00:00
Asuka Minato
216d528f1b test: migrate agent app sandbox sessions and ORM models to SQLite (#40568) 2026-08-12 06:30:17 +00:00
Xin Zhang
0ee89c1302 fix: restrict app deploy to explicit enterprise permission (#40564) 2026-08-12 06:25:05 +00:00
WH-2099
af382be837 fix(api): bind remaining RAG resources to owners (#40503) 2026-08-12 06:05:16 +00:00
chelsealong
d813edb945 fix(agent-v2): stop dropping non-vision attachments from agent chat (#40179)
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-08-12 05:57:21 +00:00
无能的迪拜巧克力
9041251da5 feat(web): add multimodal retrieval guidance for Knowledge embedding models (#40566) 2026-08-12 05:54:33 +00:00
Joel
9e04e4f5c2 chore: add Docker configuration for the Turnstile site key (#40569) 2026-08-12 05:54:21 +00:00
yyh
28ba7033fa refactor(web): remove profile bootstrap atom layer (#40474) 2026-08-12 04:05:57 +00:00
Wu Tianwei
fbaf23dc8d fix(web): improve app deployment v2 accessibility (#40550) 2026-08-12 03:43:35 +00:00
github-actions[bot]
9cc1f68364 chore(i18n): sync translations with en-US (#40546)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-08-12 03:25:34 +00:00
zyssyz123
059c9ce0b6 feat: add trunstile (#40494)
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-12 03:08:19 +00:00
yyh
668fb4c14a refactor(web): sync zendesk from profile query (#40473) 2026-08-12 02:57:27 +00:00
yyh
4c06bcf000 refactor(web): sync amplitude from profile query (#40472) 2026-08-12 02:15:24 +00:00
Wu Tianwei
7aba539e82 feat: app deployment v2 (#39829)
Co-authored-by: zhangx1n <zhangxin@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-12 01:55:36 +00:00
WH-2099
ef8544b173 fix(api): bind dataset operations to owners (#40149) 2026-08-11 16:37:14 +00:00
yyh
bee269afe8 refactor(web): align education autocomplete async behavior (#40500) 2026-08-11 14:12:50 +00:00
yyh
324b0f97ff refactor(web): read plugin profile from query cache (#40471) 2026-08-11 11:39:26 +00:00
yyh
7352944dec refactor(web): read account profile from query cache (#40470) 2026-08-11 11:17:29 +00:00
yyh
78c7fb9c5c refactor(web): move education verification to dedicated routes (#40493)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-11 09:56:11 +00:00
Joel
8598af2a17 fix: improve agent working directory tab labels and tooltips (#40496) 2026-08-11 09:36:33 +00:00
yyh
df07a0a1de refactor(web): read workflow profile from query cache (#40469) 2026-08-11 09:03:56 +00:00
Asuka Minato
a64a24b39c test: migrate snippet and vector sessions to SQLite (#40089)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron Wang <byron@dify.ai>
2026-08-11 08:51:07 +00:00
yyh
cd0e88c680 refactor(web): read dataset profile from query cache (#40468) 2026-08-11 08:30:49 +00:00
Asuka Minato
08008f6305 test: migrate controller sessions to SQLite (#40083)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-11 08:29:40 +00:00
Valor
e3812cf72b fix: add zh_Hans labels for builtin Audio provider and its tools (#40408) 2026-08-11 08:27:59 +00:00
yyh
4175d062f4 fix(web): make legacy Home redirect permanent (#40492) 2026-08-11 08:27:57 +00:00
Yunlu Wen
84f9b6530e chore: bump cryptography, pypdf, h2 for CVE fixes (#40409)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-11 08:14:55 +00:00
yyh
3d02a12eb6 refactor(web): read app profile from query cache (#40467) 2026-08-11 08:09:58 +00:00
Byron.wang
c1e94f8ee7 refactor(api): consolidate deployment edition handling (#40142)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-11 07:39:01 +00:00
yyh
7fffd51535 fix(web): preserve workflow canvas after history updates (#40489) 2026-08-11 07:36:21 +00:00
Joel
a68e0bbc6f fix: show a persistent toast for agent run timeouts (#40486) 2026-08-11 07:27:52 +00:00
Asuka Minato
dd3cf53044 test: migrate retention and task sessions to SQLite (#40090)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-11 07:05:36 +00:00
Asuka Minato
500e37e2fd test: migrate core RAG sessions to SQLite (#40085)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-11 07:05:26 +00:00
Joel
44c5f1c011 fix: split sandbox files into saved and temporary tabs (#40478) 2026-08-11 06:46:01 +00:00
Asuka Minato
7162450e83 test: migrate app service sessions to SQLite (#40088)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-11 06:43:15 +00:00
Asuka Minato
3f62f3cd05 test: migrate dataset indexing task sessions to SQLite (#40091) 2026-08-11 06:31:33 +00:00
Asuka Minato
15483f1592 test: use sqlite sessions in rag pipeline coverage (#40093) 2026-08-11 06:19:08 +00:00
wangxiaolei
4c7b870062 fix: _multiple_retrieve_thread not fail fast (#40466) 2026-08-11 05:40:23 +00:00
yyh
48451f5adf fix(web): keep agent ref updates out of render (#40429) 2026-08-11 03:57:58 +00:00
Kecheng Wang
f785af3cec fix(docker): use dynamic DNS resolution for all nginx upstreams (#39155)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 00:48:59 +00:00
Tushar Motwani
aa5e8a5eff chore: suppress pyrefly necessary conversions (#40444) 2026-08-10 16:41:34 +00:00
yyh
6a9efa8ffd fix(web): keep workflow ref updates out of render (#40428) 2026-08-10 14:38:04 +00:00
林玮 (Jade Lin)
4eb9a24997 refactor(workflow): align standalone node execution layers (#40447) 2026-08-10 12:37:34 +00:00
yyh
b206e691ca fix(web): keep shared ref updates out of render (#40427) 2026-08-10 11:33:11 +00:00
yyh
3f354c2b06 docs(web): clarify ref mutation ownership (#40426) 2026-08-10 11:04:18 +00:00
github-actions[bot]
8ae9d0ad8e chore(i18n): sync translations with en-US (#40435)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-08-10 11:00:10 +00:00
Joel
0f583cfe75 fix: widen agent orchestration chat on large displays (#40433) 2026-08-10 09:57:47 +00:00
zyqzyq
df250423d4 feat: add tool plugin param type date and date-picker (#36163)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: hjlarry <hjlarry@163.com>
2026-08-10 09:31:38 +00:00
Asuka Minato
b8934b743c test: migrate core app sessions to SQLite (#40084)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 09:01:02 +00:00
Byron.wang
976d061f9b refactor(api): merge ping and version endpoints into system (#40058)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 08:50:40 +00:00
Asuka Minato
96655436f4 test: migrate RAG index sessions to SQLite (#40080)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 08:43:18 +00:00
非法操作
648806bc9c refactor(api): extract explore banner query service (#40053) 2026-08-10 08:07:54 +00:00
yyh
64ebfe2235 fix(web): name the webapp password visibility action (#40225) 2026-08-10 07:12:24 +00:00
yyh
ca4d7ff1f2 fix(web): make suggested questions native buttons (#40200)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 06:47:19 +00:00
yyh
2917d1d51c refactor(web): migrate webapp password fields to dify ui (#40223) 2026-08-10 06:44:28 +00:00
yyh
c79b9821fa fix(web): name endpoint card actions (#40260) 2026-08-10 06:44:10 +00:00
yyh
6422ef2b57 fix(web): use button for workflow debug close (#40274)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 06:43:23 +00:00
yyh
509ea9afc4 fix(web): name secret key delete action (#40297) 2026-08-10 06:43:02 +00:00
yyh
1828cc6a10 fix(web): name MCP header delete actions (#40300) 2026-08-10 06:42:45 +00:00
yyh
8d5ff67d05 fix(web): name external API card actions (#40304) 2026-08-10 06:42:36 +00:00
yyh
ec63a26511 fix(web): expose chat variable tips state (#40334) 2026-08-10 06:42:03 +00:00
yyh
c2b3b89445 docs(web): add Next.js agent rules (#40398) 2026-08-10 06:39:37 +00:00
Joel
5967bd8e08 chore: tighten education verification and pause discount access (#40402)
Co-authored-by: Yansong Zhang <916125788@qq.com>
2026-08-10 06:33:03 +00:00
非法操作
7e7548cd18 fix(web): allow unlimited text input in trial apps (#40403) 2026-08-10 06:18:38 +00:00
dependabot[bot]
1ba3aa718e chore(deps): bump pymdown-extensions from 11.0 to 11.0.1 in /dify-agent (#40183)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 06:16:06 +00:00
dependabot[bot]
c7b1660650 chore(deps): bump gitpython from 3.1.57 to 3.1.58 in /api (#40184)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 06:14:40 +00:00
yyh
f5e1f1590f fix(web): use buttons for version filters (#40357)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 04:41:57 +00:00
yyh
09d0cca5b9 fix(web): make tool category filters keyboard accessible (#40243) 2026-08-10 04:18:04 +00:00
yyh
d8736be194 fix(web): name MCP header fields (#40299) 2026-08-10 03:57:24 +00:00
Pranav Agarwal
7f15f506f8 fix(api): end_date filter in feedback export excludes the entire end date (#40114) 2026-08-10 03:27:56 +00:00
chelsealong
6aa3fa5193 fix: catch ValueError instead of bare except in account CLI commands (#40356) 2026-08-10 03:18:33 +00:00
dependabot[bot]
c849be5e52 chore(deps): bump the github-actions-dependencies group with 2 updates (#40391)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 03:17:53 +00:00
yyh
b3b3088a17 fix(web): name external API panel close (#40309) 2026-08-10 03:13:36 +00:00
yyh
72fad39913 fix(web): name external API modal close (#40313) 2026-08-10 03:13:35 +00:00
yyh
e12563a4b4 fix(web): name plugin item actions (#40261) 2026-08-10 03:13:33 +00:00
yyh
e8afed13cc fix(web): name plugin detail close action (#40264) 2026-08-10 03:13:33 +00:00
yyh
7a72bb3571 fix(web): name OAuth redirect copy action (#40303) 2026-08-10 03:13:33 +00:00
yyh
08f30717ad fix(web): make verification back actions keyboard accessible (#40196) 2026-08-10 03:13:31 +00:00
yyh
2bdfbb9e61 fix(web): name generated result copy action (#40259) 2026-08-10 03:13:31 +00:00
yyh
06e757a9e9 fix(web): name the snippet info menu trigger (#40251) 2026-08-10 03:13:31 +00:00
yyh
7b84d4daca fix(web): use primary headings on password reset pages (#40193) 2026-08-10 03:13:31 +00:00
yyh
5e0d705a7f fix(web): name the plugin debug trigger (#40252) 2026-08-10 03:13:31 +00:00
yyh
30ca836e03 fix(web): make the citation toggle a button (#40204) 2026-08-10 03:12:25 +00:00
Asuka Minato
b391dc56a0 chore: example to rm db.session (#40370) 2026-08-10 02:58:52 +00:00
yyh
606fac6e1c fix(web): use button for publisher access (#40361)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 02:56:21 +00:00
yyh
ed36ee5133 refactor(web): rely on preview dialog containment (#40363)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 02:56:20 +00:00
yyh
c35242731f fix(web): use button for provider tool item (#40362) 2026-08-10 02:56:20 +00:00
yyh
d2d07d0345 fix(web): use buttons for plugin type switch (#40353)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 02:56:19 +00:00
yyh
8b908d6150 fix(web): use buttons for pricing categories (#40360)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 02:56:18 +00:00
yyh
1017a76b4c fix(web): name assigner add operation (#40337) 2026-08-10 02:56:18 +00:00
yyh
9c5d76c299 refactor(web): rely on QR panel containment (#40352)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 02:56:18 +00:00
yyh
83987b8eec fix(web): name prompt generator action (#40328) 2026-08-10 02:56:16 +00:00
yyh
cbd71699de fix(web): expose SVG preview toggle state (#40332) 2026-08-10 02:56:16 +00:00
yyh
5d58a25e52 fix(web): name human input preview close (#40321) 2026-08-10 02:56:16 +00:00
yyh
764d9b5fbe fix(web): name external retrieval close action (#40317) 2026-08-10 02:56:16 +00:00
yyh
5ffea6d30e fix(web): name code generator action (#40324) 2026-08-10 02:56:16 +00:00
yyh
2796210e18 fix(web): name agent log navigation menu (#40286) 2026-08-10 02:56:14 +00:00
yyh
1b2c71ac75 fix(web): name variable inspect close actions (#40289) 2026-08-10 02:56:14 +00:00
yyh
cc4bf22d07 fix(web): expose workflow parallel toggle state (#40282) 2026-08-10 02:56:14 +00:00
yyh
7295d9306f fix(web): name image previewer actions (#40283) 2026-08-10 02:56:14 +00:00
yyh
2a66b49bc7 fix(web): name human input delete action (#40293) 2026-08-10 02:56:14 +00:00
yyh
c944a25585 fix(web): expose debug toolbar semantics (#40258) 2026-08-10 02:56:12 +00:00
yyh
2067836851 fix(web): name saved item actions (#40256) 2026-08-10 02:56:12 +00:00
yyh
6f95a071b5 fix(web): name snippet stop action (#40278) 2026-08-10 02:56:11 +00:00
yyh
a543fd3656 fix(web): name workflow version history action (#40276) 2026-08-10 02:56:11 +00:00
yyh
d94c4415af fix(web): name annotation reply actions (#40270) 2026-08-10 02:56:11 +00:00
yyh
abbed23c64 fix(web): make dataset sidebar menu items buttons (#40248) 2026-08-10 02:56:09 +00:00
yyh
5de4e5b3c0 fix(web): name dataset configuration actions (#40254) 2026-08-10 02:56:09 +00:00
yyh
1e17a55e79 fix(web): name annotation configuration action (#40255) 2026-08-10 02:56:09 +00:00
yyh
465dab2eed refactor(web): keep copy id events on the button (#40247) 2026-08-10 02:56:09 +00:00
yyh
532e0a77cc fix(web): name conversation menu triggers (#40253) 2026-08-10 02:56:09 +00:00
yyh
47835e6b25 fix(web): expose the voice recording timer (#40205) 2026-08-10 02:56:07 +00:00
yyh
11c537dc1e fix(web): restore webapp email code form submission (#40227) 2026-08-10 02:56:07 +00:00
yyh
e4420227b5 fix(web): make the scroll-to-node action a button (#40214) 2026-08-10 02:56:07 +00:00
yyh
f9e44fe86e fix(web): restore the signup email focus order (#40230) 2026-08-10 02:56:07 +00:00
yyh
b256e211dc fix(web): make the member selector keyboard accessible (#40209) 2026-08-10 02:56:07 +00:00
yyh
2a34c7a714 fix(web): make the human input expander a button (#40202)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 02:55:30 +00:00
yyh
04132b51f0 fix(web): use button for chat variable panel close (#40333)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 02:53:49 +00:00
yyh
908f6ada4e fix(web): restore webapp password form submission (#40222) 2026-08-10 02:53:43 +00:00
yyh
e7da213f93 fix(web): name workflow debug actions (#40273) 2026-08-10 02:53:37 +00:00
yyh
e7f789f185 fix(web): hide citation measurement items from assistive tech (#40203) 2026-08-10 02:34:14 +00:00
yyh
f8d381e6c5 fix(web): name chat settings triggers (#40341) 2026-08-10 02:33:11 +00:00
yyh
c29425c0fc fix(web): name chat history controls (#40344) 2026-08-10 02:33:02 +00:00
yyh
612f9ee669 fix(web): use button for tool detail toggle (#40349)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 02:32:52 +00:00
Souravrajvi0
81a38df814 feat(api): cache remote recommended app template fetches (#40176) 2026-08-10 02:30:15 +00:00
yyh
e9eb130731 fix(web): use a button for unauthorized logout (#40198) 2026-08-10 02:29:46 +00:00
yyh
4edaecc04d fix(web): use a button for the webapp fallback action (#40197) 2026-08-10 02:29:10 +00:00
yyh
fc8d0a0187 fix(web): use a primary heading on the install page (#40194) 2026-08-10 02:26:36 +00:00
yyh
96960499ef fix(web): use primary headings on auth pages (#40192) 2026-08-10 02:26:10 +00:00
yyh
c2687c16b3 refactor(web): unify infinite scroll sentinels (#40188) 2026-08-10 02:22:07 +00:00
Asuka Minato
883944d289 test: Type test (#40371)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-10 02:13:17 +00:00
yyh
a7156a0702 fix(web): remove workflow output title tab stop (#40377) 2026-08-10 01:52:33 +00:00
yyh
dee9d27af8 fix(web): associate app deletion confirmation label (#40375) 2026-08-10 01:52:09 +00:00
yyh
e06f0ee315 fix(web): hide credential icons from assistive tech (#40376) 2026-08-10 01:51:42 +00:00
3896 changed files with 111462 additions and 130092 deletions

View File

@@ -55,7 +55,8 @@ For non-string `Select` and `RadioGroup` values, prefer explicit domain generics
Flag:
- Form-like UI using unrelated `Input` and `Button` pieces without a submit boundary.
- Text-like fields not composed through `Field`, `FieldLabel`, and `FieldControl` when using Dify UI form semantics.
- 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`.

View File

@@ -4,6 +4,7 @@ Read this document when a change introduces Effects, navigation side effects, me
## Effects
- Keep render pure: do not read or write `ref.current` during render except for predictable null-guarded lazy initialization. Update interaction-owned refs in event handlers, synchronize external-system refs after commit, and use state or derivation for rendered values.
- Use Effects only to synchronize with a named external system such as a browser API, subscription, timer, analytics integration, non-React widget, or imperative DOM API.
- Do not use Effects to transform render state, handle user actions, copy query data, reset state from props, or fetch data owned by framework APIs or TanStack Query.
- Initialize query-backed forms with keyed remounts or surface-entry hydration instead of copying data through Effects.

View File

@@ -37,3 +37,7 @@ indent_size = 2
[package.json]
indent_style = space
indent_size = 2
# Oxlint writes its generated suppression baseline without a final newline.
[oxlint-suppressions.json]
insert_final_newline = false

6
.github/CODEOWNERS vendored
View File

@@ -245,7 +245,7 @@
# Frontend - Billing and Education
/web/app/components/billing/ @iamjoel @zxhlyh
/web/app/education-apply/ @iamjoel @zxhlyh
/web/app/education/ @iamjoel @zxhlyh
# Frontend - Workspace
/web/app/components/header/account-dropdown/workplace-selector/ @iamjoel @zxhlyh
@@ -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@143f5f385f39b1b753ffed1a01ad443811855c8b # v1.16.1
uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
with:
node-version-file: .nvmrc
cache: true

View File

@@ -4,6 +4,8 @@ extends: default
rules:
brackets:
max-spaces-inside: 1
comments:
min-spaces-from-content: 1
comments-indentation: disable
document-start: disable
line-length: disable

View File

@@ -21,4 +21,4 @@
- [ ] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!)
- [ ] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
- [ ] I've updated the documentation accordingly.
- [ ] I ran `make lint && make type-check` (backend) and `cd web && pnpm exec vp staged` (frontend) to appease the lint gods
- [ ] I ran `make lint && make type-check` (backend) and `vp staged` (frontend) to appease the lint gods

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:
@@ -52,7 +52,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: '3.12'

View File

@@ -35,7 +35,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -94,7 +94,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -145,7 +145,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: '3.12'

View File

@@ -50,6 +50,8 @@ jobs:
vite.config.ts
lint.config.ts
eslint.config.mjs
knip.config.ts
scripts/check-web-production-unused-after-knip-fix.mjs
oxlint-suppressions.json
eslint-suppressions.json
.vscode/**
@@ -88,7 +90,7 @@ jobs:
python-version: '3.11'
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'

View File

@@ -229,3 +229,32 @@ jobs:
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
run: |
docker buildx imagetools inspect "$IMAGE_NAME:$IMAGE_VERSION"
sync-e2b-template:
needs: create-manifest
runs-on: ubuntu-24.04
if: github.repository == 'langgenius/dify' && startsWith(github.ref, 'refs/tags/')
strategy:
matrix:
include:
- project: dev
api_key_secret: E2B_API_KEY_DEV
- project: prod
api_key_secret: E2B_API_KEY_PROD
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Sync E2B Template (${{ matrix.project }})
env:
E2B_API_KEY: ${{ secrets[matrix.api_key_secret] }}
run: ./dify-agent-runtime/docker/sync-e2b-template.sh

View File

@@ -19,7 +19,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: '3.12'
@@ -40,7 +40,7 @@ jobs:
cp envs/middleware.env.example middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
with:
compose-file: |
docker/docker-compose.middleware.yaml
@@ -69,7 +69,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: '3.12'
@@ -94,7 +94,7 @@ jobs:
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
with:
compose-file: |
docker/docker-compose.middleware.yaml

View File

@@ -15,8 +15,10 @@ permissions:
statuses: write
concurrency:
group: main-ci-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.run_id }}
cancel-in-progress: true
# Pull request deduplication is owned by skip-duplicate-actions so pre_job can
# publish should_skip before required status finalizers run.
group: main-ci-${{ github.event.merge_group.head_sha || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'merge_group' }}
jobs:
pre_job:
@@ -394,10 +396,10 @@ jobs:
style-check:
name: Style Check
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
uses: ./.github/workflows/style.yml
with:
base-rev: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
skip-checks: ${{ needs.pre_job.outputs.should_skip == 'true' }}
vdb-tests-run:
name: Run VDB Tests

View File

@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Python & UV
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true

View File

@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true

View File

@@ -6,6 +6,11 @@ on:
base-rev:
required: true
type: string
skip-checks:
description: Create the required check runs without repeating previously successful work.
required: false
type: boolean
default: false
permissions:
checks: write
@@ -15,6 +20,7 @@ permissions:
jobs:
python-style:
name: Python Style
if: ${{ !inputs.skip-checks }}
runs-on: depot-ubuntu-24.04
steps:
@@ -41,7 +47,7 @@ jobs:
- name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: false
python-version: '3.12'
@@ -79,6 +85,7 @@ jobs:
web-style:
name: Web Style
if: ${{ !inputs.skip-checks }}
runs-on: depot-ubuntu-24.04
defaults:
run:
@@ -138,6 +145,7 @@ jobs:
ts-common-style:
name: TS Common
if: ${{ !inputs.skip-checks }}
runs-on: depot-ubuntu-24.04-4
permissions:
checks: write
@@ -166,6 +174,8 @@ jobs:
vite.config.ts
lint.config.ts
eslint.config.mjs
knip.config.ts
scripts/check-web-production-unused-after-knip-fix.mjs
oxlint-suppressions.json
eslint-suppressions.json
.vscode/**
@@ -181,6 +191,7 @@ jobs:
superlinter:
name: SuperLinter
if: ${{ !inputs.skip-checks }}
runs-on: depot-ubuntu-24.04
steps:
@@ -201,6 +212,7 @@ jobs:
**Dockerfile
dev/**
.editorconfig
.vite-hooks/**
- name: Super-linter
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
@@ -209,10 +221,11 @@ jobs:
BASH_SEVERITY: warning
DEFAULT_BRANCH: origin/main
EDITORCONFIG_FILE_NAME: editorconfig-checker.json
FILTER_REGEX_INCLUDE: pnpm-lock.yaml
ENABLE_GITHUB_PULL_REQUEST_SUMMARY_COMMENT: false
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IGNORE_GENERATED_FILES: true
IGNORE_GITIGNORED_FILES: true
VALIDATE_ALL_CODEBASE: false
VALIDATE_BASH: true
VALIDATE_BASH_EXEC: true
# FIXME: temporarily disabled until api-docker.yaml's run script is fixed for shellcheck

View File

@@ -8,15 +8,19 @@ on:
workflow_dispatch:
inputs:
files:
description: 'Specific files to translate (space-separated, e.g., "app common"). Required for full mode; leave empty in incremental mode to use en-US files changed since HEAD~1.'
description: 'Specific files to translate (space-separated, e.g., "app common").
Required for full mode; leave empty in incremental mode to use en-US
files changed since HEAD~1.'
required: false
type: string
languages:
description: 'Specific languages to translate (space-separated, e.g., "zh-Hans ja-JP"). Leave empty for all supported target languages except en-US.'
description: 'Specific languages to translate (space-separated, e.g., "zh-Hans
ja-JP"). Leave empty for all supported target languages except en-US.'
required: false
type: string
mode:
description: 'Sync mode: incremental (compare with previous en-US revision) or full (sync all keys in scope)'
description: 'Sync mode: incremental (compare with previous en-US revision) or
full (sync all keys in scope)'
required: false
default: incremental
type: choice
@@ -158,7 +162,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@c038e4dcdedfbbca18dfb17df35a17e40ded4ddc # v1.0.186
uses: anthropics/claude-code-action@9d7150bc8a3dae8149739a88019d192b579ad90c # v1.0.193
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

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:
@@ -26,16 +26,11 @@ jobs:
with:
persist-credentials: false
- name: Switch Ubuntu mirror
run: |
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
sudo apt-get update
- name: Setup web dependencies
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: '3.12'

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() }}
@@ -44,6 +44,41 @@ jobs:
include-hidden-files: true
retention-days: 1
browser-test:
name: Web Browser Tests
runs-on: ubuntu-24.04
timeout-minutes: 20
defaults:
run:
shell: bash
working-directory: ./web
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Install Chromium for Browser Mode
timeout-minutes: 15
run: vp exec playwright install --with-deps --only-shell chromium
- name: Run browser tests
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
if: ${{ !cancelled() }}
@@ -86,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 }}
@@ -101,11 +136,6 @@ jobs:
with:
persist-credentials: false
- name: Switch Ubuntu mirror
run: |
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
sudo apt-get update
- name: Setup web environment
uses: ./.github/actions/setup-web
@@ -116,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
@@ -127,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:
@@ -140,11 +180,6 @@ jobs:
with:
persist-credentials: false
- name: Switch Ubuntu mirror
run: |
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
sudo apt-get update
- name: Setup web environment
uses: ./.github/actions/setup-web
@@ -154,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

@@ -5,7 +5,6 @@ files=$(git diff --cached --name-only)
# check if api or web directory is modified
api_modified=false
web_modified=false
skip_web_checks=false
git_path() {
@@ -29,10 +28,6 @@ do
# set api_modified flag to true
api_modified=true
;;
web/*)
# set web_modified flag to true
web_modified=true
;;
esac
done

View File

@@ -4,7 +4,6 @@
"tailwindCSS.experimental.configFile": "web/app/styles/globals.css",
// Format
"[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
@@ -25,9 +24,9 @@
"[handlebars]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"oxc.fmt.configPath": "./vite.config.ts",
"oxc.disableNestedConfig": true,
"oxc.fmt.disableNestedConfig": true,
// Lint fix
"eslint.useFlatConfig": true,
"eslint.validate": ["json", "jsonc", "markdown", "yaml", "toml"],
"editor.codeActionsOnSave": {

View File

@@ -488,6 +488,13 @@ SENDGRID_API_KEY=
# Sentry configuration
SENTRY_DSN=
# Cloudflare Turnstile server-side verification for Dify Cloud sign-in
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
ENABLE_REQUEST_LOGGING=False
@@ -698,11 +705,10 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y
# Dify Agent backend
AGENT_BACKEND_BASE_URL=http://localhost:5050
# Bearer token sent to the Agent backend /runs API. Must match DIFY_AGENT_API_TOKEN on the server side.
# Bearer token sent to the Agent backend control-plane API. Must match DIFY_AGENT_API_TOKEN on the server side.
AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
# KnowledgeFS (Dataset 2.0)
KNOWLEDGE_FS_ENABLED=false
@@ -729,6 +735,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
@@ -825,6 +833,12 @@ ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
# Human input timeout check interval in minutes
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
# Whether to recover soft-deleted conversation cleanup jobs periodically
ENABLE_CONVERSATION_CLEANUP_TASK=true
# Recovery interval in minutes and maximum conversations dispatched per sweep
CONVERSATION_CLEANUP_TASK_INTERVAL=5
CONVERSATION_CLEANUP_BATCH_SIZE=100
# Nacos remote settings source HTTP timeouts (seconds).
# Bound how long requests to the Nacos endpoint wait before failing, so a slow or
# unresponsive Nacos server cannot stall API startup or token refresh.

View File

@@ -65,6 +65,101 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:init-validation-service-boundary]
name = Initialization validation application service is framework and persistence neutral
type = forbidden
source_modules =
services.init_validation_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:explore-banner-query-service-boundary]
name = Explore banner query application service is framework and persistence neutral
type = forbidden
source_modules =
services.explore_banner_query_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:account-application-boundary]
name = Account application services and contracts are framework and persistence neutral
type = forbidden
source_modules =
services.account_errors
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
source_modules =
services.app_definition_query_service
forbidden_modules =
controllers
extensions
flask
models
repositories
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
@@ -125,3 +220,59 @@ forbidden_modules =
repositories
sqlalchemy
werkzeug
[importlinter:contract:account-activation-service-boundary]
name = Account activation application service is framework and persistence neutral
type = forbidden
source_modules =
services.account_activation_service
services.entities.account_activation_entities
forbidden_modules =
configs
controllers
extensions
flask
models
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

View File

@@ -33,7 +33,6 @@ RUN uv sync --frozen --no-dev --no-editable
FROM base AS production
ENV FLASK_APP=app.py
ENV EDITION=SELF_HOSTED
ENV DEPLOY_ENV=PRODUCTION
ENV CONSOLE_API_URL=http://127.0.0.1:5001
ENV CONSOLE_WEB_URL=http://127.0.0.1:3000

View File

@@ -14,6 +14,7 @@ from contexts.wrapper import RecyclableContextVar
from controllers.console.error import UnauthorizedAndForceLogout
from core.logging.context import init_request_context
from dify_app import DifyApp
from enums import DeploymentEdition
from extensions.ext_socketio import sio
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.feature_entities import LicenseStatus
@@ -112,7 +113,7 @@ def create_flask_app_with_configs() -> DifyApp:
init_request_context()
RecyclableContextVar.increment_thread_recycles()
if dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
surface = _match_license_gated_surface(request.path)
if surface is not None:
try:

View File

@@ -23,6 +23,7 @@ from dify_agent.protocol import (
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RunCancelledEvent,
RunEvent,
RunStatusResponse,
)
@@ -45,6 +46,15 @@ class AgentBackendRunClient(Protocol):
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Request explicit cancellation for one Agent backend run."""
def cancel_run_and_wait(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
"""Request cancellation and wait for runner cleanup to finish."""
def stream_events(
self,
run_id: str,
@@ -67,6 +77,15 @@ class _DifyAgentSyncClient(Protocol):
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Cancel one run synchronously."""
def cancel_run_and_wait_sync(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
"""Cancel one run and wait for its terminal event synchronously."""
def stream_events_sync(
self,
run_id: str,
@@ -92,11 +111,9 @@ class DifyAgentBackendRunClient:
client: _DifyAgentSyncClient,
*,
stream_max_reconnects: int = 3,
stream_timeout_seconds: float = 1200,
) -> None:
self.client = client
self._stream_max_reconnects = stream_max_reconnects
self._stream_timeout_seconds = stream_timeout_seconds
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one run through ``POST /runs`` and normalize client exceptions."""
@@ -112,6 +129,19 @@ class DifyAgentBackendRunClient:
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def cancel_run_and_wait(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
"""Cancel one run, then wait for the cleanup-complete terminal event."""
try:
return self.client.cancel_run_and_wait_sync(run_id, request=request, after=after)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def stream_events(
self,
run_id: str,
@@ -125,7 +155,6 @@ class DifyAgentBackendRunClient:
run_id,
after=after,
max_reconnects=self._stream_max_reconnects,
timeout_seconds=self._stream_timeout_seconds,
should_stop=should_stop,
)
except Exception as exc:

View File

@@ -99,6 +99,8 @@ class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
error: str
error_type: RunFailureType | None = None
reason: str | None = None
session_snapshot: CompositorSessionSnapshot | None = None
usage: dict[str, JsonValue] | None = None
class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
@@ -107,6 +109,8 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED
reason: str | None = None
message: str | None = None
session_snapshot: CompositorSessionSnapshot | None = None
usage: dict[str, JsonValue] | None = None
type AgentBackendInternalEvent = Annotated[
@@ -184,6 +188,8 @@ class AgentBackendRunEventAdapter:
error=event.data.error,
error_type=event.data.error_type,
reason=event.data.reason,
session_snapshot=event.data.session_snapshot,
usage=_agent_run_usage(event.data.usage),
)
]
case RunCancelledEvent():
@@ -193,6 +199,8 @@ class AgentBackendRunEventAdapter:
source_event_id=event.id,
reason=event.data.reason,
message=event.data.message,
session_snapshot=event.data.session_snapshot,
usage=_agent_run_usage(event.data.usage),
)
]
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")

View File

@@ -8,6 +8,12 @@ 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:
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)
def create_agent_backend_run_client(
*,
base_url: str | None = None,
@@ -16,18 +22,17 @@ def create_agent_backend_run_client(
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
stream_read_timeout_seconds: float = 30,
stream_max_reconnects: int = 3,
stream_run_timeout_seconds: float = 1200,
) -> AgentBackendRunClient:
"""Create the API-side run client without hiding the ``dify-agent`` protocol."""
if use_fake:
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
if base_url is None:
raise ValueError("base_url is required when creating a real Agent backend client")
headers: dict[str, str] = {}
if api_token:
headers["Authorization"] = f"Bearer {api_token}"
return DifyAgentBackendRunClient(
Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds, headers=headers),
create_agent_backend_client(
base_url=base_url,
api_token=api_token,
stream_timeout=stream_read_timeout_seconds,
),
stream_max_reconnects=stream_max_reconnects,
stream_timeout_seconds=stream_run_timeout_seconds,
)

View File

@@ -18,6 +18,8 @@ from dify_agent.protocol import (
CreateRunRequest,
CreateRunResponse,
DeferredToolCallPayload,
RunCancelledEvent,
RunCancelledEventData,
RunEvent,
RunFailedEvent,
RunFailedEventData,
@@ -69,6 +71,28 @@ class FakeAgentBackendRunClient:
del request
return CancelRunResponse(run_id=run_id, status="cancelled")
def cancel_run_and_wait(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
"""Return a deterministic cleanup-complete cancellation event."""
del after
request = request or CancelRunRequest()
_ = self.cancel_run(run_id, request)
return RunCancelledEvent(
id="cancel-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunCancelledEventData(
reason=request.reason,
message=request.message,
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
def stream_events(
self,
run_id: str,
@@ -133,7 +157,11 @@ class FakeAgentBackendRunClient:
id="2-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunFailedEventData(error="fake failure", reason="unit_test"),
data=RunFailedEventData(
error="fake failure",
reason="unit_test",
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
),
)
case FakeAgentBackendScenario.PAUSED:

View File

@@ -25,11 +25,9 @@ from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, Dif
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
DifyPluginCredentialValue,
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,
@@ -57,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"
@@ -73,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)
@@ -150,8 +133,8 @@ class AgentBackendModelConfig(BaseModel):
plugin_id: str
model_provider: str
model: str
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
model_settings: dict[str, JsonValue] = Field(default_factory=dict)
context_window_tokens: int | None = Field(default=None, gt=0)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@@ -225,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).
@@ -274,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
@@ -308,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
@@ -346,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(
@@ -358,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(),
)
)
@@ -382,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(
@@ -414,8 +375,8 @@ class AgentBackendRunRequestBuilder:
plugin_id=run_input.model.plugin_id,
model_provider=run_input.model.model_provider,
model=run_input.model.model,
credentials=run_input.model.credentials,
model_settings=_agent_model_settings(run_input.model.model_settings),
context_window_tokens=run_input.model.context_window_tokens,
),
)
)
@@ -496,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.
"""
@@ -538,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(
@@ -550,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(),
)
)
@@ -574,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(
@@ -607,8 +552,8 @@ class AgentBackendRunRequestBuilder:
plugin_id=run_input.model.plugin_id,
model_provider=run_input.model.model_provider,
model=run_input.model.model,
credentials=run_input.model.credentials,
model_settings=_agent_model_settings(run_input.model.model_settings),
context_window_tokens=run_input.model.context_window_tokens,
),
),
]

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()
@@ -33,7 +33,7 @@ def reset_password(email, new_password, password_confirm):
try:
valid_password(new_password)
except:
except ValueError:
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
return
@@ -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()
@@ -75,7 +75,7 @@ def reset_email(email, new_email, email_confirm):
try:
email_validate(normalized_new_email)
except:
except ValueError:
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
return

View File

@@ -10,6 +10,8 @@ import click
import sqlalchemy as sa
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from enums import CloudPlan, DeploymentEdition
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs
@@ -126,14 +128,12 @@ def _get_archive_candidate_tenant_ids_by_prefix(
def _filter_paid_workflow_archive_tenant_ids(tenant_ids: list[str]) -> tuple[list[str], list[str]]:
from configs import dify_config
from enums.cloud_plan import CloudPlan
from services.billing_service import BillingService
tenant_ids = sorted(set(tenant_ids))
if not tenant_ids:
return [], []
if not dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return tenant_ids, []
plans = BillingService.get_plan_bulk_with_cache(tenant_ids)
@@ -1462,7 +1462,7 @@ def cleanup_orphaned_draft_variables(
"--graceful-period",
default=21,
show_default=True,
help="Graceful period in days after subscription expiration, will be ignored when billing is disabled.",
help="Graceful period in days after subscription expiration; ignored outside the Cloud edition.",
)
@click.option("--dry-run", is_flag=True, default=False, help="Show messages logs would be cleaned without deleting")
def clean_expired_messages(
@@ -1514,8 +1514,8 @@ def clean_expired_messages(
if from_days_ago <= before_days:
raise click.UsageError("--from-days-ago must be greater than --before-days.")
# Create policy based on billing configuration
# NOTE: graceful_period will be ignored when billing is disabled.
# Create the policy for the configured deployment edition.
# NOTE: graceful_period is ignored outside the Cloud edition.
policy = create_message_clean_policy(graceful_period_days=graceful_period)
if from_days_ago is not None and before_days is not None:

View File

@@ -6,7 +6,7 @@ from sqlalchemy import delete, select, update
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from events.app_event import app_was_created
from extensions.ext_database import db
from extensions.ext_redis import redis_client

View File

@@ -5,7 +5,6 @@ from typing import Any, override
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
from enums.deployment_edition import DeploymentEdition
from libs.file_utils import search_file_upwards
from .deploy import DeploymentConfig
@@ -117,11 +116,3 @@ class DifyConfig(
),
),
)
@property
def DEPLOYMENT_EDITION(self) -> DeploymentEdition:
if self.EDITION == "CLOUD":
return DeploymentEdition.CLOUD
if self.ENTERPRISE_ENABLED:
return DeploymentEdition.ENTERPRISE
return DeploymentEdition.COMMUNITY

View File

@@ -1,8 +1,8 @@
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings
from enums import DeploymentEdition
class DeploymentConfig(BaseSettings):
"""
@@ -25,9 +25,14 @@ class DeploymentConfig(BaseSettings):
default=False,
)
EDITION: Literal["SELF_HOSTED", "CLOUD"] = Field(
description="Deployment edition of the application (e.g., 'SELF_HOSTED', 'CLOUD')",
default="SELF_HOSTED",
DEPLOYMENT_EDITION: DeploymentEdition = Field(
description="Product edition of the application.",
default=DeploymentEdition.COMMUNITY,
)
INIT_PASSWORD: str = Field(
description="Password required before initializing a self-hosted deployment",
default="",
)
DEPLOY_ENV: str = Field(

View File

@@ -8,12 +8,6 @@ class EnterpriseFeatureConfig(BaseSettings):
**Before using, please contact business@dify.ai by email to inquire about licensing matters.**
"""
ENTERPRISE_ENABLED: bool = Field(
description="Enable or disable enterprise-level features."
"Before using, please contact business@dify.ai by email to inquire about licensing matters.",
default=False,
)
WEBAPP_PUBLIC_ACCESS_ENABLED: bool = Field(
description="Whether admins are allowed to set a webapp's access mode to public (anyone with the link, "
"no auth). Disable in security-sensitive on-prem deployments.",
@@ -59,7 +53,7 @@ class EnterpriseTelemetryConfig(BaseSettings):
"""
ENTERPRISE_TELEMETRY_ENABLED: bool = Field(
description="Enable enterprise telemetry collection (also requires ENTERPRISE_ENABLED=true).",
description="Enable enterprise telemetry collection for enterprise deployments.",
default=False,
)

View File

@@ -1,8 +1,10 @@
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
class ExtraServiceConfig(
@@ -10,7 +12,9 @@ class ExtraServiceConfig(
AgentBackendConfig,
ArchiveStorageConfig,
KnowledgeFSConfig,
LogStoreConfig,
NotionConfig,
SentryConfig,
TurnstileConfig,
):
pass

View File

@@ -13,7 +13,7 @@ class AgentBackendConfig(BaseSettings):
)
AGENT_BACKEND_API_TOKEN: str | None = Field(
description="Bearer token for authenticating with the Agent backend /runs API.",
description="Bearer token for authenticating with the Agent backend control-plane API.",
default=None,
)
@@ -37,11 +37,6 @@ class AgentBackendConfig(BaseSettings):
default=3,
)
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: PositiveFloat = Field(
description="Total deadline for one Agent backend run event stream.",
default=1200,
)
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

@@ -0,0 +1,41 @@
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings
class TurnstileConfig(BaseSettings):
"""Server-side Cloudflare Turnstile settings for Cloud sign-in."""
TURNSTILE_SECRET_KEY: SecretStr | None = Field(
default=None,
description="Secret key used to validate Cloudflare Turnstile tokens.",
)
TURNSTILE_ALLOWED_HOSTNAMES: str = Field(
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
def normalize_secret_key(cls, value: object) -> object:
if isinstance(value, SecretStr):
normalized = value.get_secret_value().strip()
return SecretStr(normalized) if normalized else None
if isinstance(value, str):
normalized = value.strip()
return normalized or None
return value
@property
def TURNSTILE_ALLOWED_HOSTNAME_SET(self) -> frozenset[str]:
return frozenset(
hostname.strip().lower().strip(".")
for hostname in self.TURNSTILE_ALLOWED_HOSTNAMES.split(",")
if hostname.strip().strip(".")
)

View File

@@ -810,17 +810,6 @@ class ModelLoadBalanceConfig(BaseSettings):
)
class BillingConfig(BaseSettings):
"""
Configuration for platform billing features
"""
BILLING_ENABLED: bool = Field(
description="Enable or disable billing functionality",
default=False,
)
class UpdateConfig(BaseSettings):
"""
Configuration for application update checks
@@ -1335,6 +1324,17 @@ class MultiModalTransferConfig(BaseSettings):
)
class NewAgentBetaConfig(BaseSettings):
NEW_AGENT_BETA_ACTIVITY_START_AT: datetime | None = Field(
description="New Agent Beta Publish window start in RFC3339 UTC (inclusive)",
default=None,
)
NEW_AGENT_BETA_ACTIVITY_END_AT: datetime | None = Field(
description="New Agent Beta Publish window end in RFC3339 UTC (exclusive)",
default=None,
)
class OpsTraceConfig(BaseSettings):
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field(
description="Maximum retry attempts for transient ops trace provider dispatch failures.",
@@ -1355,6 +1355,18 @@ class CeleryBeatConfig(BaseSettings):
class CeleryScheduleTasksConfig(BaseSettings):
ENABLE_CONVERSATION_CLEANUP_TASK: bool = Field(
description="Enable periodic recovery of soft-deleted conversation cleanup",
default=True,
)
CONVERSATION_CLEANUP_TASK_INTERVAL: PositiveInt = Field(
description="Soft-deleted conversation cleanup recovery interval in minutes",
default=5,
)
CONVERSATION_CLEANUP_BATCH_SIZE: PositiveInt = Field(
description="Maximum soft-deleted conversations dispatched per cleanup sweep",
default=100,
)
ENABLE_CLEAN_EMBEDDING_CACHE_TASK: bool = Field(
description="Enable clean embedding cache task",
default=False,
@@ -1537,6 +1549,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,
@@ -1621,7 +1637,6 @@ class FeatureConfig(
# place the configs in alphabet order
AppExecutionConfig,
AuthConfig, # Changed from OAuthConfig to AuthConfig
BillingConfig,
CodeExecutionSandboxConfig,
CreatorsPlatformConfig,
TriggerConfig,
@@ -1641,6 +1656,7 @@ class FeatureConfig(
ModelLoadBalanceConfig,
ModerationConfig,
MultiModalTransferConfig,
NewAgentBetaConfig,
OpsTraceConfig,
PositionConfig,
RagEtlConfig,

View File

@@ -451,6 +451,12 @@ class HostedFetchAppTemplateConfig(BaseSettings):
default="https://tmpl.dify.ai",
)
HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL: int = Field(
description="TTL in seconds for caching remote app template HTTP responses. 0 disables caching.",
default=600,
ge=0,
)
class HostedFetchPipelineTemplateConfig(BaseSettings):
"""

View File

@@ -335,22 +335,6 @@ class CeleryConfig(DatabaseConfig):
return self.CELERY_BROKER_URL.startswith("rediss://") if self.CELERY_BROKER_URL else False
class InternalTestConfig(BaseSettings):
"""
Configuration settings for Internal Test
"""
AWS_SECRET_ACCESS_KEY: str | None = Field(
description="Internal test AWS secret access key",
default=None,
)
AWS_ACCESS_KEY_ID: str | None = Field(
description="Internal test AWS access key ID",
default=None,
)
class DatasetQueueMonitorConfig(BaseSettings):
"""
Configuration settings for Dataset Queue Monitor
@@ -416,7 +400,6 @@ class MiddlewareConfig(
WeaviateConfig,
ElasticsearchConfig,
CouchbaseConfig,
InternalTestConfig,
VikingDBConfig,
UpstashConfig,
TidbOnQdrantConfig,

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,60 +0,0 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from models.agent import Agent, AgentConfigSnapshot, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App, load_annotation_reply_config
def get_published_agent_app_feature_dict_and_user_input_form(
app_model: App,
*,
session: Session,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Return public Agent App parameters backed by the published Agent Soul."""
app_model_config = app_model.app_model_config_with_session(session=session)
agent_id = app_model.bound_agent_id
if not agent_id:
raise AgentAppGeneratorError("Agent App has no bound Agent")
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.id == agent_id,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None:
raise AgentAppGeneratorError("Agent App has no bound Agent")
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
raise AgentAppNotPublishedError("Agent has not been published")
snapshot = session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AgentAppGeneratorError("Agent published version not found")
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
annotation_reply = load_annotation_reply_config(session, app_model.id) if app_model_config else None
features_dict = merge_agent_app_features(
agent_soul=agent_soul,
app_model_config=app_model_config,
annotation_reply=annotation_reply,
)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)

View File

@@ -126,12 +126,6 @@ class UsageCountResponse(ResponseModel):
count: int
class IndexInfoResponse(ResponseModel):
welcome: str
api_version: str
server_version: str
class AvatarUrlResponse(ResponseModel):
avatar_url: str

View File

@@ -152,7 +152,7 @@ def _extract_resource_id(
if resource_type == RBACResourceScope.APP:
app_id = matched_args.get("app_id")
if app_id:
return str(app_id)
return str(app_id) # pyrefly: ignore[unnecessary-type-conversion]
agent_id = matched_args.get("agent_id")
if agent_id:
@@ -161,7 +161,7 @@ def _extract_resource_id(
resource_id = matched_args.get("resource_id")
if resource_id:
return str(resource_id)
return str(resource_id) # pyrefly: ignore[unnecessary-type-conversion]
raise ValueError("Missing app_id in request path")
if resource_type == RBACResourceScope.DATASET:
@@ -171,9 +171,11 @@ def _extract_resource_id(
pipeline_id = matched_args.get("pipeline_id")
if pipeline_id:
dataset = db.session.scalar(select(Dataset).where(Dataset.pipeline_id == str(pipeline_id)))
dataset = db.session.scalar(
select(Dataset).where(Dataset.pipeline_id == str(pipeline_id), Dataset.tenant_id == tenant_id)
)
if not dataset:
raise NotFound("Dataset not found for pipeline")
return str(dataset.id)
return str(dataset.id) # pyrefly: ignore[unnecessary-type-conversion]
raise ValueError("Missing dataset_id or pipeline_id in request path")
raise ValueError(f"Unknown resource_type: {resource_type}")

View File

@@ -1,9 +1,11 @@
from http import HTTPStatus
from importlib import import_module
from flask import Blueprint
from flask import Blueprint, current_app, got_request_exception
from flask_restx import Namespace
from libs.external_api import ExternalApi
from machinery.errors import ActiveWorkspaceRequiredError
bp = Blueprint("console", __name__, url_prefix="/console/api")
@@ -14,6 +16,19 @@ api = ExternalApi(
description="Console management APIs for app configuration, monitoring, and administration",
)
@api.errorhandler(ActiveWorkspaceRequiredError)
def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError):
"""Map a broken Console admission invariant without exposing internal details."""
got_request_exception.send(current_app, exception=error)
status = HTTPStatus.INTERNAL_SERVER_ERROR
return {
"code": error.error_code,
"message": status.phrase,
"status": status.value,
}, status.value
console_ns = Namespace("console", description="Console management API operations", path="/")
RESOURCE_MODULES = (
@@ -41,10 +56,9 @@ from . import (
knowledge_fs_proxy,
notification,
onboarding,
ping,
setup,
spec,
version,
system,
workflow_run_archive,
)
from .agent import composer as agent_composer
@@ -58,7 +72,6 @@ from .app import (
agent_app_feature,
agent_app_sandbox,
agent_config_inspector,
agent_drive_inspector,
annotation,
app,
audio,
@@ -162,7 +175,6 @@ __all__ = [
"agent_app_sandbox",
"agent_composer",
"agent_config_inspector",
"agent_drive_inspector",
"agent_providers",
"agent_roster",
"annotation",
@@ -213,7 +225,6 @@ __all__ = [
"onboarding",
"ops_trace",
"parameter",
"ping",
"plugin",
"rag_pipeline",
"rag_pipeline_datasets",
@@ -231,11 +242,11 @@ __all__ = [
"socketio_workflow",
"spec",
"statistic",
"system",
"tags",
"tool_providers",
"trial",
"trigger_providers",
"version",
"website",
"workflow",
"workflow_app_log",

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})
@@ -284,7 +277,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
def _require_snippet_app_id(*, session: Session, tenant_id: str, snippet_id: UUID) -> str:
snippet = SnippetService(session=session).get_snippet_by_id(
snippet_id=str(snippet_id),
snippet_id=str(snippet_id), # pyrefly: ignore[unnecessary-type-conversion]
tenant_id=tenant_id,
)
if snippet is None:
@@ -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

@@ -3,7 +3,7 @@ from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from controllers.common.schema import (
@@ -484,12 +484,13 @@ def _resolve_agent_runtime_app_model(session: Session, *, tenant_id: str, agent_
return _agent_roster_service(session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
def _agent_api_key_count(session: Session, app_id: str) -> int:
def _agent_api_key_count(session: Session, app_model: App) -> int:
return (
session.scalar(
select(func.count(ApiToken.id)).where(
or_(ApiToken.tenant_id == app_model.tenant_id, ApiToken.tenant_id.is_(None)),
ApiToken.type == ApiTokenType.APP,
ApiToken.app_id == app_id,
ApiToken.app_id == app_model.id,
)
)
or 0
@@ -521,7 +522,7 @@ def _serialize_agent_api_access(session: Session, app_model: App) -> dict:
meta_endpoint=f"{base_url}/meta",
api_rpm=app_model.api_rpm or 0,
api_rph=app_model.api_rph or 0,
api_key_count=_agent_api_key_count(session, str(app_model.id)),
api_key_count=_agent_api_key_count(session, app_model),
)
return response.model_dump(mode="json")
@@ -598,6 +599,7 @@ class AgentAppListApi(Resource):
@console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__])
@console_ns.response(201, "Agent app created successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@console_ns.response(409, "Agent name already exists")
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@@ -940,6 +942,8 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
@@ -1069,7 +1073,7 @@ class AgentLogMessagesApi(Resource):
payload = _agent_observability_service(session).list_log_messages(
app=app_model,
agent_id=str(agent_id),
conversation_id=str(conversation_id),
conversation_id=str(conversation_id), # pyrefly: ignore[unnecessary-type-conversion]
params=AgentLogQueryParams(
page=query.page,
limit=query.limit,

View File

@@ -5,7 +5,7 @@ import flask_restx
from flask_restx import Resource
from flask_restx._http import HTTPStatus
from pydantic import field_validator
from sqlalchemy import delete, func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
@@ -89,7 +89,9 @@ class BaseApiKeyListResource(Resource):
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
keys = session.scalars(
select(ApiToken).where(
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
ApiToken.type == self.resource_type,
getattr(ApiToken, self.resource_id_field) == resource_id,
)
).all()
return ApiKeyList.model_validate({"data": keys}, from_attributes=True)
@@ -110,7 +112,9 @@ class BaseApiKeyListResource(Resource):
current_key_count: int = (
session.scalar(
select(func.count(ApiToken.id)).where(
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
ApiToken.type == self.resource_type,
getattr(ApiToken, self.resource_id_field) == resource_id,
)
)
or 0
@@ -172,6 +176,7 @@ class BaseApiKeyResource(Resource):
key = session.scalar(
select(ApiToken)
.where(
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
getattr(ApiToken, self.resource_id_field) == resource_id,
ApiToken.type == self.resource_type,
ApiToken.id == api_key_id,
@@ -187,7 +192,7 @@ class BaseApiKeyResource(Resource):
assert key is not None # nosec - for type checker only
ApiTokenCache.delete(key.token, key.type)
session.execute(delete(ApiToken).where(ApiToken.id == api_key_id))
session.delete(key)
session.commit()
@@ -198,13 +203,15 @@ class AppApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc(params={"resource_id": "App ID"})
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@agent_manage_required_for_agent_app
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
"""Get all API keys for an app"""
return dump_response(
ApiKeyList,
self._get_api_key_list(str(resource_id), current_tenant_id, session=session),
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
)
@console_ns.doc("create_app_api_key")
@@ -221,7 +228,7 @@ class AppApiKeyListResource(BaseApiKeyListResource):
"""Create a new API key for an app"""
return dump_response(
ApiKeyItem,
self._create_api_key(str(resource_id), current_tenant_id, session=session),
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
), 201
resource_type = ApiTokenType.APP
@@ -251,7 +258,7 @@ class AppApiKeyResource(BaseApiKeyResource):
) -> tuple[str, int]:
"""Delete an API key for an app"""
self._delete_api_key(
str(resource_id),
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
str(api_key_id),
current_tenant_id,
current_user,
@@ -271,12 +278,14 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc(params={"resource_id": "Dataset ID"})
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE)
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
"""Get all API keys for a dataset"""
return dump_response(
ApiKeyList,
self._get_api_key_list(str(resource_id), current_tenant_id, session=session),
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
)
@console_ns.doc("create_dataset_api_key")
@@ -292,7 +301,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
"""Create a new API key for a dataset"""
return dump_response(
ApiKeyItem,
self._create_api_key(str(resource_id), current_tenant_id, session=session),
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
), 201
resource_type = ApiTokenType.DATASET
@@ -321,7 +330,7 @@ class DatasetApiKeyResource(BaseApiKeyResource):
) -> tuple[str, int]:
"""Delete an API key for a dataset"""
self._delete_api_key(
str(resource_id),
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
str(api_key_id),
current_tenant_id,
current_user,

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

@@ -24,8 +24,11 @@ from controllers.console import console_ns
from controllers.console.app.error import AppNotFoundError
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
@@ -154,6 +157,7 @@ class AgentAppSandboxInfoResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
@@ -183,6 +187,7 @@ class AgentAppSandboxListResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
@@ -213,6 +218,7 @@ class AgentAppSandboxReadResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
@@ -243,6 +249,7 @@ class AgentAppSandboxDownloadResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
@model_validate(AgentSandboxDownloadPayload)
@@ -286,6 +293,7 @@ class WorkflowAgentSandboxListResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
@@ -323,6 +331,7 @@ class WorkflowAgentSandboxReadResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
@@ -353,6 +362,7 @@ class WorkflowAgentSandboxDownloadResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@with_current_user
@with_current_tenant_id
@model_validate(WorkflowAgentSandboxDownloadPayload)

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

@@ -95,7 +95,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__)
@@ -315,6 +314,9 @@ class WorkflowResponse(ResponseModel):
)
hash: str = Field(validation_alias=AliasChoices("unique_hash", "hash"))
version: str
# NULL for drafts and for versions published before numbering was introduced; those
# render as "Untitled Version" instead of `#N`. Never 0, so clients must test for null.
version_number: int | None = None
marked_name: str
marked_comment: str
created_by: SimpleAccount | None = Field(
@@ -1300,7 +1302,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,
@@ -1317,16 +1319,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,
@@ -1617,10 +1609,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
"""
@@ -1630,7 +1623,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,
)
@@ -1641,6 +1634,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

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

@@ -1,24 +1,25 @@
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from configs import dify_config
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
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from controllers.console.wraps import model_validate
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, timezone
from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError
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 models import AccountStatus
from models.account import TenantAccountJoin, TenantAccountRole
from services.account_service import RegisterService, TenantService
from services.billing_service import BillingService
from services.account_activation_service import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.account_activation_service import (
FrozenAccountError,
InvalidInvitationError,
InvitationAccountMismatchError,
)
from services.entities.account_activation_entities import ActivationCommand, InvitationLookup
class ActivateCheckQuery(BaseModel):
@@ -87,45 +88,19 @@ class ActivateCheckApi(Resource):
"Success",
console_ns.models[ActivationCheckResponse.__name__],
)
@model_validate(ActivateCheckQuery)
def get(self, req_data: ActivateCheckQuery):
workspaceId = req_data.workspace_id
token = req_data.token
invitation = RegisterService.get_invitation_with_case_fallback(
workspaceId, req_data.email, token, session=db.session()
def get(self):
args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True))
result = application_services().account_activation.check(
InvitationLookup(
workspace_id=args.workspace_id,
email=args.email,
token=args.token,
),
)
return ActivationCheckResponse.model_validate(result, from_attributes=True).model_dump(
mode="json",
exclude_none=True,
)
if invitation:
data = invitation.get("data", {})
tenant = invitation.get("tenant", None)
# Check workspace permission
if tenant:
from libs.workspace_permission import check_workspace_member_invite_permission
check_workspace_member_invite_permission(tenant.id)
workspace_name = tenant.name if tenant else None
workspace_id = tenant.id if tenant else None
invitee_email = data.get("email") if data else None
account = invitation.get("account")
account_status = account.status if account else None
requires_setup = data.get("requires_setup")
if requires_setup is None:
requires_setup = account_status == AccountStatus.PENDING
return {
"is_valid": invitation is not None,
"data": {
"workspace_name": workspace_name,
"workspace_id": workspace_id,
"email": invitee_email,
"account_status": account_status,
"requires_setup": requires_setup,
},
}
else:
return {"is_valid": False}
@console_ns.route("/activate")
@@ -139,70 +114,39 @@ class ActivateApi(Resource):
console_ns.models[ActivationResponse.__name__],
)
@console_ns.response(400, "Already activated or invalid token")
@model_validate(ActivatePayload)
def post(self, req_data: ActivatePayload):
def post(self):
"""Accept an invitation without letting an existing session act for another account.
Token-only activation remains available for legacy clients. When the request already
carries a console session, that session must belong to the account encoded in the
invitation before the token is consumed or tenant membership is changed.
"""
args = ActivatePayload.model_validate(console_ns.payload or {})
authenticated_account_id: str | None = None
if extract_access_token(request) is not None:
authenticated_account_id = current_account_with_tenant().account.id
normalized_request_email = req_data.email.lower() if req_data.email else None
invitation = RegisterService.get_invitation_with_case_fallback(
req_data.workspace_id, req_data.email, req_data.token, session=db.session()
)
if invitation is None:
raise AlreadyActivateError()
account = invitation["account"]
if extract_access_token(request):
current_account, _ = current_account_with_tenant()
if current_account.id != account.id:
raise InvitationAccountMismatchError()
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
raise AccountInFreezeError()
tenant = invitation["tenant"]
raw_role = invitation["data"].get("role")
try:
role = TenantAccountRole(raw_role) if raw_role else TenantAccountRole.NORMAL
except ValueError:
role = TenantAccountRole.NORMAL
if not TenantAccountRole.is_non_owner_role(role):
role = TenantAccountRole.NORMAL
membership_id = db.session.scalar(
select(TenantAccountJoin.id).where(
TenantAccountJoin.tenant_id == tenant.id,
TenantAccountJoin.account_id == account.id,
application_services().account_activation.activate(
ActivationCommand(
invitation=InvitationLookup(
workspace_id=args.workspace_id,
email=args.email,
token=args.token,
),
name=args.name,
interface_language=args.interface_language,
timezone=args.timezone,
),
authenticated_account_id=authenticated_account_id,
)
)
except InvalidInvitationError:
raise AlreadyActivateError() from None
except InvitationAccountMismatchError:
raise InvitationAccountMismatchHTTPError() from None
except EmailDomainSuspendedRegistrationError:
raise EmailDomainSuspendedError() from None
except FrozenAccountError:
raise AccountInFreezeError() from None
requires_setup = invitation["data"].get("requires_setup")
if requires_setup is None:
requires_setup = account.status == AccountStatus.PENDING
setup_fields: tuple[str, str, str] | None = None
if requires_setup:
if not req_data.name or not req_data.interface_language or not req_data.timezone:
raise AlreadyActivateError()
setup_fields = (req_data.name, req_data.interface_language, req_data.timezone)
RegisterService.revoke_token(req_data.workspace_id, normalized_request_email, req_data.token)
if membership_id is None:
TenantService.create_tenant_member(tenant, account, db.session(), role=role)
if setup_fields:
account.name = setup_fields[0]
account.interface_language = setup_fields[1]
account.timezone = setup_fields[2]
account.interface_theme = "light"
account.status = AccountStatus.ACTIVE
account.initialized_at = naive_utc_now()
TenantService.switch_tenant(account, tenant.id, session=db.session())
return {"result": "success"}
return dump_response(ActivationResponse, {"result": "success"})

View File

@@ -1,33 +1,52 @@
from http import HTTPStatus
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from extensions.ext_database import db
from controllers.console import console_ns
from controllers.console.auth.error import (
DataSourceApiKeyAuthCredentialsRejectedRequestError,
DataSourceApiKeyAuthProviderNotSupportedError,
DataSourceApiKeyAuthProviderUnavailableRequestError,
InvalidDataSourceApiKeyAuthCredentialsRequestError,
)
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, RBACResourceScope, model_validate
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.login import login_required
from services.auth.api_key_auth_service import ApiKeyAuthService
from .. import console_ns
from ..auth.error import ApiKeyAuthFailedError
from ..wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
is_admin_or_owner_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
from libs.helper import dump_response
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.auth.errors import (
DataSourceApiKeyAuthCredentialValidationError,
DataSourceApiKeyAuthProviderUnavailableError,
InvalidDataSourceApiKeyAuthCredentialsError,
UnsupportedDataSourceApiKeyAuthProviderError,
)
from services.entities.data_source_api_key_auth_entities import (
DataSourceApiKeyAuthBindingCreate,
DataSourceApiKeyAuthCredentials,
)
class ApiKeyAuthConfigPayload(BaseModel):
model_config = ConfigDict(extra="allow")
api_key: str = Field(min_length=1)
class ApiKeyAuthCredentialsPayload(BaseModel):
auth_type: str = Field(min_length=1)
config: ApiKeyAuthConfigPayload
class ApiKeyAuthBindingPayload(BaseModel):
category: str = Field(...)
provider: str = Field(...)
credentials: dict = Field(...)
category: str = Field(min_length=1)
provider: str = Field(min_length=1)
credentials: ApiKeyAuthCredentialsPayload
class ApiKeyAuthDataSourceItem(ResponseModel):
@@ -52,65 +71,77 @@ register_response_schema_models(
)
_ADMIN_OR_OWNER_ROLES = frozenset({TenantAccountRole.ADMIN, TenantAccountRole.OWNER})
@console_ns.route("/api-key-auth/data-source")
class ApiKeyAuthDataSource(Resource):
@console_ns.response(200, "Success", console_ns.models[ApiKeyAuthDataSourceListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
data_source_api_key_bindings = ApiKeyAuthService.get_provider_auth_list(current_tenant_id, session=db.session())
if data_source_api_key_bindings:
return {
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ApiKeyAuthDataSourceListResponse.__name__])
@console_account_admission()
def get(self, request_context: RequestContext):
bindings = application_services().data_source_api_key_auth.list_bindings(request_context)
return dump_response(
ApiKeyAuthDataSourceListResponse,
{
"sources": [
{
"id": data_source_api_key_binding.id,
"category": data_source_api_key_binding.category,
"provider": data_source_api_key_binding.provider,
"disabled": data_source_api_key_binding.disabled,
"created_at": int(data_source_api_key_binding.created_at.timestamp()),
"updated_at": int(data_source_api_key_binding.updated_at.timestamp()),
"id": binding.id,
"category": binding.category,
"provider": binding.provider,
"disabled": binding.disabled,
"created_at": int(binding.created_at.timestamp()),
"updated_at": int(binding.updated_at.timestamp()),
}
for data_source_api_key_binding in data_source_api_key_bindings
for binding in bindings
]
}
return {"sources": []}
},
)
@console_ns.route("/api-key-auth/data-source/binding")
class ApiKeyAuthDataSourceBinding(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.expect(console_ns.models[ApiKeyAuthBindingPayload.__name__])
@with_current_tenant_id
@console_account_admission(
allowed_roles=_ADMIN_OR_OWNER_ROLES,
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.CREDENTIAL_CREATE,
rbac_resource_required=False,
)
@model_validate(ApiKeyAuthBindingPayload)
def post(self, req_data: ApiKeyAuthBindingPayload, current_tenant_id: str):
# The role of the current user in the table must be admin or owner
data = req_data.model_dump()
ApiKeyAuthService.validate_api_key_auth_args(data)
def post(self, req_data: ApiKeyAuthBindingPayload, request_context: RequestContext):
credential_options = req_data.credentials.config.model_dump(exclude={"api_key"})
command = DataSourceApiKeyAuthBindingCreate(
category=req_data.category,
provider=req_data.provider,
credentials=DataSourceApiKeyAuthCredentials(
auth_type=req_data.credentials.auth_type,
api_key=req_data.credentials.config.api_key,
options=credential_options,
),
)
try:
ApiKeyAuthService.create_provider_auth(current_tenant_id, data, session=db.session())
except Exception as e:
raise ApiKeyAuthFailedError(str(e))
return {"result": "success"}, 200
application_services().data_source_api_key_auth.create_binding(request_context, command)
except UnsupportedDataSourceApiKeyAuthProviderError as exc:
raise DataSourceApiKeyAuthProviderNotSupportedError() from exc
except InvalidDataSourceApiKeyAuthCredentialsError as exc:
raise InvalidDataSourceApiKeyAuthCredentialsRequestError(description=str(exc)) from exc
except DataSourceApiKeyAuthCredentialValidationError as exc:
raise DataSourceApiKeyAuthCredentialsRejectedRequestError(description=str(exc)) from exc
except DataSourceApiKeyAuthProviderUnavailableError as exc:
raise DataSourceApiKeyAuthProviderUnavailableRequestError() from exc
return dump_response(SimpleResultResponse, {"result": "success"}), HTTPStatus.OK
@console_ns.route("/api-key-auth/data-source/<uuid:binding_id>")
class ApiKeyAuthDataSourceBindingDelete(Resource):
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@console_ns.response(204, "Binding deleted successfully")
@with_current_tenant_id
def delete(self, current_tenant_id: str, binding_id: UUID):
# The role of the current user in the table must be admin or owner
ApiKeyAuthService.delete_provider_auth(current_tenant_id, str(binding_id), session=db.session())
return "", 204
@console_ns.response(HTTPStatus.NO_CONTENT, "Binding deleted successfully")
@console_account_admission(
allowed_roles=_ADMIN_OR_OWNER_ROLES,
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.CREDENTIAL_MANAGE,
rbac_resource_required=False,
)
def delete(self, request_context: RequestContext, binding_id: UUID):
application_services().data_source_api_key_auth.delete_binding(request_context, str(binding_id))
return "", HTTPStatus.NO_CONTENT

View File

@@ -15,6 +15,7 @@ from controllers.console.auth.error import (
InvalidTokenError,
PasswordMismatchError,
)
from enums import DeploymentEdition
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import EmailStr, extract_remote_ip
@@ -23,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
@@ -98,8 +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.BILLING_ENABLED 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)
@@ -181,13 +192,15 @@ class EmailRegisterResetApi(Resource):
if account:
raise EmailAlreadyInUseError()
ip_address = extract_remote_ip(request)
account = self._create_new_account(
email=normalized_email,
password=req_data.password_confirm,
timezone=req_data.timezone,
language=req_data.language,
ip_address=ip_address,
)
token_pair = AccountService.login(account=account, session=db.session(), ip_address=extract_remote_ip(request))
token_pair = AccountService.login(account=account, session=db.session(), ip_address=ip_address)
AccountService.reset_login_error_rate_limit(normalized_email)
return {"result": "success", "data": token_pair.model_dump()}
@@ -198,6 +211,7 @@ class EmailRegisterResetApi(Resource):
password: str,
timezone: str | None = None,
language: str | None = None,
ip_address: str | None = None,
) -> Account:
try:
return AccountService.create_account_and_tenant(
@@ -206,9 +220,12 @@ class EmailRegisterResetApi(Resource):
password=password,
interface_language=get_valid_language(language),
timezone=timezone,
ip_address=ip_address,
session=db.session(),
)
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

@@ -1,10 +1,28 @@
from libs.exception import BaseHTTPException
class ApiKeyAuthFailedError(BaseHTTPException):
error_code = "auth_failed"
description = "{message}"
code = 500
class DataSourceApiKeyAuthProviderNotSupportedError(BaseHTTPException):
error_code = "unsupported_data_source_api_key_auth_provider"
description = "The data-source API-key authentication provider is not supported."
code = 400
class InvalidDataSourceApiKeyAuthCredentialsRequestError(BaseHTTPException):
error_code = "invalid_data_source_api_key_auth_credentials"
description = "The data-source API-key authentication credentials are invalid."
code = 400
class DataSourceApiKeyAuthCredentialsRejectedRequestError(BaseHTTPException):
error_code = "data_source_api_key_auth_credentials_rejected"
description = "The data-source provider rejected the API-key authentication credentials."
code = 400
class DataSourceApiKeyAuthProviderUnavailableRequestError(BaseHTTPException):
error_code = "data_source_api_key_auth_provider_unavailable"
description = "The data-source API-key authentication provider is temporarily unavailable."
code = 502
class InvalidEmailError(BaseHTTPException):
@@ -77,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."
@@ -95,6 +119,18 @@ class EmailPasswordLoginLimitError(BaseHTTPException):
code = 429
class TurnstileVerificationFailedError(BaseHTTPException):
error_code = "turnstile_verification_failed"
description = "Turnstile verification failed. Please try again."
code = 400
class TurnstileServiceUnavailableError(BaseHTTPException):
error_code = "turnstile_service_unavailable"
description = "Turnstile verification is temporarily unavailable. Please try again later."
code = 503
class EmailCodeLoginRateLimitExceededError(BaseHTTPException):
error_code = "email_code_login_rate_limit_exceeded"
description = "Too many login emails have been sent. Please try again in {minutes} minutes."

View File

@@ -1,4 +1,5 @@
import logging
from uuid import UUID
import flask_login
from flask import make_response, request
@@ -22,14 +23,18 @@ from controllers.console import console_ns
from controllers.console.auth.error import (
AuthenticationFailedError,
EmailCodeError,
EmailCodeLoginServiceUnavailableError,
EmailPasswordLoginLimitError,
InvalidEmailError,
InvalidTokenError,
TurnstileServiceUnavailableError,
TurnstileVerificationFailedError,
)
from controllers.console.error import (
AccountBannedError,
AccountInFreezeError,
AccountNotFound,
EmailDomainSuspendedError,
EmailSendIpLimitError,
NotAllowedCreateWorkspace,
SeatsLimitExceeded,
@@ -43,6 +48,7 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from enums import DeploymentEdition
from extensions.ext_database import db
from libs.helper import EmailStr, extract_remote_ip
from libs.helper import timezone as validate_timezone_string
@@ -58,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,
@@ -65,8 +75,17 @@ 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,
)
logger = logging.getLogger(__name__)
@@ -81,10 +100,23 @@ class EmailPayload(BaseModel):
language: str | None = Field(default=None)
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)
@@ -96,7 +128,7 @@ class EmailCodeLoginPayload(BaseModel):
return validate_timezone_string(value)
register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPayload)
register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeSendPayload, EmailCodeLoginPayload)
register_response_schema_models(
console_ns,
SimpleResultDataResponse,
@@ -121,9 +153,13 @@ class LoginApi(Resource):
request_email = req_data.email
normalized_email = request_email.lower()
if dify_config.BILLING_ENABLED 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:
@@ -160,7 +196,6 @@ class LoginApi(Resource):
AccountService.add_login_error_rate_limit(normalized_email)
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS)
raise AuthenticationFailedError() from exc
# SELF_HOSTED only have one workspace
tenants = TenantService.get_join_tenants(account, session=db.session())
if len(tenants) == 0:
if (
@@ -226,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,
@@ -242,24 +279,36 @@ class ResetPasswordSendEmailApi(Resource):
@console_ns.route("/email-code-login")
class EmailCodeLoginSendEmailApi(Resource):
@setup_required
@console_ns.expect(console_ns.models[EmailPayload.__name__])
@console_ns.expect(console_ns.models[EmailCodeSendPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@model_validate(EmailPayload)
def post(self, req_data: EmailPayload):
@model_validate(EmailCodeSendPayload)
def post(self, req_data: EmailCodeSendPayload):
normalized_email = req_data.email.lower()
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
raise EmailSendIpLimitError()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
try:
TurnstileService.verify(token=req_data.turnstile_token, remote_ip=ip_address)
except TurnstileChallengeRejectedError as exc:
logger.info("Turnstile rejected an email-code login challenge")
raise TurnstileVerificationFailedError() from exc
except TurnstileUpstreamError as exc:
logger.warning("Turnstile verification is unavailable", exc_info=True)
raise TurnstileServiceUnavailableError() from exc
if req_data.language is not None and req_data.language == "zh-Hans":
language = "zh-Hans"
else:
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:
@@ -284,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:
@@ -327,18 +411,22 @@ class EmailCodeLoginApi(Resource):
name=user_email,
interface_language=get_valid_language(language),
timezone=req_data.timezone,
ip_address=ip_address,
session=db.session(),
)
except WorkSpaceNotAllowedCreateError:
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=extract_remote_ip(request))
token_pair = AccountService.login(account, session=db.session(), ip_address=ip_address)
AccountService.reset_login_error_rate_limit(user_email)
# Create response with cookies instead of returning tokens in body

View File

@@ -12,6 +12,8 @@ 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
from libs.helper import extract_remote_ip
@@ -25,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
@@ -232,7 +241,13 @@ class OAuthCallback(Resource):
return _redirect_with_console_session(account, target_url)
try:
account, oauth_new_user = _generate_account(provider, user_info, timezone=timezone, language=language)
account, oauth_new_user = _generate_account(
provider,
user_info,
timezone=timezone,
language=language,
ip_address=extract_remote_ip(request),
)
except AccountNotFoundError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.")
except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError):
@@ -242,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:
@@ -284,6 +301,7 @@ def _generate_account(
user_info: OAuthUserInfo,
timezone: str | None = None,
language: str | None = None,
ip_address: str | None = None,
) -> tuple[Account, bool]:
# Get account by openid or email.
account = _get_account_by_openid_or_email(provider, user_info)
@@ -301,13 +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.BILLING_ENABLED 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)
@@ -319,6 +336,7 @@ def _generate_account(
provider=provider,
language=interface_language,
timezone=timezone,
ip_address=ip_address,
session=db.session(),
)

View File

@@ -42,6 +42,7 @@ class OAuthProviderAppResponse(BaseModel):
app_icon: str
app_label: dict[str, Any]
scope: str
auto_authorize: bool
class OAuthProviderAuthorizeResponse(BaseModel):
@@ -167,6 +168,7 @@ class OAuthServerAppApi(Resource):
"app_icon": oauth_provider_app.app_icon,
"app_label": oauth_provider_app.app_label,
"scope": oauth_provider_app.scope,
"auto_authorize": oauth_provider_app.auto_authorize,
}
)

View File

@@ -15,7 +15,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from enums.cloud_plan import CloudPlan
from enums import CloudPlan
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
@@ -40,14 +40,18 @@ class BillingInvoiceResponse(ResponseModel):
url: str
class BillingSubscriptionResponse(ResponseModel):
url: str
register_schema_models(console_ns, SubscriptionQuery, PartnerTenantsPayload)
register_response_schema_models(console_ns, BillingResponse, BillingInvoiceResponse)
register_response_schema_models(console_ns, 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[BillingResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[BillingSubscriptionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -92,8 +96,8 @@ class PartnerTenants(Resource):
try:
click_id = req_data.click_id
decoded_partner_key = base64.b64decode(partner_key).decode("utf-8")
except Exception:
raise BadRequest("Invalid partner_key")
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:
raise BadRequest("Invalid partner information")

View File

@@ -36,6 +36,7 @@ from ..wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
is_admin_or_owner_required,
model_validate,
rbac_permission_required,
setup_required,
@@ -187,6 +188,8 @@ 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[SimpleResultResponse.__name__])
@with_current_tenant_id

View File

@@ -53,6 +53,7 @@ from models.enums import ApiTokenType, SegmentStatus
from models.provider_ids import ModelProviderID
from services.api_token_service import ApiTokenCache
from services.app_service import AppService
from services.dataset_ref_service import DatasetRefService
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.rbac_service import RBACResourceWhitelistScope, ReplaceMemberBindings
@@ -67,6 +68,18 @@ def _has_dataset_list_permission(permission_keys: list[str]) -> bool:
return any(permission_key in DATASET_LIST_PERMISSION_KEYS for permission_key in permission_keys)
def _get_accessible_dataset(dataset_id: UUID, tenant_id: str, current_user: Account, session: Session) -> Dataset:
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), tenant_id, session=session)
if dataset is None:
raise NotFound("Dataset not found.")
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))
return dataset
def _validate_indexing_technique(value: str | None) -> str | None:
if value is None:
return value
@@ -655,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,
@@ -811,12 +826,13 @@ class DatasetUseCheckApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
@with_session(write=False)
def get(self, session: Session, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset_is_using = DatasetService.dataset_use_check(dataset_id_str, session)
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
dataset_is_using = DatasetService.dataset_use_check(DatasetRefService.create_dataset_ref(dataset), session)
return UsageCheckResponse(is_using=dataset_is_using).model_dump(mode="json"), 200
@@ -1027,13 +1043,15 @@ class DatasetIndexingStatusApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, dataset_id: UUID):
dataset_id_str = str(dataset_id)
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
documents = session.scalars(
select(Document).where(Document.dataset_id == dataset_id_str, Document.tenant_id == current_tenant_id)
select(Document).where(Document.dataset_id == dataset.id, Document.tenant_id == dataset.tenant_id)
).all()
documents_status = []
for document in documents:
@@ -1041,6 +1059,8 @@ class DatasetIndexingStatusApi(Resource):
session.scalar(
select(func.count(DocumentSegment.id)).where(
DocumentSegment.completed_at.isnot(None),
DocumentSegment.tenant_id == dataset.tenant_id,
DocumentSegment.dataset_id == dataset.id,
DocumentSegment.document_id == str(document.id),
DocumentSegment.status != SegmentStatus.RE_SEGMENT,
)
@@ -1050,6 +1070,8 @@ class DatasetIndexingStatusApi(Resource):
total_segments = (
session.scalar(
select(func.count(DocumentSegment.id)).where(
DocumentSegment.tenant_id == dataset.tenant_id,
DocumentSegment.dataset_id == dataset.id,
DocumentSegment.document_id == str(document.id),
DocumentSegment.status != SegmentStatus.RE_SEGMENT,
)
@@ -1086,6 +1108,8 @@ class DatasetApiKeyApi(Resource):
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
@@ -1177,12 +1201,16 @@ class DatasetEnableApiApi(Resource):
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
def post(self, session: Session, dataset_id: UUID, status: str):
dataset_id_str = str(dataset_id)
def post(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID, status: str):
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
if not current_user.is_dataset_editor:
raise Forbidden()
DatasetService.update_dataset_api_status(dataset_id_str, status == "enable", session)
DatasetService.update_dataset_api_status(dataset, status == "enable", current_user, session)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@@ -1248,14 +1276,15 @@ class DatasetErrorDocs(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
@with_session(write=False)
def get(self, session: Session, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
if dataset is None:
raise NotFound("Dataset not found.")
results = DocumentService.get_error_documents_by_dataset_id(dataset_id_str, session)
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
results = DocumentService.get_error_documents_by_dataset_ref(
DatasetRefService.create_dataset_ref(dataset), session
)
return dump_response(ErrorDocsResponse, {"data": results, "total": len(results)}), 200
@@ -1307,12 +1336,13 @@ class DatasetAutoDisableLogApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
@with_session(write=False)
def get(self, session: Session, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
if dataset is None:
raise NotFound("Dataset not found.")
auto_disable_logs = DatasetService.get_dataset_auto_disable_logs(dataset_id_str, session)
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
auto_disable_logs = DatasetService.get_dataset_auto_disable_logs(
DatasetRefService.create_dataset_ref(dataset), session
)
return dump_response(AutoDisableLogsResponse, auto_disable_logs), 200

View File

@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
from controllers.common.controller_schemas import DocumentBatchDownloadZipPayload
from controllers.common.fields import SimpleResultMessageResponse, SimpleResultResponse, UrlResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
@@ -54,14 +55,17 @@ from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from libs.pagination import paginate_query
from models import Account, Document, DocumentSegment, UploadFile
from models.dataset import DocumentPipelineExecutionLog
from models.dataset import DatasetPermissionEnum, DocumentPipelineExecutionLog
from models.enums import IndexingStatus, ProcessRuleMode, SegmentStatus
from services.dataset_ref_service import DatasetRefService
from services.dataset_service import DatasetService, DocumentService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.rbac_service import RBACResourceWhitelistScope, ReplaceMemberBindings
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel
from services.file_service import FileService
from services.vector_space_admission_service import get_vector_space_admission_error_fields
from tasks.generate_summary_index_task import generate_summary_index_task
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
from ..app.error import (
ProviderModelCurrentlyNotSupportError,
@@ -78,6 +82,7 @@ from ..datasets.error import (
)
from ..wraps import (
account_initialization_required,
check_knowledge_rate_limit,
cloud_edition_billing_rate_limit_check,
cloud_edition_billing_resource_check,
setup_required,
@@ -294,23 +299,23 @@ class DocumentResource(Resource):
def get_document(
self, session: Session, dataset_id: str, document_id: str, current_user: Account, current_tenant_id: str
) -> Document:
dataset = DatasetService.get_dataset(dataset_id, session)
dataset = DatasetService.get_dataset_for_tenant(dataset_id, current_tenant_id, session=session)
if not dataset:
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))
document = DocumentService.get_document(dataset_id, document_id, session=session)
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
document_ref = DatasetRefService.create_document_ref_from_id(dataset_ref, document_id)
document = DatasetRefService.get_document_by_ref(document_ref, session=session)
if not document:
raise NotFound("Document not found.")
if document.tenant_id != current_tenant_id:
raise Forbidden("No permission.")
return document
def get_batch_documents(
@@ -578,18 +583,33 @@ class DatasetDocumentListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Documents deleted successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
def delete(self, session: Session, dataset_id: UUID):
def delete(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
dataset_id: UUID,
):
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 dataset is None:
raise NotFound("Dataset not found.")
# check user's model setting
DatasetService.check_dataset_model_setting(dataset)
if not current_user.is_dataset_editor:
raise Forbidden()
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))
check_knowledge_rate_limit()
try:
document_ids = request.args.getlist("document_id")
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
@@ -662,6 +682,21 @@ class DatasetInitApi(Resource):
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
if dify_config.RBAC_ENABLED:
dataset.permission = DatasetPermissionEnum.ALL_TEAM
else:
dataset.permission = DatasetPermissionEnum.ONLY_ME
session.flush()
if dify_config.RBAC_ENABLED:
enterprise_rbac_service.RBACService.DatasetAccess.replace_whitelist(
current_tenant_id,
current_user.id,
dataset.id,
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
)
initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, dataset_id=dataset.id)
return dump_response(
DatasetAndDocumentResponse,
{"dataset": dataset, "documents": document_responses(documents, session=session), "batch": batch},
@@ -1367,29 +1402,32 @@ class DocumentPauseApi(DocumentResource):
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Document paused successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
def patch(self, session: Session, dataset_id: UUID, document_id: UUID):
def patch(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
dataset_id: UUID,
document_id: UUID,
):
"""pause document."""
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
if not dataset:
raise NotFound("Dataset not found.")
document = DocumentService.get_document(dataset.id, document_id_str, session=session)
# 404 if document not found
if document is None:
raise NotFound("Document Not Exists.")
document = self.get_document(session, dataset_id_str, document_id_str, current_user, current_tenant_id)
if not current_user.is_dataset_editor:
raise Forbidden()
# 403 if document is archived
if DocumentService.check_archived(document):
raise ArchivedDocumentImmutableError()
check_knowledge_rate_limit()
try:
# pause document
DocumentService.pause_document(document, session)
@@ -1404,26 +1442,31 @@ class DocumentRecoverApi(DocumentResource):
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Document resumed successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
def patch(self, session: Session, dataset_id: UUID, document_id: UUID):
def patch(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
dataset_id: UUID,
document_id: UUID,
):
"""recover document."""
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
if not dataset:
raise NotFound("Dataset not found.")
document = DocumentService.get_document(dataset.id, document_id_str, session=session)
# 404 if document not found
if document is None:
raise NotFound("Document Not Exists.")
document = self.get_document(session, dataset_id_str, document_id_str, current_user, current_tenant_id)
if not current_user.is_dataset_editor:
raise Forbidden()
# 403 if document is archived
if DocumentService.check_archived(document):
raise ArchivedDocumentImmutableError()
check_knowledge_rate_limit()
try:
# pause document
DocumentService.recover_document(document, session)
@@ -1438,22 +1481,42 @@ class DocumentRetryApi(DocumentResource):
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.expect(console_ns.models[DocumentRetryPayload.__name__])
@console_ns.response(204, "Documents retry started successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
def post(self, session: Session, dataset_id: UUID):
@model_validate(DocumentRetryPayload)
def post(
self,
req_data: DocumentRetryPayload,
session: Session,
current_tenant_id: str,
current_user: Account,
dataset_id: UUID,
):
"""retry document."""
payload = DocumentRetryPayload.model_validate(console_ns.payload or {})
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
retry_documents = []
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
if not dataset:
raise NotFound("Dataset not found.")
documents = DocumentService.get_documents_by_ids(dataset.id, payload.document_ids, session)
if not current_user.is_dataset_editor:
raise Forbidden()
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))
documents = DocumentService.get_documents_by_ids(
DatasetRefService.create_dataset_ref(dataset), req_data.document_ids, session
)
documents_by_id = {document.id: document for document in documents}
for document_id in payload.document_ids:
retry_documents = []
for document_id in req_data.document_ids:
try:
document = documents_by_id.get(document_id)
@@ -1473,6 +1536,7 @@ class DocumentRetryApi(DocumentResource):
logger.exception("Failed to retry document, document id: %s", document_id)
continue
# retry document
check_knowledge_rate_limit()
DocumentService.retry_document(dataset_id_str, retry_documents, session)
return "", 204
@@ -1488,7 +1552,15 @@ class DocumentRenameApi(DocumentResource):
@with_current_user
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
def post(self, session: Session, current_user: Account, dataset_id: UUID, document_id: UUID):
@model_validate(DocumentRenamePayload)
def post(
self,
req_data: DocumentRenamePayload,
session: Session,
current_user: Account,
dataset_id: UUID,
document_id: UUID,
):
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
if not current_user.is_dataset_editor:
raise Forbidden()
@@ -1496,10 +1568,9 @@ class DocumentRenameApi(DocumentResource):
if not dataset:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_operator_permission(current_user, dataset, session=session)
payload = DocumentRenamePayload.model_validate(console_ns.payload or {})
try:
document = DocumentService.rename_document(str(dataset_id), str(document_id), payload.name, session)
document = DocumentService.rename_document(str(dataset_id), str(document_id), req_data.name, session)
except services.errors.document.DocumentIndexingError:
raise DocumentIndexingError("Cannot delete document during indexing.")
@@ -1512,28 +1583,34 @@ class WebsiteDocumentSyncApi(DocumentResource):
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT)
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
def get(self, session: Session, current_tenant_id: str, dataset_id: UUID, document_id: UUID):
def get(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
dataset_id: UUID,
document_id: UUID,
):
"""sync website document."""
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.")
if not current_user.is_dataset_editor:
raise Forbidden()
document_id_str = str(document_id)
document = DocumentService.get_document(dataset.id, document_id_str, session=session)
if not document:
raise NotFound("Document not found.")
if document.tenant_id != current_tenant_id:
raise Forbidden("No permission.")
document = self.get_document(session, dataset.id, document_id_str, current_user, current_tenant_id)
if document.data_source_type != "website_crawl":
raise ValueError("Document is not a website document.")
# 403 if document is archived
if DocumentService.check_archived(document):
raise ArchivedDocumentImmutableError()
# sync document
DocumentService.sync_website_document(dataset_id_str, document, session)
DocumentService.sync_website_document(dataset, document, session)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@@ -1548,21 +1625,25 @@ class DocumentPipelineExecutionLogApi(DocumentResource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT)
@with_session(write=False)
def get(self, session: Session, dataset_id: UUID, document_id: UUID):
def get(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
dataset_id: UUID,
document_id: UUID,
):
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
if not dataset:
raise NotFound("Dataset not found.")
document = DocumentService.get_document(dataset.id, document_id_str, session=session)
if not document:
raise NotFound("Document not found.")
document = self.get_document(session, dataset_id_str, document_id_str, current_user, current_tenant_id)
log = session.scalar(
select(DocumentPipelineExecutionLog)
.where(DocumentPipelineExecutionLog.document_id == document_id_str)
.where(DocumentPipelineExecutionLog.document_id == document.id)
.order_by(DocumentPipelineExecutionLog.created_at.desc())
.limit(1)
)
@@ -1600,7 +1681,8 @@ class DocumentGenerateSummaryApi(Resource):
@with_current_user
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
def post(self, session: Session, current_user: Account, dataset_id: UUID):
@model_validate(GenerateSummaryPayload)
def post(self, req_data: GenerateSummaryPayload, session: Session, current_user: Account, dataset_id: UUID):
"""
Generate summary index for specified documents.
@@ -1624,9 +1706,7 @@ class DocumentGenerateSummaryApi(Resource):
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
# Validate request payload
payload = GenerateSummaryPayload.model_validate(console_ns.payload or {})
document_list = payload.document_list
document_list = req_data.document_list
if not document_list:
from werkzeug.exceptions import BadRequest
@@ -1645,7 +1725,9 @@ class DocumentGenerateSummaryApi(Resource):
raise ValueError("Summary index is not enabled for this dataset. Please enable it in the dataset settings.")
# Verify all documents exist and belong to the dataset
documents = DocumentService.get_documents_by_ids(dataset_id_str, document_list, session)
documents = DocumentService.get_documents_by_ids(
DatasetRefService.create_dataset_ref(dataset), document_list, session
)
if len(documents) != len(document_list):
found_ids = {doc.id for doc in documents}

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

@@ -42,7 +42,6 @@ from services.enterprise import rbac_service as enterprise_rbac_service
from services.entities.external_knowledge_entities.external_knowledge_entities import ExternalDatasetCreatePayload
from services.external_knowledge_service import ExternalDatasetService
from services.hit_testing_service import HitTestingService
from services.knowledge_service import BedrockRetrievalSetting, ExternalDatasetTestService
class ExternalKnowledgeApiPayload(BaseModel):
@@ -56,12 +55,6 @@ class ExternalHitTestingPayload(BaseModel):
metadata_filtering_conditions: dict[str, Any] | None = None
class BedrockRetrievalPayload(BaseModel):
retrieval_setting: BedrockRetrievalSetting
query: str
knowledge_id: str
class ExternalApiTemplateListQuery(BaseModel):
page: int = Field(default=1, description="Page number")
limit: int = Field(default=20, description="Number of items per page")
@@ -137,23 +130,11 @@ class ExternalHitTestingResponse(ResponseModel):
records: list[ExternalHitTestingRecordResponse]
class BedrockRetrievalRecordResponse(ResponseModel):
metadata: dict[str, Any] | None = None
score: float
title: str | None = None
content: str | None = None
class BedrockRetrievalResponse(ResponseModel):
records: list[BedrockRetrievalRecordResponse]
register_schema_models(
console_ns,
ExternalKnowledgeApiPayload,
ExternalDatasetCreatePayload,
ExternalHitTestingPayload,
BedrockRetrievalPayload,
ExternalApiTemplateListQuery,
)
register_response_schema_models(
@@ -166,8 +147,6 @@ register_response_schema_models(
ExternalHitTestingQueryResponse,
ExternalHitTestingRecordResponse,
ExternalHitTestingResponse,
BedrockRetrievalRecordResponse,
BedrockRetrievalResponse,
)
@@ -451,20 +430,3 @@ class ExternalKnowledgeHitTestingApi(Resource):
return dump_response(ExternalHitTestingResponse, response)
except Exception as e:
raise InternalServerError(str(e))
@console_ns.route("/test/retrieval")
class BedrockRetrievalApi(Resource):
# this api is only for internal testing
@console_ns.doc("bedrock_retrieval_test")
@console_ns.doc(description="Bedrock retrieval test (internal use only)")
@console_ns.expect(console_ns.models[BedrockRetrievalPayload.__name__])
@console_ns.response(200, "Bedrock retrieval test completed", console_ns.models[BedrockRetrievalResponse.__name__])
@model_validate(BedrockRetrievalPayload)
def post(self, req_data: BedrockRetrievalPayload):
# Call the knowledge retrieval service
result = ExternalDatasetTestService.knowledge_retrieval(
req_data.retrieval_setting, req_data.query, req_data.knowledge_id
)
return dump_response(BedrockRetrievalResponse, result), 200

View File

@@ -3,8 +3,10 @@ from uuid import UUID
from flask_restx import Resource
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
from controllers.common.controller_schemas import MetadataUpdatePayload
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.session import with_session
@@ -35,6 +37,7 @@ from services.entities.knowledge_entities.knowledge_entities import (
MetadataDetail,
MetadataOperationData,
)
from services.errors.metadata import MetadataResourceNotFoundError
from services.metadata_service import MetadataService
register_schema_models(
@@ -87,13 +90,20 @@ class DatasetMetadataCreateApi(Resource):
@console_ns.response(
200, "Metadata retrieved successfully", console_ns.models[DatasetMetadataListResponse.__name__]
)
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT)
@with_session(write=False)
def get(self, session: Session, dataset_id: UUID):
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
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 dataset is None:
raise NotFound("Dataset not found.")
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))
metadata = MetadataService.get_dataset_metadatas(dataset, session)
return dump_response(DatasetMetadataListResponse, metadata), 200
@@ -124,14 +134,12 @@ class DatasetMetadataApi(Resource):
dataset_id_str = str(dataset_id)
metadata_id_str = str(metadata_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user, session)
metadata = MetadataService.update_metadata_name(
dataset_id_str, metadata_id_str, name, current_user, current_tenant_id, session=session
)
metadata = MetadataService.update_metadata_name(dataset, metadata_id_str, name, current_user, session=session)
return dump_response(DatasetMetadataResponse, metadata), 200
@setup_required
@@ -140,17 +148,25 @@ class DatasetMetadataApi(Resource):
@enterprise_license_required
@console_ns.response(204, "Metadata deleted successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
def delete(self, session: Session, current_user: Account, dataset_id: UUID, metadata_id: UUID):
def delete(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
dataset_id: UUID,
metadata_id: UUID,
):
dataset_id_str = str(dataset_id)
metadata_id_str = str(metadata_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user, session)
MetadataService.delete_metadata(dataset_id_str, metadata_id_str, session)
MetadataService.delete_metadata(dataset, metadata_id_str, session)
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
return "", 204
@@ -208,18 +224,29 @@ class DocumentMetadataEditApi(Resource):
204,
"Documents metadata updated successfully",
)
@console_ns.response(404, "Dataset, document, or metadata not found")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
@model_validate(MetadataOperationData)
def post(self, req_data: MetadataOperationData, session: Session, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
def post(
self,
req_data: MetadataOperationData,
session: Session,
current_tenant_id: str,
current_user: Account,
dataset_id: UUID,
):
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), current_tenant_id, session=session)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user, session)
MetadataService.update_documents_metadata(dataset, req_data, current_user, session=session)
try:
MetadataService.update_documents_metadata(dataset, req_data, current_user, session=session)
except MetadataResourceNotFoundError as exc:
raise NotFound(str(exc)) from exc
# Frontend callers only await success and invalidate caches; no response body is consumed.
return "", 204

View File

@@ -30,6 +30,7 @@ from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.enums import PermissionEnum
from models.provider_ids import DatasourceProviderID
from services.datasource_provider_service import DatasourceProviderService
from services.plugin.oauth_service import OAuthProxyService
@@ -74,6 +75,13 @@ class DatasourceUpdateNamePayload(BaseModel):
class DatasourceOAuthAuthorizationQuery(BaseModel):
credential_id: str | None = Field(default=None, description="Credential ID to reauthorize")
visibility: str | None = Field(
default=None,
description=(
"Visibility for the credential to be created. Accepts 'only_me' or 'all_team_members'; "
"any other value falls back to 'only_me'. Ignored on reauthorization (credential_id set)."
),
)
class DatasourceOAuthCallbackQuery(BaseModel):
@@ -175,12 +183,27 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
if not oauth_config:
raise ValueError(f"No OAuth Client Config for {provider_id}")
# Visibility is chosen by the user in the frontend before the redirect,
# then read back in the callback below when the credential is created.
# Only ONLY_ME / ALL_TEAM are accepted; anything else falls back to
# ONLY_ME (OAuth tokens are personal by nature).
# For reauthorization (credential_id set), visibility is ignored — we
# keep whatever the credential was created with.
raw_visibility = request.args.get("visibility")
try:
requested_visibility = PermissionEnum(raw_visibility) if raw_visibility else PermissionEnum.ONLY_ME
except ValueError:
requested_visibility = PermissionEnum.ONLY_ME
if requested_visibility not in (PermissionEnum.ONLY_ME, PermissionEnum.ALL_TEAM):
requested_visibility = PermissionEnum.ONLY_ME
context_id = OAuthProxyService.create_proxy_context(
user_id=current_user.id,
tenant_id=tenant_id,
plugin_id=plugin_id,
provider=provider_name,
credential_id=credential_id,
extra_data={"visibility": requested_visibility.value},
)
oauth_handler = OAuthHandler()
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/datasource/callback"
@@ -253,6 +276,17 @@ class DatasourceOAuthCallback(Resource):
credential_id=credential_id,
)
else:
# Visibility was chosen by the user before the redirect and stashed
# in the proxy context. Fall back to ONLY_ME for older cookies (or
# anything that somehow wrote an unexpected value) — OAuth tokens
# are personal by default.
stored_visibility = context.get("visibility")
try:
visibility = PermissionEnum(stored_visibility) if stored_visibility else PermissionEnum.ONLY_ME
except ValueError:
visibility = PermissionEnum.ONLY_ME
if visibility not in (PermissionEnum.ONLY_ME, PermissionEnum.ALL_TEAM):
visibility = PermissionEnum.ONLY_ME
datasource_provider_service.add_datasource_oauth_provider(
tenant_id=tenant_id,
provider_id=datasource_provider_id,
@@ -260,6 +294,8 @@ class DatasourceOAuthCallback(Resource):
name=oauth_response.metadata.get("name") or None,
expire_at=oauth_response.expires_at,
credentials=dict(oauth_response.credentials),
user_id=user_id,
visibility=visibility,
)
return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback")
@@ -381,11 +417,12 @@ class DatasourceAuthListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str):
def get(self, current_tenant_id: str, user: Account):
datasource_provider_service = DatasourceProviderService()
datasources = datasource_provider_service.get_all_datasource_credentials(
tenant_id=current_tenant_id, session=db.session()
tenant_id=current_tenant_id, session=db.session(), user=user
)
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
@@ -400,11 +437,12 @@ class DatasourceHardCodeAuthListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str):
def get(self, current_tenant_id: str, user: Account):
datasource_provider_service = DatasourceProviderService()
datasources = datasource_provider_service.get_hard_code_datasource_credentials(
tenant_id=current_tenant_id, session=db.session()
tenant_id=current_tenant_id, session=db.session(), user=user
)
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
@@ -436,6 +474,8 @@ class DatasourceAuthOauthCustomClient(Resource):
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_tenant_id
def delete(self, current_tenant_id: str, provider_id: str):

View File

@@ -3,10 +3,10 @@ from typing import Any
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import NotFound
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.common.fields import SimpleDataResponse
from controllers.common.schema import (
JsonResponseWithStatus,
@@ -16,11 +16,15 @@ from controllers.common.schema import (
)
from controllers.console import console_ns
from controllers.console.app.wraps import with_session
from controllers.console.datasets.wraps import get_rag_pipeline
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
enterprise_license_required,
knowledge_pipeline_publish_enabled,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
@@ -30,8 +34,11 @@ from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from models.dataset import PipelineCustomizedTemplate
from models.dataset import Pipeline
from services.dataset_service import DatasetService
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity
from services.errors.account import NoPermissionError
from services.errors.rag_pipeline import RagPipelineResourceNotFoundError
from services.rag_pipeline.rag_pipeline import RagPipelineService
logger: logging.Logger = logging.getLogger(__name__)
@@ -125,15 +132,24 @@ class PipelineTemplateListApi(Resource):
class PipelineTemplateDetailApi(Resource):
@console_ns.doc(params=query_params_from_model(PipelineTemplateDetailQuery))
@console_ns.response(200, "Pipeline template", console_ns.models[PipelineTemplateDetailResponse.__name__])
@console_ns.response(404, "Pipeline template not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_session
@with_current_tenant_id
@with_session(write=False)
@model_validate(PipelineTemplateDetailQuery)
def get(self, req_data: PipelineTemplateDetailQuery, session: Session, template_id: str) -> JsonResponseWithStatus:
def get(
self,
req_data: PipelineTemplateDetailQuery,
session: Session,
current_tenant_id: str,
template_id: str,
) -> JsonResponseWithStatus:
pipeline_template = RagPipelineService.get_pipeline_template_detail(
template_id,
current_tenant_id,
type=req_data.type,
session=session,
)
@@ -181,38 +197,57 @@ class CustomizedPipelineTemplateApi(Resource):
@account_initialization_required
@enterprise_license_required
@console_ns.response(200, "Success", console_ns.models[SimpleDataResponse.__name__])
def post(self, template_id: str) -> JsonResponseWithStatus:
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
template = session.scalar(
select(PipelineCustomizedTemplate).where(PipelineCustomizedTemplate.id == template_id).limit(1)
@console_ns.response(404, "Customized pipeline template not found")
@with_current_tenant_id
@with_session(write=False)
def post(self, session: Session, current_tenant_id: str, template_id: str) -> JsonResponseWithStatus:
try:
yaml_content = RagPipelineService.get_customized_pipeline_template_yaml(
template_id, current_tenant_id, session=session
)
if not template:
raise ValueError("Customized pipeline template not found.")
except RagPipelineResourceNotFoundError as exc:
raise NotFound(str(exc)) from exc
return dump_response(SimpleDataResponse, {"data": template.yaml_content}), 200
return dump_response(SimpleDataResponse, {"data": yaml_content}), 200
@console_ns.route("/rag/pipelines/<string:pipeline_id>/customized/publish")
class PublishCustomizedPipelineTemplateApi(Resource):
@console_ns.expect(console_ns.models[CustomizedPipelineTemplatePayload.__name__])
@console_ns.response(204, "Pipeline template published")
@console_ns.response(404, "Pipeline, workflow, or dataset not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@knowledge_pipeline_publish_enabled
@with_current_user
@with_current_tenant_id
@get_rag_pipeline
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_PIPELINE_RELEASE)
@model_validate(CustomizedPipelineTemplatePayload)
def post(
self,
req_data: CustomizedPipelineTemplatePayload,
current_tenant_id: str,
current_user: Account,
pipeline_id: str,
pipeline: Pipeline,
) -> tuple[str, int]:
rag_pipeline_service = RagPipelineService(db.session())
rag_pipeline_service.publish_customized_pipeline_template(
pipeline_id, req_data.model_dump(), current_user, current_tenant_id, session=db.session()
)
session = db.session()
dataset = pipeline.retrieve_dataset(session=session)
if dataset is None:
raise NotFound("Dataset not found")
if not dify_config.RBAC_ENABLED:
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
try:
RagPipelineService.publish_customized_pipeline_template(
pipeline, dataset, req_data.model_dump(), current_user, session=session
)
except RagPipelineResourceNotFoundError as exc:
raise NotFound(str(exc)) from exc
return "", 204

View File

@@ -8,7 +8,6 @@ from flask import Response
from flask_restx import Resource, marshal, marshal_with
from pydantic import BaseModel, Field
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.common.schema import query_params_from_model, register_schema_models
@@ -19,12 +18,22 @@ from controllers.console.app.error import (
from controllers.console.app.workflow_draft_variable import (
_WORKFLOW_DRAFT_VARIABLE_FIELDS, # type: ignore[private-usage]
EnvironmentVariableListResponse,
ensure_variable_access,
workflow_draft_variable_list_model,
workflow_draft_variable_list_without_value_model,
workflow_draft_variable_model,
)
from controllers.console.datasets.wraps import get_rag_pipeline
from controllers.console.wraps import account_initialization_required, model_validate, setup_required, with_current_user
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_user,
)
from core.app.file_access import DatabaseFileAccessController
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, environment_variable_value_type
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
@@ -62,21 +71,21 @@ def _api_prerequisite[T, **P, R](
It ensures the following conditions are satisfied:
- Dify has been property setup.
- Dify has been properly set up.
- The request user has logged in and initialized.
- The requested app is a workflow or a chat flow.
- The request user has the edit permission for the app.
- The requested RAG pipeline belongs to the current tenant.
- The request user has legacy edit permission or Dataset edit RBAC access.
"""
@setup_required
@login_required
@account_initialization_required
@get_rag_pipeline
@edit_permission_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_current_user
@wraps(f)
def wrapper(self: T, current_user: Account, *args: P.args, **kwargs: P.kwargs) -> R | Response:
if not current_user.has_edit_permission:
raise Forbidden()
return f(self, current_user, *args, **kwargs)
return wrapper
@@ -180,16 +189,17 @@ class RagPipelineVariableApi(Resource):
@console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model)
@_api_prerequisite
@marshal_with(workflow_draft_variable_model)
def get(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID):
def get(self, current_user: Account, pipeline: Pipeline, variable_id: UUID):
draft_var_srv = WorkflowDraftVariableService(
session=db.session(),
)
variable_id_str = str(variable_id)
variable = draft_var_srv.get_variable(variable_id=variable_id_str)
if variable is None:
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
if variable.app_id != pipeline.id:
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
variable = ensure_variable_access(
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
app_id=pipeline.id,
variable_id=variable_id_str,
current_user_id=current_user.id,
)
return variable
@console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model)
@@ -200,7 +210,7 @@ class RagPipelineVariableApi(Resource):
def patch(
self,
req_data: WorkflowDraftVariablePatchPayload,
_current_user: Account,
current_user: Account,
pipeline: Pipeline,
variable_id: UUID,
):
@@ -231,11 +241,12 @@ class RagPipelineVariableApi(Resource):
args = req_data.model_dump(exclude_none=True)
variable_id_str = str(variable_id)
variable = draft_var_srv.get_variable(variable_id=variable_id_str)
if variable is None:
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
if variable.app_id != pipeline.id:
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
variable = ensure_variable_access(
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
app_id=pipeline.id,
variable_id=variable_id_str,
current_user_id=current_user.id,
)
new_name = args.get(self._PATCH_NAME_FIELD, None)
raw_value = args.get(self._PATCH_VALUE_FIELD, None)
@@ -272,16 +283,17 @@ class RagPipelineVariableApi(Resource):
@console_ns.response(204, "Variable deleted successfully")
@_api_prerequisite
def delete(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID):
def delete(self, current_user: Account, pipeline: Pipeline, variable_id: UUID):
draft_var_srv = WorkflowDraftVariableService(
session=db.session(),
)
variable_id_str = str(variable_id)
variable = draft_var_srv.get_variable(variable_id=variable_id_str)
if variable is None:
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
if variable.app_id != pipeline.id:
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
variable = ensure_variable_access(
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
app_id=pipeline.id,
variable_id=variable_id_str,
current_user_id=current_user.id,
)
draft_var_srv.delete_variable(variable)
db.session.commit()
return Response("", 204)
@@ -292,7 +304,7 @@ class RagPipelineVariableResetApi(Resource):
@console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model)
@console_ns.response(204, "Variable reset (no content)")
@_api_prerequisite
def put(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID):
def put(self, current_user: Account, pipeline: Pipeline, variable_id: UUID):
draft_var_srv = WorkflowDraftVariableService(
session=db.session(),
)
@@ -304,11 +316,12 @@ class RagPipelineVariableResetApi(Resource):
f"Draft workflow not found, pipeline_id={pipeline.id}",
)
variable_id_str = str(variable_id)
variable = draft_var_srv.get_variable(variable_id=variable_id_str)
if variable is None:
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
if variable.app_id != pipeline.id:
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
variable = ensure_variable_access(
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
app_id=pipeline.id,
variable_id=variable_id_str,
current_user_id=current_user.id,
)
resetted = draft_var_srv.reset_variable(draft_workflow, variable)
db.session.commit()

View File

@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
from configs import dify_config
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
@@ -60,8 +61,11 @@ 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
from services.errors.rag_pipeline import RagPipelineResourceNotFoundError
from services.rag_pipeline.pipeline_generate_service import PipelineGenerateService
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.rag_pipeline.rag_pipeline_manage_service import RagPipelineManageService
@@ -767,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.
"""
@@ -780,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,
)
@@ -791,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
@@ -1016,19 +1026,31 @@ class RagPipelineWorkflowLastRunApi(Resource):
@console_ns.route("/rag/pipelines/transform/datasets/<uuid:dataset_id>")
class RagPipelineTransformApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@console_ns.response(404, "Dataset or pipeline not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_session
def post(self, session: Session, current_user: Account, dataset_id: UUID):
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
raise Forbidden()
def post(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), current_tenant_id, session=session)
if dataset is None:
raise NotFound("Dataset not found.")
dataset_id_str = str(dataset_id)
rag_pipeline_transform_service = RagPipelineTransformService()
result = rag_pipeline_transform_service.transform_dataset(dataset_id_str, session)
return result
if not dify_config.RBAC_ENABLED:
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
try:
return RagPipelineTransformService().transform_dataset(dataset, current_user.id, session)
except RagPipelineResourceNotFoundError as exc:
raise NotFound(str(exc)) from exc
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/datasource/variables-inspect")

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

@@ -3,17 +3,14 @@ from typing import cast
from flask_restx import Namespace, Resource
from pydantic import BaseModel, Field, RootModel, field_validator
from sqlalchemy import select
from controllers.common.schema import query_params_from_model, register_response_schema_models
from controllers.console import api
from controllers.console.wraps import model_validate
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 models.enums import BannerStatus
from models.model import ExporleBanner
from services.feature_service import FeatureService
class BannerListQuery(BaseModel):
@@ -67,23 +64,7 @@ class BannerApi(Resource):
@model_validate(BannerListQuery)
def get(self, req_data: BannerListQuery):
"""Get banner list."""
if not FeatureService.is_explore_banner_enabled():
return dump_response(BannerListResponse, [])
# Build base query for enabled banners
base_query = select(ExporleBanner).where(ExporleBanner.status == BannerStatus.ENABLED)
# Try to get banners in the requested language
banners = db.session.scalars(
base_query.where(ExporleBanner.language == req_data.language).order_by(ExporleBanner.sort)
).all()
# Fallback to en-US if no banners found and language is not en-US
if not banners and req_data.language != "en-US":
banners = db.session.scalars(
base_query.where(ExporleBanner.language == "en-US").order_by(ExporleBanner.sort)
).all()
banners = application_services().explore_banner_queries.list_for_language(req_data.language)
return dump_response(BannerListResponse, banners)

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

@@ -1,16 +1,16 @@
from typing import Any, cast
from typing import Any
from pydantic import BaseModel, Field
from controllers.common import fields
from controllers.common.fields import Parameters
from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.app.error import AppUnavailableError
from controllers.console.explore.wraps import InstalledAppResource
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from extensions.ext_database import db
from models.model import AppMode, InstalledApp, load_annotation_reply_config
from services.app_service import AppService
from extensions.ext_application_services import application_services
from libs.helper import dump_response
from models.model import InstalledApp
from services.app_definition_query_service import AppDefinitionUnavailableError
class ExploreAppMetaResponse(BaseModel):
@@ -22,44 +22,22 @@ class ExploreAppMetaResponse(BaseModel):
tool_icons: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
register_response_schema_models(console_ns, fields.Parameters, ExploreAppMetaResponse)
register_response_schema_models(console_ns, Parameters, ExploreAppMetaResponse)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/parameters", endpoint="installed_app_parameters")
class AppParameterApi(InstalledAppResource):
"""Resource for app variables."""
@console_ns.response(200, "Success", console_ns.models[fields.Parameters.__name__])
@console_ns.response(200, "Success", console_ns.models[Parameters.__name__])
def get(self, installed_app: InstalledApp):
"""Retrieve app parameters."""
session = db.session()
app_model = installed_app.app_with_session(session=session)
try:
parameters = application_services().app_definitions.get_parameters(installed_app.app_id)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
if app_model is None:
raise AppUnavailableError()
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow_with_session(session=session)
if workflow is None:
raise AppUnavailableError()
features_dict: dict[str, Any] = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config_with_session(session=session)
if app_model_config is None:
raise AppUnavailableError()
annotation_reply = load_annotation_reply_config(session, app_model.id)
features_dict = cast(
dict[str, Any],
app_model_config.to_dict(annotation_reply=annotation_reply),
)
user_input_form = features_dict.get("user_input_form", [])
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
return fields.Parameters.model_validate(parameters).model_dump(mode="json")
return dump_response(Parameters, parameters)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/meta", endpoint="installed_app_meta")
@@ -67,7 +45,12 @@ class ExploreAppMetaApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ExploreAppMetaResponse.__name__])
def get(self, installed_app: InstalledApp):
"""Get app meta"""
app_model = installed_app.app_with_session(session=db.session())
if not app_model:
raise ValueError("App not found")
return AppService().get_app_meta(app_model, session=db.session())
try:
tool_icons = application_services().app_definitions.get_tool_icons(installed_app.app_id)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
return dump_response(
ExploreAppMetaResponse,
{"tool_icons": tool_icons},
)

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

@@ -1,5 +1,4 @@
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal
@@ -39,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,
@@ -51,7 +50,6 @@ from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_reque
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file_from_request
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate, with_current_user
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
@@ -61,6 +59,7 @@ from core.errors.error import (
)
from core.helper import encrypter
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
@@ -74,9 +73,10 @@ from libs import helper
from libs.helper import dump_response, to_timestamp, uuid_value
from models import Account, App
from models.account import TenantStatus
from models.model import AppMode, Site, load_annotation_reply_config
from models.model import AppMode, Site
from models.workflow import Workflow
from services.account_service import TenantService
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_generate_service import AppGenerateService
from services.app_ref_service import AppRefService
from services.app_service import AppResponseView, AppService
@@ -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,39 +847,25 @@ 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."""
if app_model is None:
raise AppUnavailableError()
features_dict: Mapping[str, Any]
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow_with_session(session=session)
if workflow is None:
raise AppUnavailableError()
try:
parameters = application_services().app_definitions.get_parameters(app_model.id)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
features_dict = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config_with_session(session=session)
if app_model_config is None:
raise AppUnavailableError()
annotation_reply = load_annotation_reply_config(session, app_model_config.app_id)
features_dict = app_model_config.to_dict(annotation_reply=annotation_reply)
user_input_form = features_dict.get("user_input_form", [])
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
return ParametersResponse.model_validate(parameters).model_dump(mode="json")
return dump_response(ParametersResponse, parameters)
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"""
@@ -896,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:
@@ -916,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

@@ -5,19 +5,28 @@ from functools import wraps
from typing import Concatenate
from flask import Response, abort, request
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 core.logging.context import get_request_id, get_trace_id
from enums.deployment_edition import DeploymentEdition
from core.rbac import RBACPermission, RBACResourceScope
from enums import DeploymentEdition
from libs.login import current_account_with_tenant, login_required
from machinery.context import RequestContext
from machinery.errors import AdmissionConfigurationError
from models.account import TenantAccountRole
def console_account_admission[T, **P, R](
*,
editions: frozenset[DeploymentEdition] | None = None,
require_valid_enterprise_license: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac_resource_scope: RBACResourceScope | None = None,
rbac_permission: RBACPermission | None = None,
rbac_resource_required: bool = True,
) -> Callable[
[Callable[Concatenate[T, RequestContext, P], R]],
Callable[Concatenate[T, P], R | Response],
@@ -26,18 +35,35 @@ def console_account_admission[T, **P, R](
All combinations use this decorator factory. Requirements are data, while
the execution order stays fixed: edition, setup, login/CSRF, account
initialization, optional enterprise license, then context construction.
initialization, optional enterprise license, role/RBAC checks, then context
construction.
"""
if (rbac_resource_scope is None) != (rbac_permission is None):
raise AdmissionConfigurationError("RBAC resource scope and permission must be configured together")
def decorator(
view: Callable[Concatenate[T, RequestContext, P], R],
) -> Callable[Concatenate[T, P], R | Response]:
@wraps(view)
def inject_request_context(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
account_with_tenant = current_account_with_tenant()
account = account_with_tenant.account
tenant_id = account_with_tenant.tenant_id
if allowed_roles is not None and not dify_config.RBAC_ENABLED and account.role not in allowed_roles:
raise Forbidden()
if rbac_resource_scope is not None and rbac_permission is not None:
enforce_rbac_access(
tenant_id=tenant_id,
account_id=account.id,
resource_type=rbac_resource_scope,
scene=rbac_permission,
resource_required=rbac_resource_required,
path_args=kwargs,
)
request_context = RequestContext(
account_id=account_with_tenant.account.id,
active_workspace_id=account_with_tenant.tenant_id,
account_id=account.id,
active_workspace_id=tenant_id,
request_id=get_request_id(),
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
)

View File

@@ -1,17 +1,11 @@
import os
from typing import Literal
from flask import session
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from controllers.fastopenapi import console_router
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from models.model import DifySetup
from services.account_service import TenantService
from extensions.ext_application_services import application_services
from services.init_validation_service import AlreadyInitializedError, InvalidInitializationPasswordError
from .error import AlreadySetupError, InitValidateFailedError
from .wraps import only_edition_self_hosted
@@ -36,7 +30,7 @@ class InitValidateResponse(BaseModel):
)
def get_init_status() -> InitStatusResponse:
"""Get initialization validation status."""
init_status = get_init_validate_status()
init_status = is_init_validated()
if init_status:
return InitStatusResponse(status="finished")
return InitStatusResponse(status="not_started")
@@ -51,25 +45,19 @@ def get_init_status() -> InitStatusResponse:
@only_edition_self_hosted
def validate_init_password(payload: InitValidatePayload) -> InitValidateResponse:
"""Validate initialization password."""
tenant_count = TenantService.get_tenant_count(session=db.session())
if tenant_count > 0:
raise AlreadySetupError()
if payload.password != os.environ.get("INIT_PASSWORD"):
try:
application_services().init_validation.validate_password(payload.password)
except AlreadyInitializedError:
raise AlreadySetupError() from None
except InvalidInitializationPasswordError:
session["is_init_validated"] = False
raise InitValidateFailedError()
raise InitValidateFailedError() from None
session["is_init_validated"] = True
return InitValidateResponse(result="success")
def get_init_validate_status() -> bool:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
if os.environ.get("INIT_PASSWORD"):
if session.get("is_init_validated"):
return True
with Session(db.engine) as db_session:
return db_session.execute(select(DifySetup)).scalar_one_or_none() is not None
return True
def is_init_validated() -> bool:
return application_services().init_validation.is_validated(
session_validated=bool(session.get("is_init_validated")),
)

View File

@@ -1,17 +0,0 @@
from pydantic import BaseModel, Field
from controllers.fastopenapi import console_router
class PingResponse(BaseModel):
result: str = Field(description="Health check result", examples=["pong"])
@console_router.get(
"/ping",
response_model=PingResponse,
tags=["console"],
)
def ping() -> PingResponse:
"""Health check endpoint for connection testing."""
return PingResponse(result="pong")

View File

@@ -14,7 +14,7 @@ from services.setup_service import (
)
from .error import AlreadySetupError, NotInitValidateError
from .init_validate import get_init_validate_status
from .init_validate import is_init_validated
from .wraps import mark_setup_completed, only_edition_self_hosted
@@ -73,7 +73,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
"""Initialize system setup with admin account.
NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
Access is restricted to self-hosted editions (`COMMUNITY` and `ENTERPRISE`), one-time setup guards,
and init-password validation rather than user session authentication.
"""
try:
@@ -85,7 +85,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
ip_address=extract_remote_ip(request),
language=payload.language,
),
initialization_validated=get_init_validate_status(),
initialization_validated=is_init_validated(),
)
except SetupAlreadyCompletedError:
raise AlreadySetupError() from None

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

@@ -10,21 +10,27 @@ from controllers.fastopenapi import console_router
logger = logging.getLogger(__name__)
class PingResponse(BaseModel):
result: str = Field(description="Health check result", examples=["pong"])
class VersionQuery(BaseModel):
current_version: str = Field(..., description="Current application version")
class VersionFeatures(BaseModel):
can_replace_logo: bool = Field(description="Whether logo replacement is supported")
model_load_balancing_enabled: bool = Field(description="Whether model load balancing is enabled")
class VersionResponse(BaseModel):
version: str = Field(description="Latest version number")
release_date: str = Field(description="Release date of latest version")
release_notes: str = Field(description="Release notes for latest version")
can_auto_update: bool = Field(description="Whether auto-update is supported")
features: VersionFeatures = Field(description="Feature flags and capabilities")
@console_router.get(
"/ping",
response_model=PingResponse,
tags=["console"],
)
def ping() -> PingResponse:
"""Health check endpoint for connection testing."""
return PingResponse(result="pong")
@console_router.get(
@@ -38,13 +44,7 @@ def check_version_update(query: VersionQuery) -> VersionResponse:
result = VersionResponse(
version=dify_config.project.version,
release_date="",
release_notes="",
can_auto_update=False,
features=VersionFeatures(
can_replace_logo=dify_config.CAN_REPLACE_LOGO,
model_load_balancing_enabled=dify_config.MODEL_LB_ENABLED,
),
)
if not check_update_url:
@@ -62,11 +62,9 @@ def check_version_update(query: VersionQuery) -> VersionResponse:
result.version = query.current_version
return result
latest_version = content.get("version", result.version)
if _has_new_version(latest_version=latest_version, current_version=f"{query.current_version}"):
if _has_new_version(latest_version=latest_version, current_version=query.current_version):
result.version = latest_version
result.release_date = content.get("releaseDate", "")
result.release_notes = content.get("releaseNotes", "")
result.can_auto_update = content.get("canAutoUpdate", False)
return result
@@ -75,7 +73,6 @@ def _has_new_version(*, latest_version: str, current_version: str) -> bool:
latest = version.parse(latest_version)
current = version.parse(current_version)
# Compare versions
return latest > current
except version.InvalidVersion:
logger.warning("Invalid version format: latest=%s, current=%s", latest_version, current_version)

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"] = 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

@@ -11,7 +11,6 @@ from controllers.common.schema import register_response_schema_models, register_
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_enabled,
cloud_edition_billing_paid_plan_required,
model_validate,
only_edition_cloud,
@@ -122,7 +121,6 @@ class WorkflowRunArchivesApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
def get(self):
tenant_id, _ = _current_owner_or_admin_ids()
@@ -143,7 +141,6 @@ class WorkflowRunArchiveDownloadsApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@model_validate(WorkflowRunArchiveDownloadPayload)
def post(self, req_data: WorkflowRunArchiveDownloadPayload):
@@ -170,7 +167,6 @@ class WorkflowRunArchiveDownloadApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
def get(self, download_id: str):
tenant_id, _ = _current_owner_or_admin_ids()
@@ -195,7 +191,6 @@ class WorkflowRunArchiveDownloadFileApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
def get(self, download_id: str):
tenant_id, _ = _current_owner_or_admin_ids()

View File

@@ -2,12 +2,13 @@ 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 pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.json_schema import SkipJsonSchema
from sqlalchemy import select
from werkzeug.exceptions import NotFound
@@ -28,7 +29,13 @@ from controllers.console.auth.error import (
InvalidEmailError,
InvalidTokenError,
)
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
from controllers.console.error import (
AccountInFreezeError,
AccountNotFound,
EmailDomainSuspendedError,
EmailSendIpLimitError,
)
from controllers.console.flask_admission import console_account_admission
from controllers.console.workspace.error import (
AccountAlreadyInitedError,
CurrentPasswordIncorrectError,
@@ -38,7 +45,6 @@ from controllers.console.workspace.error import (
)
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_enabled,
enable_change_email,
enterprise_license_required,
model_validate,
@@ -46,7 +52,8 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountResponse
@@ -54,12 +61,15 @@ 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 machinery.context import RequestContext
from models import Account, AccountIntegrate, InvitationCode
from models.account import AccountStatus, InvitationCodeStatus
from models.enums import CreatorUserRole
from models.model import UploadFile
from services import account_errors
from services.account_service import AccountService
from services.billing_service import BillingService
from services.entities.account_entities import AccountProfileChanges
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
ChangeEmailNewEmailVerifiedToken,
@@ -119,6 +129,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
@@ -184,6 +230,7 @@ register_schema_models(
AccountInterfaceLanguagePayload,
AccountInterfaceThemePayload,
AccountTimezonePayload,
AccountProfilePatchPayload,
AccountPasswordPayload,
AccountDeletePayload,
AccountDeletionFeedbackPayload,
@@ -249,6 +296,14 @@ 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__])
@@ -306,21 +361,28 @@ class AccountProfileApi(Resource):
def get(self, current_user: Account):
return dump_response(AccountResponse, current_user)
@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")
@@ -351,73 +413,69 @@ class AccountAvatarApi(Resource):
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")
@@ -536,7 +594,6 @@ class EducationVerifyApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationVerifyResponse.__name__])
@with_current_user
def get(self, account: Account):
@@ -554,7 +611,6 @@ class EducationApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@with_current_user
def post(self, account: Account):
payload = console_ns.payload or {}
@@ -567,7 +623,6 @@ class EducationApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__])
@with_current_user
def get(self, account: Account):
@@ -585,7 +640,6 @@ class EducationAutoCompleteApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
def get(self):
payload = request.args.to_dict(flat=True)
@@ -720,7 +774,10 @@ class ChangeEmailResetApi(Resource):
args = ChangeEmailResetPayload.model_validate(payload)
normalized_new_email = args.new_email.lower()
if AccountService.is_account_in_freeze(normalized_new_email):
freeze_type = AccountService.get_account_freeze_type(normalized_new_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_new_email, session=db.session()):
@@ -767,7 +824,10 @@ class CheckEmailUnique(Resource):
payload = console_ns.payload or {}
args = CheckEmailUniquePayload.model_validate(payload)
normalized_email = args.email.lower()
if AccountService.is_account_in_freeze(normalized_email):
freeze_type = AccountService.get_account_freeze_type(normalized_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_email, session=db.session()):
raise EmailAlreadyInUseError()

View File

@@ -32,6 +32,7 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from extensions.ext_redis import redis_client
@@ -179,7 +180,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
features = FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True)
if dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
workspace_members = features.workspace_members
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
raise WorkspaceMembersLimitExceeded()
@@ -189,7 +190,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
raise SeatsLimitExceeded()
return
if dify_config.BILLING_ENABLED and features.billing.enabled is True:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
members = features.members
current_member_count = _count_current_members(tenant_id)
if 0 < members.limit < current_member_count + new_member_count:
@@ -265,7 +266,10 @@ class MemberInviteEmailApi(Resource):
tenant_id = inviter.current_tenant.id
with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60):
if dify_config.ENTERPRISE_ENABLED is True or dify_config.BILLING_ENABLED is True:
if dify_config.DEPLOYMENT_EDITION in {
DeploymentEdition.CLOUD,
DeploymentEdition.ENTERPRISE,
}:
new_member_count, new_account_count = _count_new_member_invites(tenant_id, invitee_emails)
_check_member_invite_limits(tenant_id, new_member_count, new_account_count)

View File

@@ -209,6 +209,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):

View File

@@ -349,6 +349,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

@@ -14,6 +14,7 @@ from controllers.console import console_ns
from controllers.console.wraps import RBACPermission, RBACResourceScope, model_validate, rbac_permission_required
from core.db.session_factory import session_factory
from core.rbac import RBACResourceWhitelistScope
from enums import DeploymentEdition
from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import Account
@@ -316,14 +317,16 @@ class RBACRolesApi(Resource):
options = req_data.to_inner_options()
if not dify_config.RBAC_ENABLED:
result = _legacy_workspace_roles(
options, include_owner=req_data.include_owner, billing_enabled=dify_config.BILLING_ENABLED
options,
include_owner=req_data.include_owner,
billing_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD,
)
else:
result = svc.RBACService.Roles.list(
tenant_id,
account_id,
include_owner=req_data.include_owner,
biiling_enabled=dify_config.BILLING_ENABLED,
biiling_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD,
options=options,
)
@@ -351,7 +354,12 @@ class RBACRoleItemApi(Resource):
def get(self, role_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.Roles.get(tenant_id, account_id, role_id, billing_enabled=dify_config.BILLING_ENABLED)
svc.RBACService.Roles.get(
tenant_id,
account_id,
role_id,
billing_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD,
)
)
@login_required
@@ -719,14 +727,17 @@ class RBACDatasetWhitelistApi(Resource):
def put(self, dataset_id):
tenant_id, account_id = _current_ids()
request = _payload(_ResourceAccessScopeRequest)
return _dump(
svc.RBACService.DatasetAccess.replace_whitelist(
tenant_id,
account_id,
str(dataset_id),
svc.ReplaceMemberBindings(scope=request.scope.value),
)
result = svc.RBACService.DatasetAccess.replace_whitelist(
tenant_id,
account_id,
str(dataset_id),
svc.ReplaceMemberBindings(scope=request.scope.value),
)
# Widening the scope only records it: the members still need the default access policy
# before they can reach the dataset, same as the app whitelist route above.
if dify_config.RBAC_ENABLED and request.scope is RBACResourceWhitelistScope.ALL:
initialize_created_app_rbac_access_task.delay(tenant_id, account_id, dataset_id=str(dataset_id))
return _dump(result)
@console_ns.route("/workspaces/current/rbac/datasets/<uuid:dataset_id>/user-access-policies")

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