Compare commits

...

240 Commits

Author SHA1 Message Date
Eddy ZHANG
e7d9c8897a refactor(api): enable strict typing for trace ID helper tests (#43611) 2026-10-07 03:22:56 +00:00
huhupy
519ef8b8c2 fix(web): share query-core in vinext development (#43269) 2026-10-06 14:04:30 +00:00
Lucien
0cd3824608 fix(nodejs-client): avoid retrying write requests after transport failures (#43079) 2026-10-06 14:03:35 +00:00
Lucien
869c1dbf07 fix(php-sdk): send conversation renames to the name endpoint (#43075) 2026-10-06 13:57:09 +00:00
lin-hongkuan
2738bad1fe fix(api): keep workflow log cleanup in one transaction (#37897)
Co-authored-by: Byron Wang <byron@dify.ai>
2026-10-06 13:34:26 +00:00
dependabot[bot]
a4cefbe669 chore(deps): bump the storage group in /api with 4 updates (#43500)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 13:21:03 +00:00
dependabot[bot]
1f5afb5a48 chore(deps): bump pypdf from 6.16.1 to 6.19.0 in /dify-agent (#43463)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 13:18:50 +00:00
dependabot[bot]
9d285e38eb chore(deps): bump pypdf from 6.16.1 to 6.19.0 in /api (#43464)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 13:17:42 +00:00
dependabot[bot]
9ce90a4075 chore(deps): bump the google group in /api with 2 updates (#43495)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 13:17:23 +00:00
Asuka Minato
a5eea69efc test: construct configured SQLAlchemy repositories (#43384) 2026-10-06 12:31:31 +00:00
Asuka Minato
8260acf04b test: use real model configuration entities (#43383) 2026-10-06 12:31:30 +00:00
Asuka Minato
5ea0b59bdd test: exercise real agent model assembly (#43382) 2026-10-06 12:31:29 +00:00
Asuka Minato
0753e6480b test: use real Watercrawl and Jina responses (#43381) 2026-10-06 12:31:28 +00:00
Asuka Minato
1aeba1c6bc test: use concrete agent resource task boundaries (#43380) 2026-10-06 11:54:07 +00:00
Asuka Minato
642bfe40eb test: use real app describe fixtures (#43379) 2026-10-06 11:54:06 +00:00
Asuka Minato
10c5da16f3 test: use real firecrawl auth responses (#43378) 2026-10-06 11:54:05 +00:00
Asuka Minato
3b093f881f test: use concrete notion import adapters (#43377)
Co-authored-by: Codex <codex@openai.com>
2026-10-06 11:54:03 +00:00
Eddy ZHANG
a212b1b0b1 refactor(api): enable strict typing for Firecrawl extractor tests (#43599) 2026-10-06 09:25:12 +00:00
Byron.wang
8023189566 fix: use caller session for Qdrant annotation bindings (#43543) 2026-10-06 07:58:58 +00:00
Eddy ZHANG
d73c93d2cf refactor(api): enable strict typing for CSV sanitizer tests (#43598) 2026-10-06 06:09:48 +00:00
非法操作
63d2af7d27 refactor(api): use project HTTP errors in Explore controllers (#43481)
Co-authored-by: Byron.wang <byron@dify.ai>
2026-10-05 15:23:32 +00:00
KenXiong
81f627cfa1 refactor(api): decouple console API-based extension management (#43340)
Co-authored-by: !潤弘資訊熊冠詠 <RT025232@rtc.ruentex.corp>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 15:04:09 +00:00
Asuka Minato
e160419159 test: use real data source auth gateways (#43376) 2026-10-05 14:21:54 +00:00
Asuka Minato
22cce965f3 test: use real web passport gateways (#43375) 2026-10-05 14:21:53 +00:00
Asuka Minato
ea9a3dbb4b test: use real workflow file runtime boundaries (#43374) 2026-10-05 14:21:52 +00:00
Asuka Minato
fd99ce64c9 test: use concrete archive adapter boundaries (#43373) 2026-10-05 14:21:51 +00:00
Asuka Minato
64f028564e test: use concrete parent context boundaries (#43372)
Co-authored-by: Codex <codex@openai.com>
2026-10-05 14:21:50 +00:00
Asuka Minato
4214abf28f test: use concrete agent account boundaries (#43371) 2026-10-05 14:21:49 +00:00
Asuka Minato
f4027ad6ef test: use concrete workflow tool recorders (#43370) 2026-10-05 14:21:48 +00:00
Asuka Minato
9d01361c91 test: use real tool variable pools and encryption (#43368) 2026-10-05 14:21:47 +00:00
Asuka Minato
7d13952e24 test: use real tool runtime callbacks (#43367) 2026-10-05 14:21:46 +00:00
Eddy ZHANG
54486a14c1 refactor(api): enable strict typing for dataset docstore tests (#43511) 2026-10-05 09:13:41 +00:00
Eddy ZHANG
3be01cbb7a refactor(api): enable strict typing for website crawl controller tests (#43507) 2026-10-05 06:29:18 +00:00
Eddy ZHANG
de09a17ba9 refactor(api): enable strict typing for RAG pipeline wraps tests (#43501) 2026-10-05 03:06:18 +00:00
dependabot[bot]
cf957564d8 chore(deps): bump the github-actions-dependencies group with 2 updates (#43493)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-05 03:05:36 +00:00
dependabot[bot]
5c0c9dfff0 chore(deps): bump opentelemetry-propagator-b3 from 1.44.0 to 1.45.0 in /api in the opentelemetry group (#43497)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-05 01:39:10 +00:00
Eddy ZHANG
e92d13220f refactor(api): enable strict typing for service API hit testing tests (#43485) 2026-10-05 00:30:13 +00:00
Asuka Minato
70ef9cbd5d test: use real dataset tool callbacks and sessions (#43366) 2026-10-04 11:38:25 +00:00
Asuka Minato
f0266537cb test: use real tool model and encryption fixtures (#43365) 2026-10-04 11:38:24 +00:00
Asuka Minato
16bbfc50bd test: use real HTTP responses and plugin tool clients (#43364) 2026-10-04 11:38:23 +00:00
Asuka Minato
1cbb6dc385 test: guard spec-based mock constructors with ast-grep (#43232)
Co-authored-by: Codex <codex@openai.com>
2026-10-03 15:44:24 +00:00
Asuka Minato
09e372b237 test: use real object storage SDK responses (#43231) 2026-10-03 15:44:24 +00:00
Asuka Minato
c687d0dc6f test: exercise collaboration leader and session storage (#43230) 2026-10-03 15:44:23 +00:00
Asuka Minato
8222397ea9 test: use real collaboration visibility state (#43229) 2026-10-03 10:12:10 +00:00
Asuka Minato
01f632d059 test: use real collaboration request state (#43228) 2026-10-03 10:12:10 +00:00
Asuka Minato
98c9a6a23a test: use real human input stream dependencies (#43227) 2026-10-03 10:12:09 +00:00
Asuka Minato
c12c01b8be test: persist human input event forms in SQLite (#43226) 2026-10-03 10:12:08 +00:00
Asuka Minato
7f4e357418 test: use real email registration adapters (#43225)
Co-authored-by: Codex <codex@openai.com>
2026-10-03 10:12:07 +00:00
Asuka Minato
43af5082d3 test: use real account change email adapters (#43224) 2026-10-03 03:16:11 +00:00
Asuka Minato
f2755a79c7 test: use real account services in controller fixtures (#43223) 2026-10-03 03:16:10 +00:00
Asuka Minato
e6e4cd9406 test: use concrete integration execution fixtures (#43222) 2026-10-03 03:16:09 +00:00
Asuka Minato
b7bf3f8a29 test: use real rerank model instances (#43221) 2026-10-03 03:16:09 +00:00
Asuka Minato
0f611811c2 test: use real pipeline and retriever fixtures (#43220) 2026-10-03 01:51:56 +00:00
Stream
efcf73b4da fix(test): reset shared Redis mock method history (#43437) 2026-10-03 01:13:46 +00:00
Stream
daadf78c02 test(account): strengthen Redis command assertions (#43436) 2026-10-03 01:12:58 +00:00
-LAN-
baf6888007 test(api): stabilize shared fixtures and type-check scopes (#42801) 2026-10-02 23:48:13 +00:00
-LAN-
4b89972f55 fix(redis): reject subscription close signals when receiving (#42799) 2026-10-02 18:50:56 +00:00
Chengmian Liu
a2427050e1 fix(api): survive DST transitions when computing the next cron run (#43357) 2026-10-02 18:08:31 +00:00
Asuka Minato
f3074ced5b test: use real statistic query repositories (#43219) 2026-10-02 13:53:39 +00:00
Asuka Minato
1dbf027277 test: use real account activation adapters (#43218) 2026-10-02 13:53:38 +00:00
Asuka Minato
9f0e3a4c17 test: use real account deletion adapters (#43217) 2026-10-02 13:53:37 +00:00
Asuka Minato
a54589a672 test: compose real datasource credential repositories (#43216) 2026-10-02 13:53:36 +00:00
Asuka Minato
ba28e3ad82 test: use real stream subscriptions for workflow dispatch (#43213) 2026-10-02 13:53:35 +00:00
Asuka Minato
49a4a13846 test: exercise real file uploads and web app icon queries (#43212) 2026-10-02 13:53:33 +00:00
Asuka Minato
934427ce16 test: use concrete knowledge node adapters and source records (#43211) 2026-10-02 13:53:32 +00:00
Asuka Minato
f100adf0ea test: use concrete workflow composition dependencies (#43210) 2026-10-02 13:53:31 +00:00
kr1shna-exe
63094c91ba refactor(api): enable strict typing for migration tests (#43347) 2026-10-02 13:12:13 +00:00
kr1shna-exe
9f617f9d3f refactor(api): enable strict typing for agent controller tests (#43342) 2026-10-02 13:07:45 +00:00
KenXiong
30eadcc9e5 refactor(api): adopt with_session in audio controllers (#37403) (#43322)
Co-authored-by: !潤弘資訊熊冠詠 <RT025232@rtc.ruentex.corp>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 13:03:06 +00:00
Asuka Minato
bf62165e22 test: use real plugin upload ownership and storage adapters (#43209) 2026-10-02 12:21:08 +00:00
Asuka Minato
c797b8e99e test: deliver real uploaded and tool files from local storage (#43208) 2026-10-02 12:21:07 +00:00
Asuka Minato
fa4f49df00 test: construct real repositories and Redis composition fixtures (#43207) 2026-10-02 12:21:06 +00:00
Asuka Minato
6cca398f1c test: use real repositories for human input node fixtures (#43206) 2026-10-02 12:21:05 +00:00
Asuka Minato
b4ae43986c test: persist human input forms across graph pause and resume (#43205) 2026-10-02 12:21:04 +00:00
Asuka Minato
c05a1c4e9a test: persist real human input forms in callback tests (#43204) 2026-10-02 11:59:33 +00:00
Asuka Minato
84cc8ef77a test: use persisted message files and local preview storage (#43203) 2026-10-02 11:59:32 +00:00
Asuka Minato
f2e13873b5 test: use concrete model instances in LLM utility tests (#43202) 2026-10-02 11:59:31 +00:00
Asuka Minato
a0c004d877 test: exercise onboarding retries with real database sessions (#43200) 2026-10-02 11:59:30 +00:00
Asuka Minato
14cd89ded5 test: persist account initialization and avatar ownership fixtures (#43199) 2026-10-02 11:59:29 +00:00
Asuka Minato
8055521b49 test: exercise real compliance and deletion rate limiters (#43198) 2026-10-02 11:03:16 +00:00
Asuka Minato
aff59ef19a test: use real LLM provider dependencies and recording file saver (#43197) 2026-10-02 11:03:05 +00:00
Asuka Minato
f93bec4ea5 test: use concrete LLM execution dependencies (#43195) 2026-10-02 11:03:04 +00:00
Asuka Minato
087dd1d1ae test: construct real dependencies for graph test nodes (#43194) 2026-10-02 11:03:03 +00:00
Asuka Minato
1b86126ab1 test: use real Redis clients in catalog fixtures (#43193) 2026-10-02 11:03:03 +00:00
yyh
ca27376211 fix(agent): preserve prompt copy confirmation (#43327) 2026-10-02 05:46:37 +00:00
Asuka Minato
c0fa5c3bcd test: use real tool file adapters (#43192) 2026-10-02 05:28:46 +00:00
Asuka Minato
46f32a7d22 test: replace session specs with real sessions (#43190) 2026-10-02 05:28:45 +00:00
Asuka Minato
5c6f46c952 test: use real model results and workflow execution values (#43189) 2026-10-02 05:28:44 +00:00
Asuka Minato
e23c02340a test: exercise tenant indexing queue serialization (#43188) 2026-10-02 05:28:44 +00:00
Asuka Minato
b3188a299b test: use persisted account query fixtures (#43187) 2026-10-02 05:28:43 +00:00
Asuka Minato
570552c6fa test: use real account education adapters (#43184) 2026-10-02 02:54:24 +00:00
Asuka Minato
636e0f0ace test: persist account password and profile fixtures (#43183) 2026-10-02 02:54:23 +00:00
Asuka Minato
fecc3af674 test: use concrete LLM memory and file fixtures (#43182) 2026-10-02 02:54:22 +00:00
Asuka Minato
0b9b0db408 test: use real node variable pools and lifecycle events (#43179) 2026-10-02 02:54:21 +00:00
Asuka Minato
a2b0b2a143 test: exercise real website request conversion (#43178) 2026-10-02 02:54:20 +00:00
Asuka Minato
0fa381f3e7 test: use validated workflow invocation entities (#43176) 2026-10-02 02:54:19 +00:00
Asuka Minato
d28b99ef6d test: use real generation events and trace managers (#43175) 2026-10-02 02:54:18 +00:00
Asuka Minato
38f1ecdd3b test: construct real LLM generation dependencies (#43174) 2026-10-02 02:54:17 +00:00
Asuka Minato
a0330a756f test: use real workflow state and command channels (#43173) 2026-10-02 02:54:17 +00:00
Asuka Minato
723aeb8b10 test: use real agent runtime dependencies (#43171) 2026-10-02 02:54:16 +00:00
Asuka Minato
cba5e5503a test: replace tool transformation spec mocks with concrete fixtures (#43170) 2026-10-02 02:54:15 +00:00
Asuka Minato
4c640ff898 test: exercise account Redis command serialization (#43169) 2026-10-02 02:14:14 +00:00
yyh
0bd950fd01 fix(ui): align menu semantics and focus behavior (#43325) 2026-10-01 15:22:26 +00:00
dependabot[bot]
f004f6b67d chore(deps): bump pyjwt from 2.14.0 to 2.15.0 in /api (#43318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 10:27:08 +00:00
dependabot[bot]
50695cbf92 chore(deps): bump litellm from 1.89.3 to 1.89.7 in /api (#43317)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 10:26:41 +00:00
dependabot[bot]
23e13a5e93 chore(deps): bump urllib3 from 2.7.0 to 2.8.0 in /api (#43316)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 10:25:51 +00:00
Asuka Minato
6423a83b78 test: use real Redis clients for tokens tasks and setup locks (#43168)
Co-authored-by: Byron Wang <byron@dify.ai>
2026-10-01 09:57:56 +00:00
dependabot[bot]
4534c37aa7 chore(deps): bump urllib3 from 2.7.0 to 2.8.0 in /dify-agent (#43314)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 09:55:12 +00:00
yyh
431c185706 fix(web): upgrade Next.js to 16.3.8 for September security fixes (#43320) 2026-10-01 03:25:06 +00:00
yyh
5260f0eee6 refactor(web): move monitoring date range picker to app components (#43295) 2026-10-01 03:23:40 +00:00
wangxiaolei
9fa987a550 fix: fix migrate permission not exclude admin and owner (#43311) 2026-10-01 02:04:16 +00:00
Asuka Minato
1beefc2b50 test: use real upload streams and document values (#43167) 2026-10-01 02:02:02 +00:00
yyh
1a918ea407 fix(dify-ui): use default cursor for form labels (#43287) 2026-09-30 16:11:44 +00:00
yyh
fac6179309 fix(workflow): restore compact node panel title alignment (#43286) 2026-09-30 16:11:16 +00:00
yyh
3c17c90bea fix(ui): expose slider values and unify class name resolution (#43293) 2026-09-30 16:10:57 +00:00
Chiranjeevi Balawat
5e3065ec7d refactor(api): remove redundant casts and enable strict type checks for bundle archive maintenance tests (#43270)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:12:24 +00:00
Joel
71e5b353b1 chore(web): improve pricing plans accessibility (SNP-885) (#43279) 2026-09-30 08:48:05 +00:00
Joel
9988dcc720 chore(web): improve agent monitoring accessibility (SNP-897) (#43274)
Co-authored-by: Byron.wang <byron@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-30 07:27:40 +00:00
Byron.wang
95373bc4fd refactor(api): consolidate account modules and shared errors (#42570)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-30 07:26:44 +00:00
Byron.wang
6decc78fd5 refactor(api): move web authentication into application services (#41318)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-30 06:47:17 +00:00
yyh
bf41e733ba fix(workflow): preserve inline agent dialog during suspense (#43273) 2026-09-30 06:33:59 +00:00
yyh
d7600ad9a7 fix(app): preserve sidebar header layout while loading (#43271) 2026-09-30 06:32:30 +00:00
Asuka Minato
52d586a7b4 test: use SQLite repository for human input uploads (#43166) 2026-09-30 06:21:03 +00:00
Asuka Minato
4433d56a88 test: exercise human input submissions with SQLite repository (#43165) 2026-09-30 06:21:02 +00:00
Asuka Minato
db317bf9a1 test: use real HTTP clients and creator responses (#43164) 2026-09-30 06:21:01 +00:00
Asuka Minato
1642281176 test: exercise real telemetry queues and trace managers (#43162) 2026-09-30 06:21:01 +00:00
Asuka Minato
8edc40e2f1 test: use real moderation configuration and queues (#43161) 2026-09-30 06:18:22 +00:00
Asuka Minato
fe49d123c0 test: use real schema namespaces and registries (#43160) 2026-09-30 06:18:21 +00:00
Asuka Minato
5ab572e0a7 test: use real MCP auth and session fixtures (#43159) 2026-09-30 06:18:21 +00:00
Asuka Minato
2cb6431582 test: use real prompt model and memory fixtures (#43158) 2026-09-30 06:17:54 +00:00
yyh
e48680f32b fix(ui): compose drawer close controls with IconButton (#43267) 2026-09-30 06:06:37 +00:00
yyh
a743a9150f refactor(app): move sidebar details to the route query (#43264) 2026-09-30 06:04:54 +00:00
yyh
2f6b14525e fix(workflow): isolate run records while preserving selected tabs (#43263) 2026-09-30 04:35:56 +00:00
yyh
df016cd309 refactor(logs): pass app identity into log inspectors (#43261) 2026-09-30 04:35:56 +00:00
yyh
d5c3fb9ecd refactor(logs): scope page data and replay to the owning app (#43260) 2026-09-30 04:35:55 +00:00
yyh
0effc36f45 refactor(access-config): scope app permissions to the page query (#43259) 2026-09-30 04:35:54 +00:00
yyh
28697d9587 refactor(overview): read app detail from the route query (#43258) 2026-09-30 04:35:54 +00:00
Asuka Minato
ea38e484b8 test: use real document extractor file fixtures (#43156) 2026-09-30 03:44:01 +00:00
Asuka Minato
5bae0bb010 test: use real MCP request and transport fixtures (#43155) 2026-09-30 03:44:00 +00:00
Asuka Minato
51fa4a25f9 test: use real structured output model fixtures (#43154) 2026-09-30 03:43:59 +00:00
Joel
b4cb02ccb9 fix: make OAuth client info card span form width (#43256) 2026-09-30 03:36:07 +00:00
Sourav Rajvi
77ec1b2027 chore(deps): bump c12 to 4.0.0-rc.2 (#43103) 2026-09-30 03:01:48 +00:00
dependabot[bot]
68726eb097 chore(deps): bump pyjwt from 2.13.0 to 2.14.0 in /api (#43254)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-30 02:59:22 +00:00
Asuka Minato
b4dd2c4886 test: use real Easy UI stream fixtures (#43153) 2026-09-30 02:56:54 +00:00
Joel
cf02d52d0d fix(e2e): align Agent v2 workflow access link locator (#43255) 2026-09-30 02:32:54 +00:00
Xiyuan Chen
a4524c215f feat(cli): difyctl v2 over the server catalog (#42608)
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-09-30 01:58:58 +00:00
yyh
96abb8ea22 fix(dify-ui): make native form story locators locale-independent (#43250) 2026-09-30 01:03:29 +00:00
Joel
216151ffa7 chore: address Access Point accessibility issues (#43196)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-09-29 14:29:54 +00:00
Stephen Zhou
7b0660b45b docs: refresh project guides and fix generated API tables (#43239) 2026-09-29 13:53:30 +00:00
yyh
a4f949f173 test(web): wait for command palette focus before pasting (#43236) 2026-09-29 12:24:09 +00:00
yyh
0ed392fb62 chore(dify-ui): align authoring rules and dependency optimization (#43233) 2026-09-29 11:58:49 +00:00
Joel
3f0032f20b chore(web): address WCAG A/AA issues on agent logs page (SNP-896) (#43215) 2026-09-29 10:28:23 +00:00
Coding On Star
bfd5636bf0 fix(web): hide installed plugins from workflow marketplace selectors (#43191)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-09-29 10:18:11 +00:00
Taranum Wasu
f79ed842fa fix(web): render a message in OAuth callback without opener (#43053)
Co-authored-by: Taranum01 <taranumwasu@users.noreply.github.com>
Co-authored-by: Taranum01 <taranum01@users.noreply.github.com>
2026-09-29 10:02:45 +00:00
Byron.wang
a67cd87eb0 refactor(api): establish dataset application boundaries (#41729)
Co-authored-by: Jyong <jyong@dify.ai>
2026-09-29 08:42:31 +00:00
Joel
c005932db9 chore(web): address Agent Configure WCAG issues (SNP-894) (#43172) 2026-09-29 08:36:34 +00:00
Stephen Zhou
af7f6abb95 perf(api): disable unused SDK pytest plugins (#43125) 2026-09-29 07:47:43 +00:00
Aniket Kumar
2a1211baa5 test: pin ZIP timestamp in roster archive helper (#43122) 2026-09-29 07:28:43 +00:00
Stephen Zhou
3813c4d552 perf(api): reuse auth matrix routes and batch test data setup (#43128) 2026-09-29 07:26:31 +00:00
yyh
8fc8178ff7 fix(web): align Agent template avatar and translations (#43163) 2026-09-29 07:26:28 +00:00
Asuka Minato
fb0e55f5e7 test: replace plugin model client spec mocks with a recording fake (#43152) 2026-09-29 07:08:49 +00:00
Asuka Minato
fb9b97b26d test: use a real Phoenix tracer and in-memory exporter (#43151) 2026-09-29 07:08:48 +00:00
Asuka Minato
80b34b5ab6 test: use real trace records and SDK span values (#43150) 2026-09-29 07:08:47 +00:00
Asuka Minato
8db1712ccd test: construct real datasource plugin and message fixtures (#43149) 2026-09-29 07:08:46 +00:00
Asuka Minato
6dc9de24d8 test: use real datasource provider fixtures (#43148) 2026-09-29 07:08:45 +00:00
Asuka Minato
9c11e36cc5 test: use real app models in integration fixtures (#43147) 2026-09-29 07:08:44 +00:00
Asuka Minato
1d02154638 test: validate real document configuration entities (#43145) 2026-09-29 07:08:44 +00:00
Asuka Minato
b4bc25cab6 test: compose application services with real Redis wrappers (#43144) 2026-09-29 07:08:43 +00:00
Asuka Minato
948fd166dd test: use real tool runtimes responses and plugin entities (#43142) 2026-09-29 07:08:42 +00:00
Asuka Minato
38f42f357e test: replace agent and upload ORM spec mocks with real models (#43141) 2026-09-29 07:08:41 +00:00
Stephen Zhou
f24586323b perf(api): avoid numbered temporary directories for test databases (#43127) 2026-09-29 06:56:39 +00:00
Denis Ermakov
bc0279572e fix(web): surface SSO login request failures to the user (#43076)
Co-authored-by: SquabbyZ <43177939+SquabbyZ@users.noreply.github.com>
2026-09-29 06:52:02 +00:00
Joel
20d9f15933 chore(web): improve agents roster accessibility (SNP-893) (#43140) 2026-09-29 06:40:29 +00:00
yyh
d42276a5c0 refactor(goto-anything): simplify command context and execution (#43136) 2026-09-29 06:37:41 +00:00
yyh
5c4489911c feat(goto-anything): improve command discovery and keyboard navigation (#43133) 2026-09-29 06:37:40 +00:00
Lucien
657624154d fix(web): preserve dataset uploads after returning to source step (#43080) 2026-09-29 05:50:16 +00:00
Stephen Zhou
be8744264f perf(api): reuse controller OpenAPI documents in schema tests (#43120) 2026-09-29 03:28:43 +00:00
Wu Tianwei
0fec636da5 fix(web): protect workspace admins in resource access (#43118) 2026-09-29 03:07:15 +00:00
Stephen Zhou
ce27ef9dc3 ci: reduce main CI orchestration scheduling overhead (#43114) 2026-09-29 03:05:39 +00:00
Stephen Zhou
f7e5946d81 ci: overlap core E2E startup with web build preparation (#43116) 2026-09-29 02:54:26 +00:00
yyh
90f7fbd468 feat(ui): redesign date and time pickers (#43090) 2026-09-29 02:14:10 +00:00
Harsh Kashyap
1545517fe8 ci(test): gate new session-mock assignments in api unit tests (#42854)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <harsh@harshkashyap.dev>
2026-09-29 02:11:45 +00:00
KUNAL KAKDE
1b9ddf9ae5 docs(api): align audio-to-text supported formats in service-openapi (#41891)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-29 02:09:09 +00:00
Denis Ermakov
9bb9935f44 refactor(models): remove implicit-session Account tenant setters (#43064) 2026-09-29 02:03:34 +00:00
Denis Ermakov
1ff3d3db68 refactor(console): dep-inject the activation payload with @model_validate (#43056) 2026-09-29 02:01:34 +00:00
Denis Ermakov
18fea7ff0d chore(api): remove redundant str() on StringUUID ids (#43061) 2026-09-29 02:00:16 +00:00
Stephen Zhou
357f697b16 chore(web): upgrade vinext and Vite+ to 1.0.0 (#43110) 2026-09-29 01:53:48 +00:00
杨俊波
e6f1d77ed5 feat: tool fallback observability (#42596)
Co-authored-by: liuns-yang <liuns-yang@users.noreply.github.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-09-28 11:10:48 +00:00
Yunlu Wen
4f8bf32b72 feat: support host style in archive s3 client (#43066) 2026-09-28 11:06:38 +00:00
invalid w
d8187b6e51 chore(web): resolve eslint error for node/panel components in CustomNode renderer (#42470) 2026-09-28 09:58:53 +00:00
Stephen Zhou
51940eac9c perf(web): lazy-load SVG code block previews (#42951) 2026-09-28 09:26:33 +00:00
林玮 (Jade Lin)
005f019a80 feat: support New Agent templates and trials (#42837)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-09-28 09:21:50 +00:00
Stephen Zhou
5f6bb5e6d3 perf(workflow): lazy load node configuration panels (#42970) 2026-09-28 09:07:08 +00:00
Joel
7020120197 chore: improve Custom Endpoint accessibility (SNP-876) (#43060) 2026-09-28 08:59:34 +00:00
Harsh Kashyap
fe5dfd756f fix(web): skip shiki WASM engine load for plain-text fences (#42957)
Co-authored-by: Harsh Kashyap <harsh@harshkashyap.dev>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-09-28 08:54:54 +00:00
Stephen Zhou
4808976d0c fix(web): reset frontend session after logout (#43055) 2026-09-28 08:17:49 +00:00
Joel
1b21b8220d chore(web): focus node panel on keyboard open (SNP-877) (#43047) 2026-09-28 07:52:39 +00:00
Denis Ermakov
bf55d1f6a6 refactor: pass db.session explicitly to instruction_modify_legacy (#37403) (#43045) 2026-09-28 07:38:08 +00:00
Keith
855842410a refactor(models): remove implicit-session AppModelConfig.annotation_reply_dict (#43024) 2026-09-28 07:36:44 +00:00
huhupy
33b2f09325 fix(web): correct knowledge document information fields (#42885) 2026-09-28 07:32:51 +00:00
非法操作
4713c3853b refactor(api): bound suggested question runtime sessions (#42968) 2026-09-28 07:31:24 +00:00
非法操作
89e6b71b2d refactor(api): migrate console suggested questions to shared runtime (#43033) 2026-09-28 07:31:17 +00:00
平衡世界的BOY
27cdf50c7d fix(web): improve snippet run panel accessibility (#43028) 2026-09-28 07:00:30 +00:00
Joel
2b88685fbd chore: improve a11y of trigger integration, agent strategy and extension page (#43023) 2026-09-28 06:41:17 +00:00
yyh
52dcf83de6 refactor(web): scope app UI sessions to their owners (#42984) 2026-09-28 06:39:19 +00:00
yyh
a4a18e0652 refactor(web): separate AppIcon display from picker triggers (#43041) 2026-09-28 06:34:04 +00:00
林玮 (Jade Lin)
6441436b0c feat(api): include Site settings in App DSL (#42863) 2026-09-28 06:17:44 +00:00
Byron.wang
0c5f2507d0 refactor(api): separate app MCP server service and persistence (#42850) 2026-09-28 06:03:58 +00:00
qielanci
2515cf5b48 fix(api): use plain Session in MCP controller to avoid closed transactionFix/mcp plain session (#43038) 2026-09-28 05:44:11 +00:00
Stephen Zhou
5283462286 chore(deps): update compatible workspace dependencies (#43031) 2026-09-28 04:37:37 +00:00
yyh
f95edc85f5 refactor(web): consume generated app detail contracts (#42982) 2026-09-28 04:35:06 +00:00
Stephen Zhou
f3107fee3f fix(tooling): resolve standalone TypeScript checks (#43029) 2026-09-28 03:36:07 +00:00
yyh
c42ee2fbac refactor(api): align app model config write contract (#42979) 2026-09-28 03:22:37 +00:00
Stephen Zhou
fc5053edf0 chore(web): upgrade vinext and align Vite+ configuration (#43021) 2026-09-28 03:04:45 +00:00
Taranum Wasu
d8ec14027e fix(api): mark async workflow trigger log FAILED when dispatch raises (#42148) (#43009)
Co-authored-by: Taranum01 <taranum01@users.noreply.github.com>
2026-09-28 02:57:05 +00:00
非法操作
96f444e3c6 refactor(api): separate history queries from prompt rendering (#42967) 2026-09-28 02:51:58 +00:00
非法操作
9d4629c946 refactor(api): share installed app suggested questions runtime (#42933) 2026-09-28 02:51:38 +00:00
Stephen Zhou
11f6a350f1 fix(tooling): align Vite+ configuration and upgrade toolchain (#43019) 2026-09-28 02:32:25 +00:00
huoex1222-hub
1366cadbb4 fix(api): report batch count as the progress denominator in Vector (#43005)
Co-authored-by: huoenxi <15811355218@139.com>
2026-09-28 02:29:58 +00:00
henrydev23
174a908786 chore(api): remove unnecessary str calls in openapi controllers (#42931) 2026-09-28 02:17:48 +00:00
dependabot[bot]
2062d5e892 chore(deps-dev): bump the dev group in /api with 7 updates (#43017)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 02:13:04 +00:00
非法操作
fb7b00365c refactor(api): separate suggested question model preparation and invocation (#42966) 2026-09-28 02:07:36 +00:00
非法操作
1c535791cb refactor(api): use HTTPStatus throughout Explore controllers (#42891) 2026-09-28 02:07:12 +00:00
dependabot[bot]
090f7d923c chore(deps): bump the github-actions-dependencies group with 3 updates (#43013)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 02:07:08 +00:00
非法操作
3dce05e09e refactor(api): group installed and trial app services (#42878) 2026-09-28 02:07:02 +00:00
jeongeun
a154a4d674 refactor(api): inject TextToSpeechVoiceQuery into agent TTS voices via the decorator (#43006)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:54:28 +00:00
ly303550688
934662cc48 fix(api): resolve ob_vector_memory_limit_percentage by index (#42846)
Co-authored-by: yangl <303550688@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-28 01:52:22 +00:00
Keith
b470071c4c chore: remove mypy from backend type checks (#42917) 2026-09-28 01:52:05 +00:00
yyh
d6c96ce1a5 refactor(web): redesign icon picker and clarify autocomplete highlighting (#43007) 2026-09-28 01:35:36 +00:00
Taranum Wasu
60744157d8 fix(api): fall back to tool name when MCP tool title is null (#42453) (#43010)
Co-authored-by: Taranum01 <taranum01@users.noreply.github.com>
2026-09-28 01:35:22 +00:00
dependabot[bot]
43f64090e1 chore(deps): bump the storage group across 1 directory with 4 updates (#42813)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 01:29:39 +00:00
dependabot[bot]
12f65f887a chore(deps): bump google-cloud-aiplatform from 2.1.3 to 2.2.0 in /api in the google group (#43012)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 01:27:11 +00:00
Sourav Rajvi
725611b2e9 test: drop redundant created_at/updated_at in unit tests (#42989) (#42990) 2026-09-26 18:10:34 +00:00
destire-mio
e6fa1a1dd0 refactor(models): pass session into Conversation.app (#42975)
Co-authored-by: destire-mio <248462155+destire-mio@users.noreply.github.com>
2026-09-26 17:29:35 +00:00
yyh
414cce2f70 refactor(web): align hotkeys with command ownership (#42974) 2026-09-26 12:37:36 +00:00
Stefan Yoshovski
7a7dc6011a fix(agent-v2): use icon_url for image avatars in the configure preview (#42977)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-09-26 12:36:08 +00:00
Bruce-Yii
3960b57102 fix(web): resolve pdf worker url under the configured base path (#42956)
Co-authored-by: Bruce-Yii <298228875+Bruce-Yii@users.noreply.github.com>
2026-09-26 05:32:07 +00:00
yyh
698ecd3e62 refactor(dify-ui): narrow checkbox public API (#42953) 2026-09-26 04:52:26 +00:00
yyh
f4602cc1fe refactor(web): refine detail sidebar states and preview (#42938) 2026-09-25 15:46:20 +00:00
yyh
2d813b2a87 fix(web): preserve detail sidebar state during SSR (#42936) 2026-09-25 15:46:19 +00:00
2833 changed files with 114425 additions and 109471 deletions

View File

@@ -1,93 +1,15 @@
# Rule Catalog — Architecture
# Architecture review
## Scope
- Covers: controller/service/core-domain/libs/model layering, dependency direction, responsibility placement, observability-friendly flow.
The [API agent guide](../../../../api/AGENTS.md) owns package boundaries. In particular, `api/core/` is migration-only: do not propose new files or extracted implementations there.
## Rules
## Transport and domain boundaries
### Keep business logic out of controllers
- Category: maintainability
- Description: Controllers should parse input, call services, and return serialized responses. Business decisions inside controllers make behavior hard to reuse and test.
- Suggested fix: Move domain/business logic into the service or core/domain layer. Keep controller handlers thin and orchestration-focused.
- Example:
- Bad:
```python
@bp.post("/apps/<app_id>/publish")
def publish_app(app_id: str):
payload = request.get_json() or {}
if payload.get("force") and current_user.role != "admin":
raise ValueError("only admin can force publish")
app = App.query.get(app_id)
app.status = "published"
db.session.commit()
return {"result": "ok"}
```
- Good:
```python
@bp.post("/apps/<app_id>/publish")
def publish_app(app_id: str):
payload = PublishRequest.model_validate(request.get_json() or {})
app_service.publish_app(app_id=app_id, force=payload.force, actor_id=current_user.id)
return {"result": "ok"}
```
Controllers own request parsing, service calls, and response serialization. Trace business decisions to their service or domain owner; flag controller policy when it duplicates or bypasses that owner. Move extracted behavior to an appropriate owner outside `core/`.
### Preserve layer dependency direction
- Category: best practices
- Description: Controllers may depend on services, and services may depend on core/domain abstractions. Reversing this direction (for example, core importing controller/web modules) creates cycles and leaks transport concerns into domain code.
- Suggested fix: Extract shared contracts into core/domain or service-level modules and make upper layers depend on lower, not the reverse.
- Example:
- Bad:
```python
# core/policy/publish_policy.py
from controllers.console.app import request_context
## Dependency direction
Transport layers may depend on services and domain contracts. Domain code must not import controller or request context. Pass validated actor, tenant, and resource data explicitly instead of reaching upward for it. Check existing owners and import-linter contracts before proposing a shared abstraction.
def can_publish() -> bool:
return request_context.current_user.is_admin
```
- Good:
```python
# core/policy/publish_policy.py
def can_publish(role: str) -> bool:
return role == "admin"
## Business-agnostic libraries
# service layer adapts web/user context to domain input
allowed = can_publish(role=current_user.role)
```
### Keep libs business-agnostic
- Category: maintainability
- Description: Modules under `api/libs/` should remain reusable, business-agnostic building blocks. They must not encode product/domain-specific rules, workflow orchestration, or business decisions.
- Suggested fix:
- If business logic appears in `api/libs/`, extract it into the appropriate `services/` or `core/` module and keep `libs` focused on generic, cross-cutting helpers.
- Keep `libs` dependencies clean: avoid importing service/controller/domain-specific modules into `api/libs/`.
- Example:
- Bad:
```python
# api/libs/conversation_filter.py
from services.conversation_service import ConversationService
def should_archive_conversation(conversation, tenant_id: str) -> bool:
# Domain policy and service dependency are leaking into libs.
service = ConversationService()
if service.has_paid_plan(tenant_id):
return conversation.idle_days > 90
return conversation.idle_days > 30
```
- Good:
```python
# api/libs/datetime_utils.py (business-agnostic helper)
def older_than_days(idle_days: int, threshold_days: int) -> bool:
return idle_days > threshold_days
# services/conversation_service.py (business logic stays in service/core)
from libs.datetime_utils import older_than_days
def should_archive_conversation(conversation, tenant_id: str) -> bool:
threshold_days = 90 if has_paid_plan(tenant_id) else 30
return older_than_days(conversation.idle_days, threshold_days)
```
`api/libs/` must not own product policy or orchestration. Flag service/controller dependencies and domain-specific decisions in library helpers. Move that behavior to its existing service or domain owner outside `core/`; retain only reusable infrastructure in `libs/`.

View File

@@ -11,7 +11,7 @@
- Description: If a table/model already has a repository abstraction, all reads/writes/queries for that table should use the existing repository. If no repository exists, introduce one only when complexity justifies it, such as large/high-volume tables, repeated complex query logic, or likely storage-strategy variation.
- Suggested fix:
- First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` to verify whether the table/model already has a repository abstraction. If it exists, route all operations through it and add missing repository methods instead of bypassing it with ad-hoc SQLAlchemy access.
- If no repository exists, add one only when complexity warrants it (for example, repeated complex queries, large data domains, or multiple storage strategies), while preserving dependency direction (service/core depends on abstraction; infra provides implementation).
- If no repository exists, add one only when complexity warrants it (for example, repeated complex queries, large data domains, or multiple storage strategies). Follow `api/AGENTS.md`: put new abstractions and implementations outside migration-only `api/core/`, with services/domain code depending on abstractions and infrastructure providing implementations.
- Example:
- Bad:
```python

View File

@@ -10,13 +10,8 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
## Keyboard Commands
- Distinguish application commands from widget-local keyboard semantics. Use `@tanstack/react-hotkeys` for application commands; keep menu navigation, dialog Escape handling, editor behavior, and ARIA widget keys in their local primitive or owner.
- Use `useHotkey` or `useHotkeys` for registered commands. When an existing `onKeyDown` intentionally owns the command, use `matchesKeyboardEvent` rather than duplicating modifier parsing or adding another global listener.
- Keep registration and keycap or menu display derived from one canonical command. Distinguish registered commands, held keys, and display-only accelerators.
- Keep a single-owner command beside its component. Create a feature-local hotkey module only when several production files share it; tests alone do not justify extraction.
- Make availability and scope explicit with `enabled`, `ignoreInputs`, and `target`. Put a target ref on the actual behavior owner rather than creating wrapper DOM solely for hotkey scope.
- Preserve existing `preventDefault` and propagation behavior when migrating command APIs.
- Test observable command behavior, disabled and input scope, target scope, and the registration/display contract at the owning feature boundary.
- Keep widget navigation, dialog dismissal, and editor commands in their local primitive or owner.
- For Web application commands, follow [Keyboard commands] for TanStack registration, typed bindings, targets, event consumption, display, and verification. Do not recreate that policy in a wrapper or a second command framework.
## Secondary Surfaces
@@ -28,4 +23,5 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
- Prefer primitive-owned open state unless another owner must observe or coordinate it. Analytics callbacks and local cleanup alone do not require a controlled root.
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md
[Keyboard commands]: ../../../../web/docs/hotkeys.md
[state ownership]: state.md

View File

@@ -1,7 +1,7 @@
# Development with devcontainer
This project includes a devcontainer configuration that allows you to open the project in a container with a fully configured development environment.
Both frontend and backend environments are initialized when the container is started.
The create hook installs workspace JavaScript dependencies and `uv`; the start hook synchronizes backend dependencies. Follow the [backend setup](../api/README.md) and [frontend guide](../web/README.md) to configure environment files and start services.
## GitHub Codespaces
@@ -19,20 +19,6 @@ if you have VS Code installed, you can click the button above to open this proje
You can learn more in the [Dev Containers documentation](https://code.visualstudio.com/docs/devcontainers/containers).
## Pros of Devcontainer
Unified Development Environment: By using devcontainers, you can ensure that all developers are developing in the same environment, reducing the occurrence of "it works on my machine" type of issues.
Quick Start: New developers can set up their development environment in a few simple steps, without spending a lot of time on environment configuration.
Isolation: Devcontainers isolate your project from your host operating system, reducing the chance of OS updates or other application installations impacting the development environment.
## Cons of Devcontainer
Learning Curve: For developers unfamiliar with Docker and VS Code, using devcontainers may be somewhat complex.
Performance Impact: While usually minimal, programs running inside a devcontainer may be slightly slower than those running directly on the host.
## Troubleshooting
if you see such error message when you open this project in codespaces:

3
.gitattributes vendored
View File

@@ -9,3 +9,6 @@
# Codegen output must stay byte-identical across platforms so
# `pnpm tree:check` in CI does not trip on CRLF rewrites.
*.generated.ts text eol=lf
# Skill scripts run on every platform, so keep LF line endings.
/skills/** text=auto eol=lf

5
.github/CODEOWNERS vendored
View File

@@ -57,13 +57,10 @@
# Backend - RAG (Retrieval Augmented Generation)
/api/core/rag/ @JohnJyong
/api/services/knowledge/ @JohnJyong
/api/services/rag_pipeline/ @JohnJyong
/api/services/dataset_service.py @JohnJyong
/api/services/knowledge_service.py @JohnJyong
/api/services/external_knowledge_service.py @JohnJyong
/api/services/hit_testing_service.py @JohnJyong
/api/services/metadata_service.py @JohnJyong
/api/services/vector_service.py @JohnJyong
/api/services/entities/knowledge_entities/ @JohnJyong
/api/services/entities/external_knowledge_entities/ @JohnJyong
/api/controllers/console/datasets/ @JohnJyong

View File

@@ -15,7 +15,7 @@ runs:
run-install: 'false'
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'

View File

@@ -13,11 +13,11 @@ runs:
using: composite
steps:
- name: Setup pnpm and Node.js
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
with:
install: false
- name: Setup Vite+
uses: voidzero-dev/setup-vp@9fd26ff49f9b5e6276c2b493da4454ad4527f7f4 # v1.20.0
uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1
with:
node-manager: false
package-manager: |

View File

@@ -0,0 +1,104 @@
const { execFile } = require('node:child_process')
const { appendFile, mkdtemp, rm } = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const { promisify } = require('node:util')
const waitForBuild = require('./wait.cjs')
const execFileAsync = promisify(execFile)
async function downloadWebBuild({
env = process.env,
execute = execFileAsync,
webDir = path.resolve(__dirname, '../../../web'),
} = {}) {
const repository = env.GITHUB_REPOSITORY
const runId = env.GITHUB_RUN_ID
const runAttempt = Number(env.GITHUB_RUN_ATTEMPT)
if (
!repository ||
!/^\d+$/.test(runId || '') ||
!Number.isInteger(runAttempt) ||
runAttempt < 1 ||
!env.GH_TOKEN
) {
throw new Error(
'Downloading the Web build requires GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, and GH_TOKEN.',
)
}
const run = (command, args, timeout = 60_000) =>
execute(command, args, {
env,
timeout,
maxBuffer: 32 * 1024 * 1024,
})
const api = async (endpoint, paginate = false) => {
const { stdout } = await run('gh', [
'api',
endpoint,
...(paginate ? ['--paginate', '--slurp'] : []),
])
return JSON.parse(stdout)
}
const prefix = `repos/${repository}/actions`
let summary = ''
const artifactId = await waitForBuild({
github: {
rest: {
actions: { listJobsForWorkflowRunAttempt: 'jobs', listWorkflowRunArtifacts: 'artifacts' },
},
paginate: async (kind) => {
const endpoint =
kind === 'jobs'
? `${prefix}/runs/${runId}/attempts/${runAttempt}/jobs?per_page=100`
: `${prefix}/runs/${runId}/artifacts?per_page=100`
const pages = await api(endpoint, true)
return pages.flatMap((page) => page[kind])
},
},
context: { repo: {}, runId },
core: {
info: console.log,
summary: {
addHeading(text) {
summary += `### ${text}\n\n`
return this
},
addRaw(text) {
summary += `${text}\n`
return this
},
async write() {
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, summary)
},
},
},
runAttempt,
})
const artifact = await api(`${prefix}/artifacts/${artifactId}`)
const directory = await mkdtemp(path.join(os.tmpdir(), 'dify-e2e-web-build-'))
try {
// Artifact names are immutable within a run. Use the name selected by wait.cjs,
// including its matching upload timestamp when only consumer jobs are rerun.
await run(
'gh',
['run', 'download', runId, '--repo', repository, '--name', artifact.name, '--dir', directory],
300_000,
)
await run('tar', ['-xf', path.join(directory, 'e2e-web-build.tar'), '-C', webDir])
console.log(`Web build artifact ${artifactId} extracted and ready.`)
} finally {
await rm(directory, { force: true, recursive: true })
}
}
module.exports = downloadWebBuild
if (require.main === module) {
downloadWebBuild().catch((error) => {
console.error(error)
process.exitCode = 1
})
}

View File

@@ -0,0 +1,112 @@
const assert = require('node:assert/strict')
const { access } = require('node:fs/promises')
const { test } = require('node:test')
const downloadWebBuild = require('./download.cjs')
function fixture({ downloadFails = false, producerFails = false } = {}) {
const calls = []
const env = {
GITHUB_REPOSITORY: 'owner/repo',
GITHUB_RUN_ID: '123',
GITHUB_RUN_ATTEMPT: '4',
GH_TOKEN: 'test-token',
}
const execute = async (command, args) => {
calls.push({ command, args })
if (command === 'tar') return { stdout: '' }
if (args[0] === 'run') {
if (downloadFails) throw new Error('artifact download failed')
return { stdout: '' }
}
if (args[1].includes('/jobs?')) {
return {
stdout: JSON.stringify([
{
jobs: [
{
name: 'Run Web Full-Stack E2E / Prepare Core E2E Web Build',
status: 'completed',
conclusion: producerFails ? 'failure' : 'success',
steps: [
{
name: 'Upload Web build',
conclusion: 'success',
started_at: '2026-09-22T04:08:08Z',
completed_at: '2026-09-22T04:08:12Z',
},
],
},
],
},
]),
}
}
if (args[1].includes('/artifacts?')) {
return {
stdout: JSON.stringify([
{ artifacts: [] },
{
artifacts: [
{
id: 42,
name: 'core-e2e-web-build-2',
created_at: '2026-09-22T04:08:11Z',
expired: false,
},
],
},
]),
}
}
assert.equal(args[1], 'repos/owner/repo/actions/artifacts/42')
return { stdout: JSON.stringify({ name: 'core-e2e-web-build-2' }) }
}
return { calls, options: { env, execute, webDir: '/test/web' } }
}
test('downloads the producer-selected artifact on consumer-only reruns, including paginated results', async () => {
const { calls, options } = fixture()
await downloadWebBuild(options)
assert.match(calls[0].args[1], /runs\/123\/attempts\/4\/jobs/)
const download = calls.find(({ args }) => args[0] === 'run')
assert.deepEqual(download.args.slice(0, 7), [
'run',
'download',
'123',
'--repo',
'owner/repo',
'--name',
'core-e2e-web-build-2',
])
const extract = calls.at(-1)
assert.equal(extract.command, 'tar')
assert.deepEqual(extract.args.slice(-2), ['-C', '/test/web'])
await assert.rejects(access(download.args.at(-1)), { code: 'ENOENT' })
})
test('does not extract a failed download and removes its temporary directory', async () => {
const { calls, options } = fixture({ downloadFails: true })
await assert.rejects(downloadWebBuild(options), /artifact download failed/)
assert.equal(
calls.some(({ command }) => command === 'tar'),
false,
)
await assert.rejects(access(calls.at(-1).args.at(-1)), { code: 'ENOENT' })
})
test('does not download or extract when the producer fails', async () => {
const { calls, options } = fixture({ producerFails: true })
await assert.rejects(downloadWebBuild(options), /Web build finished with failure/)
assert.equal(calls.length, 1)
})
test('requires CI metadata and credentials before starting preparation', async () => {
const { calls, options } = fixture()
for (const key of Object.keys(options.env)) {
await assert.rejects(
downloadWebBuild({ ...options, env: { ...options.env, [key]: '' } }),
/requires GITHUB/,
)
}
assert.equal(calls.length, 0)
})

View File

@@ -52,7 +52,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'

View File

@@ -23,7 +23,7 @@ jobs:
with:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: '3.12'
enable-cache: false
@@ -86,7 +86,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -117,7 +117,7 @@ jobs:
-p no:benchmark \
--timeout "${PYTEST_TIMEOUT:-20}" \
-n auto \
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
--cov-report= \
--file-shard-plan unit-test-plan.json \
--shard-index "${{ matrix.shardIndex }}" \
@@ -164,7 +164,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'
@@ -181,7 +181,7 @@ jobs:
run: |
uv run --project api pytest \
--timeout "${PYTEST_TIMEOUT:-20}" \
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
--cov-report= \
--junitxml=test-results/controllers.xml \
api/tests/unit_tests/controllers
@@ -227,7 +227,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -246,7 +246,7 @@ jobs:
--start-middleware \
--middleware-stop-timeout 2 \
-n auto \
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
--cov-report= \
--timeout "${PYTEST_TIMEOUT:-180}" \
api/tests/integration_tests/workflow \
@@ -282,7 +282,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: '3.12'

View File

@@ -64,7 +64,7 @@ jobs:
python-version: '3.11'
- if: github.event_name != 'merge_group' && (steps.api-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true' || steps.dify-agent-changes.outputs.any_changed == 'true')
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'

View File

@@ -78,7 +78,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -87,7 +87,7 @@ jobs:
with:
bun-version: latest
- uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
- uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
with:
install: false
@@ -121,7 +121,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -166,7 +166,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -227,7 +227,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -266,7 +266,7 @@ jobs:
- name: Upload results on failure
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
path: cli/test-results/
@@ -287,7 +287,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -341,7 +341,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -396,7 +396,7 @@ jobs:
- name: Upload results on failure
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-last-${{ github.run_id }}
path: cli/test-results/

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- 'cli/**'
- 'skills/**'
- 'packages/contracts/generated/api/openapi/**'
workflow_dispatch:

View File

@@ -77,11 +77,6 @@ jobs:
echo "dify_tag=${tag}" >> "$GITHUB_OUTPUT"
echo "::notice::target Dify release ${tag}"
- name: Compatibility check
env:
DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }}
run: node scripts/release-naming.mjs compat-check "$DIFY_TAG"
release:
name: build + attach standalone binaries (all targets)
needs: validate

View File

@@ -51,7 +51,7 @@ jobs:
- name: Run smoke against live dify
working-directory: ./cli
run: pnpm exec node scripts/run-smoke.ts --base-url http://localhost:5001
run: DIFY_SERVER=http://localhost:5001 bun scripts/run-smoke.ts
- name: Dump dify logs on failure
if: failure()

View File

@@ -19,7 +19,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'
@@ -69,7 +69,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'

View File

@@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@@ -21,9 +21,10 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'merge_group' }}
jobs:
# Keep orchestration independent of the Depot pool used for test execution.
pre_job:
name: Skip Duplicate Checks
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
steps:
@@ -37,7 +38,7 @@ jobs:
# Check which paths were changed to determine which tests to run
check-changes:
name: Check Changed Files
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
outputs:
api-changed: ${{ steps.changes.outputs.api }}
cli-changed: ${{ steps.changes.outputs.cli }}
@@ -62,10 +63,13 @@ jobs:
- 'api/**'
- 'scripts/ast_grep_guard.py'
- 'scripts/check_no_new_getattr.py'
- 'scripts/check_no_spec_mock.py'
- 'scripts/check_no_new_controller_sqlalchemy.py'
- 'scripts/lint_controller_sqlalchemy.py'
- 'scripts/ast_grep_rules/no_new_getattr.yml'
- 'scripts/ast_grep_rules/no_spec_mock.yml'
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
- '.github/workflows/style.yml'
- '.github/workflows/main-ci.yml'
- '.github/workflows/api-tests.yml'
@@ -78,6 +82,7 @@ jobs:
- 'docker/volumes/sandbox/conf/**'
cli:
- 'cli/**'
- 'skills/**'
- 'packages/contracts/**'
- 'packages/tsconfig/**'
- 'package.json'
@@ -159,10 +164,14 @@ jobs:
- 'api/**'
- 'scripts/ast_grep_guard.py'
- 'scripts/check_no_new_getattr.py'
- 'scripts/check_no_spec_mock.py'
- 'scripts/check_no_new_controller_sqlalchemy.py'
- 'scripts/check_no_new_session_mock.py'
- 'scripts/lint_controller_sqlalchemy.py'
- 'scripts/ast_grep_rules/no_new_getattr.yml'
- 'scripts/ast_grep_rules/no_spec_mock.yml'
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
- '.github/workflows/style.yml'
- '.github/workflows/main-ci.yml'
dify-agent-style:
@@ -187,6 +196,7 @@ jobs:
ts-common-style:
- 'web/**'
- 'cli/**'
- 'skills/**'
- 'e2e/**'
- 'sdks/nodejs-client/**'
- 'packages/**'
@@ -212,7 +222,8 @@ jobs:
- '.editorconfig'
- '.vite-hooks/**'
# Run tests in parallel while always emitting stable required checks.
# Finalizers report unchanged paths directly, avoiding separate skip-only jobs.
# Keep each required check name stable and fail closed if classification fails.
api-tests-run:
name: Run API Tests
needs:
@@ -222,17 +233,6 @@ jobs:
uses: ./.github/workflows/api-tests.yml
secrets: inherit
api-tests-skip:
name: Skip API Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped API tests
run: echo "No API-related changes detected; skipping API tests."
api-tests:
name: API Tests
if: ${{ always() }}
@@ -240,21 +240,26 @@ jobs:
- pre_job
- check-changes
- api-tests-run
- api-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize API Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.api-changed }}
RUN_RESULT: ${{ needs.api-tests-run.result }}
SKIP_RESULT: ${{ needs.api-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "API tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "API tests ran successfully."
@@ -265,12 +270,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "API tests were skipped because no API-related files changed."
exit 0
fi
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
cli-tests-run:
@@ -282,17 +287,6 @@ jobs:
uses: ./.github/workflows/cli-tests.yml
secrets: inherit
cli-tests-skip:
name: Skip CLI Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped CLI tests
run: echo "No CLI-related changes detected; skipping CLI tests."
cli-tests:
name: CLI Tests
if: ${{ always() }}
@@ -300,21 +294,26 @@ jobs:
- pre_job
- check-changes
- cli-tests-run
- cli-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize CLI Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.cli-changed }}
RUN_RESULT: ${{ needs.cli-tests-run.result }}
SKIP_RESULT: ${{ needs.cli-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "CLI tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "CLI tests ran successfully."
@@ -325,12 +324,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "CLI tests were skipped because no CLI-related files changed."
exit 0
fi
echo "CLI tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
web-tests-run:
@@ -342,17 +341,6 @@ jobs:
uses: ./.github/workflows/web-tests.yml
secrets: inherit
web-tests-skip:
name: Skip Web Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped web tests
run: echo "No web-related changes detected; skipping web tests."
web-tests:
name: Web Tests
if: ${{ always() }}
@@ -360,21 +348,26 @@ jobs:
- pre_job
- check-changes
- web-tests-run
- web-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Web Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.web-changed }}
RUN_RESULT: ${{ needs.web-tests-run.result }}
SKIP_RESULT: ${{ needs.web-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Web tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Web tests ran successfully."
@@ -385,12 +378,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Web tests were skipped because no web-related files changed."
exit 0
fi
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
web-e2e-run:
@@ -404,17 +397,6 @@ jobs:
run-external-runtime: false
secrets: inherit
web-e2e-skip:
name: Skip Web Full-Stack E2E
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped web full-stack e2e
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
web-e2e:
name: Web Full-Stack E2E
if: ${{ always() }}
@@ -422,21 +404,26 @@ jobs:
- pre_job
- check-changes
- web-e2e-run
- web-e2e-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Web Full-Stack E2E status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
RUN_RESULT: ${{ needs.web-e2e-run.result }}
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Web full-stack E2E was skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Web full-stack E2E ran successfully."
@@ -447,12 +434,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Web full-stack E2E was skipped because no E2E-related files changed."
exit 0
fi
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
style-check:
@@ -480,17 +467,6 @@ jobs:
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed == 'true'
uses: ./.github/workflows/vdb-tests.yml
vdb-tests-skip:
name: Skip VDB Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped VDB tests
run: echo "No VDB-related changes detected; skipping VDB tests."
vdb-tests:
name: VDB Tests
if: ${{ always() }}
@@ -498,21 +474,26 @@ jobs:
- pre_job
- check-changes
- vdb-tests-run
- vdb-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize VDB Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.vdb-changed }}
RUN_RESULT: ${{ needs.vdb-tests-run.result }}
SKIP_RESULT: ${{ needs.vdb-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "VDB tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "VDB tests ran successfully."
@@ -523,12 +504,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "VDB tests were skipped because no VDB-related files changed."
exit 0
fi
echo "VDB tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
db-migration-test-run:
@@ -539,17 +520,6 @@ jobs:
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed == 'true'
uses: ./.github/workflows/db-migration-test.yml
db-migration-test-skip:
name: Skip DB Migration Test
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped DB migration tests
run: echo "No migration-related changes detected; skipping DB migration tests."
db-migration-test:
name: DB Migration Test
if: ${{ always() }}
@@ -557,21 +527,26 @@ jobs:
- pre_job
- check-changes
- db-migration-test-run
- db-migration-test-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize DB Migration Test status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.migration-changed }}
RUN_RESULT: ${{ needs.db-migration-test-run.result }}
SKIP_RESULT: ${{ needs.db-migration-test-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "DB migration tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "DB migration tests ran successfully."
@@ -582,12 +557,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "DB migration tests were skipped because no migration-related files changed."
exit 0
fi
echo "DB migration tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
sandbox-runtime-tests-run:
@@ -599,17 +574,6 @@ jobs:
uses: ./.github/workflows/sandbox-runtime-tests.yml
secrets: inherit
sandbox-runtime-tests-skip:
name: Skip Sandbox Runtime Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.sandbox-runtime-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped sandbox runtime tests
run: echo "No sandbox-runtime-related changes detected; skipping sandbox runtime tests."
sandbox-runtime-tests:
name: Sandbox Runtime Tests
if: ${{ always() }}
@@ -617,21 +581,26 @@ jobs:
- pre_job
- check-changes
- sandbox-runtime-tests-run
- sandbox-runtime-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Sandbox Runtime Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.sandbox-runtime-changed }}
RUN_RESULT: ${{ needs.sandbox-runtime-tests-run.result }}
SKIP_RESULT: ${{ needs.sandbox-runtime-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Sandbox runtime tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Sandbox runtime tests ran successfully."
@@ -642,12 +611,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Sandbox runtime tests were skipped because no sandbox-runtime-related files changed."
exit 0
fi
echo "Sandbox runtime tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
dify-agent-tests-run:
@@ -659,17 +628,6 @@ jobs:
uses: ./.github/workflows/dify-agent-tests.yml
secrets: inherit
dify-agent-tests-skip:
name: Skip Dify Agent Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.dify-agent-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped dify agent tests
run: echo "No dify-agent-related changes detected; skipping dify agent tests."
dify-agent-tests:
name: Dify Agent Tests
if: ${{ always() }}
@@ -677,21 +635,26 @@ jobs:
- pre_job
- check-changes
- dify-agent-tests-run
- dify-agent-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Dify Agent Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.dify-agent-changed }}
RUN_RESULT: ${{ needs.dify-agent-tests-run.result }}
SKIP_RESULT: ${{ needs.dify-agent-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Dify agent tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Dify agent tests ran successfully."
@@ -702,10 +665,10 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Dify agent tests were skipped because no dify-agent-related files changed."
exit 0
fi
echo "Dify agent tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1

View File

@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true

View File

@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true

View File

@@ -61,7 +61,7 @@ jobs:
fetch-depth: 0
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: false
python-version: '3.12'
@@ -89,6 +89,14 @@ jobs:
if: inputs.run-python-style
run: uv run --project api python scripts/check_no_new_controller_sqlalchemy.py --base-rev "${{ inputs.base-rev }}"
- name: Run No New Session Mock Guard
if: inputs.run-python-style
run: uv run --project api python scripts/check_no_new_session_mock.py --base-rev "${{ inputs.base-rev }}"
- name: Run No Spec Mock Guard
if: inputs.run-python-style
run: uv run --project api python scripts/check_no_spec_mock.py
- name: Run Type Checks
if: inputs.run-python-style
env:
@@ -205,9 +213,25 @@ jobs:
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Restore static check task cache
id: static-check-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: node_modules/.vite/task-cache
key: vite-task-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
vite-task-static-v1-${{ runner.os }}-${{ runner.arch }}-
- name: Static check
run: vp run -w check
- name: Save static check task cache
if: success() && github.event_name != 'merge_group'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: node_modules/.vite/task-cache
key: ${{ steps.static-check-cache.outputs.cache-primary-key }}
superlinter:
name: SuperLinter
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-superlinter) }}
@@ -227,7 +251,7 @@ jobs:
persist-credentials: false
- name: Super-linter
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
uses: super-linter/super-linter/slim@2da136927bd4a73596db63044b504547c62cb854 # v9.0.0
env:
BASH_SEVERITY: warning
DEFAULT_BRANCH: origin/main

View File

@@ -9,6 +9,8 @@ on:
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .github/actions/setup-web/**
- .github/workflows/tool-test-sdks.yaml
concurrency:
group: sdk-tests-${{ github.head_ref || github.run_id }}
@@ -28,15 +30,13 @@ jobs:
with:
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: ''
cache-dependency-path: 'pnpm-lock.yaml'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Test
run: pnpm test
- name: Build and verify package entry points
run: |
pnpm build
node --input-type=module -e "import { access } from 'node:fs/promises'; import 'dify-client'; await access('dist/index.d.ts')"

View File

@@ -163,7 +163,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@4036a180cf690f49529f5d8c79c998855287f590 # v1.0.230
uses: anthropics/claude-code-action@ed670b4cf9de2a5a570d130d2f6197b9e543cd64 # v1.0.240
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@@ -58,7 +58,7 @@ jobs:
run: |
set -o pipefail
started=$SECONDS
pnpm exec vinext build 2>&1 | tee "$RUNNER_TEMP/vinext-build.log"
pnpm run build:vinext 2>&1 | tee "$RUNNER_TEMP/vinext-build.log"
printf 'Full Vinext build: **%s seconds**.\n' "$((SECONDS - started))" >> "$GITHUB_STEP_SUMMARY"
- name: Collect sizes
@@ -101,7 +101,7 @@ jobs:
persist-credentials: false
- name: Setup Node.js
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
with:
install: false

View File

@@ -26,7 +26,7 @@ jobs:
persist-credentials: false
- name: Test Web build synchronization
run: node --test .github/actions/wait-e2e-web-build/wait.test.cjs
run: node --test .github/actions/wait-e2e-web-build/*.test.cjs
- name: Compute build source hash
id: build-source
@@ -119,24 +119,12 @@ jobs:
with:
browser: chromium
# Install dependencies while core-build runs, then wait only for its artifact.
- name: Wait for Web build
id: web-build
timeout-minutes: 35
uses: ./.github/actions/wait-e2e-web-build
- name: Download Web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ steps.web-build.outputs.artifact-id }}
github-token: ${{ github.token }}
- name: Extract Web build
run: tar -xf e2e-web-build.tar -C web
# The runner prepares the backend while waiting for and downloading the build.
- name: Run Chromium full shard
working-directory: ./e2e
run: vp run e2e:full -- --shard ${{ matrix.shard }}/3
env:
GH_TOKEN: ${{ github.token }}
run: vp run e2e:full --download-web-build -- --shard ${{ matrix.shard }}/3
- name: Upload Cucumber report
if: ${{ !cancelled() }}
@@ -183,24 +171,12 @@ jobs:
with:
browser: webkit
# Install dependencies while core-build runs, then wait only for its artifact.
- name: Wait for Web build
id: web-build
timeout-minutes: 35
uses: ./.github/actions/wait-e2e-web-build
- name: Download Web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ steps.web-build.outputs.artifact-id }}
github-token: ${{ github.token }}
- name: Extract Web build
run: tar -xf e2e-web-build.tar -C web
# Keep the same overlap and teardown ownership for the WebKit smoke lane.
- name: Run WebKit browser smoke
working-directory: ./e2e
run: vp run e2e:full -- --tags '@browser-smoke'
env:
GH_TOKEN: ${{ github.token }}
run: vp run e2e:full --download-web-build -- --tags '@browser-smoke'
- name: Upload Cucumber report
if: ${{ !cancelled() }}
@@ -239,7 +215,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'

9
.vscode/README.md vendored
View File

@@ -1,14 +1,11 @@
# Debugging with VS Code
This `launch.json.template` file provides various debug configurations for the Dify project within VS Code / Cursor. To use these configurations, you should copy the contents of this file into a new file named `launch.json` in the same `.vscode` directory.
[`launch.json.template`](launch.json.template) provides API and Celery worker debug configurations for VS Code / Cursor. Both use `api/.venv/bin/python`; complete the [backend setup](../api/README.md) first.
## How to Use
1. **Create `launch.json`**: If you don't have one, create a file named `launch.json` inside the `.vscode` directory.
1. **Copy Content**: Copy the entire content from `launch.json.template` into your newly created `launch.json` file.
1. Copy `.vscode/launch.json.template` to `.vscode/launch.json`, or merge the configurations into an existing file.
1. **Select Debug Configuration**: Go to the Run and Debug view in VS Code / Cursor (Ctrl+Shift+D or Cmd+Shift+D).
1. **Start Debugging**: Select the desired configuration from the dropdown menu and click the green play button.
## Tips
- If you need to debug with Edge browser instead of Chrome, modify the `serverReadyAction` configuration in the "Next.js: debug full stack" section, change `"debugWithChrome"` to `"debugWithEdge"` to use Microsoft Edge for debugging.
Start the frontend separately using the [frontend guide](../web/README.md).

View File

@@ -81,24 +81,13 @@ AI tools are welcome, but you are responsible for understanding and verifying ev
#### Frontend
For setting up the frontend service, please refer to our comprehensive [guide](https://github.com/langgenius/dify/blob/main/web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly.
For setting up the frontend service, please refer to our comprehensive [guide](web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly.
**Testing**: Add focused tests when a change affects observable behavior or carries meaningful regression risk. See [web/docs/test.md](https://github.com/langgenius/dify/blob/main/web/docs/test.md) for the canonical frontend testing guidelines.
**Testing**: Add focused tests when a change affects observable behavior or carries meaningful regression risk. See [web/docs/test.md](web/docs/test.md) for the canonical frontend testing guidelines.
#### Backend
For setting up the backend service, kindly refer to our detailed [instructions](https://github.com/langgenius/dify/blob/main/api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly.
#### Other things to note
We recommend reviewing this document carefully before proceeding with the setup, as it contains essential information about:
- Prerequisites and dependencies
- Installation steps
- Configuration details
- Common troubleshooting tips
Feel free to reach out if you encounter any issues during the setup process.
For setting up the backend service, kindly refer to our detailed [instructions](api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly.
## Getting Help

View File

@@ -80,6 +80,7 @@ lint:
@uv run --project api --dev ruff format ./api
@uv run --project api --dev ruff check --fix ./api
@$(MAKE) api-contract-lint
@uv run --project api python scripts/check_no_spec_mock.py
@uv run --directory api --dev lint-imports
@uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example
@echo "✅ Linting complete"
@@ -90,15 +91,13 @@ api-contract-lint:
@echo "✅ Response contract lint complete"
type-check:
@echo "📝 Running type checks (pyrefly + mypy)..."
@echo "📝 Running type checks (pyrefly)..."
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@echo "✅ Type checks complete"
type-check-core:
@echo "📝 Running core type checks (pyrefly + mypy)..."
@echo "📝 Running core type checks (pyrefly)..."
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@echo "✅ Core type checks complete"
test:
@@ -214,8 +213,8 @@ help:
@echo " make check - Check code with ruff"
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
@echo " make api-contract-lint - Check Flask response docs against returned schemas"
@echo " make type-check - Run type checks (pyrefly, mypy)"
@echo " make type-check-core - Run core type checks (pyrefly, mypy)"
@echo " make type-check - Run type checks (pyrefly)"
@echo " make type-check-core - Run core type checks (pyrefly)"
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
@echo " make test-all - Run full backend tests, including Docker-backed suites"
@echo ""

View File

@@ -83,7 +83,7 @@ docker compose up -d
After running, you can access the Dify dashboard in your browser at [http://localhost/install](http://localhost/install) and start the initialization process.
#### Seeking help
### Seeking help
Please refer to our [FAQ](https://docs.dify.ai/getting-started/install-self-hosted/faqs) if you encounter problems setting up Dify. Reach out to [the community and us](#community--contact) if you are still having issues.
@@ -116,7 +116,7 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
## Using Dify
- **Cloud <br/>**
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan. If you run into issues with Dify Cloud, [contact our Cloud support team](mailto:cloud@dify.ai?subject=%5BGitHub%5DDify%20Cloud%20Support).
We host [Dify Cloud](https://cloud.dify.ai) so you can try Dify without managing a server. See the [pricing page](https://dify.ai/pricing) for current plans and usage allowances. If you run into issues, [contact our Cloud support team](mailto:cloud@dify.ai?subject=%5BGitHub%5DDify%20Cloud%20Support).
- **Self-hosting Dify Community Edition<br/>**
Quickly get Dify running in your environment with this [starter guide](#quick-start).
@@ -171,7 +171,7 @@ Choose the channel that best fits your question:
## Security disclosure
To protect your privacy, please avoid posting security issues on GitHub. Instead, report issues to security@dify.ai, and our team will respond with detailed answer.
Report vulnerabilities privately through GitHub Security Advisories, following our [security policy](SECURITY.md). Do not disclose them in public issues, discussions, or pull requests.
## License

View File

@@ -156,6 +156,7 @@ ARCHIVE_STORAGE_EXPORT_BUCKET=
ARCHIVE_STORAGE_ACCESS_KEY=
ARCHIVE_STORAGE_SECRET_KEY=
ARCHIVE_STORAGE_REGION=auto
ARCHIVE_STORAGE_ADDRESS_STYLE=path
# Azure Blob Storage configuration
AZURE_BLOB_ACCOUNT_NAME=your-account-name

View File

@@ -1,12 +1,14 @@
[importlinter]
root_packages =
core
core.rag.extractor.entity
constants
context
configs
controllers
extensions
factories
fields
libs
machinery
models
@@ -16,7 +18,7 @@ root_packages =
include_external_packages = True
[importlinter:contract:backend-layers]
name = Backend modules follow the transport, application, domain, and shared-library layers
name = Backend modules follow the transport, application, execution-kernel, and shared-library layers
type = layers
layers =
controllers
@@ -27,7 +29,7 @@ layers =
# error makes this list shrink whenever a legacy dependency is removed.
unmatched_ignore_imports_alerting = error
ignore_imports =
core.app.app_config.easy_ui_based_app.dataset.manager -> services.dataset_service
core.app.app_config.easy_ui_based_app.dataset.manager -> services.knowledge.dataset_service
core.app.apps.advanced_chat.app_generator -> controllers.console.app.workflow
core.app.apps.advanced_chat.app_generator -> services.conversation_service
core.app.apps.advanced_chat.app_generator -> services.errors.conversation
@@ -48,45 +50,41 @@ ignore_imports =
core.app.apps.message_based_app_generator -> services.errors.conversation
core.app.apps.message_based_app_generator -> services.errors.message
core.app.apps.pipeline.pipeline_generator -> services
core.app.apps.pipeline.pipeline_generator -> services.dataset_service
core.app.apps.pipeline.pipeline_generator -> services.datasource_provider_service
core.app.apps.pipeline.pipeline_generator -> services.knowledge.dataset_service
core.app.apps.pipeline.pipeline_generator -> services.data_source.provider_service
core.app.apps.pipeline.pipeline_generator -> services.feature_service
core.app.apps.pipeline.pipeline_generator -> services.workflow_draft_variable_service
core.app.apps.pipeline.pipeline_runner -> services.dataset_ref_service
core.app.apps.workflow.app_generator -> controllers.console.app.workflow
core.app.apps.workflow.app_generator -> services.snippet_generate_service
core.app.apps.workflow.app_generator -> services.workflow_draft_variable_service
core.app.apps.workflow_app_runner -> tasks.mail_human_input_delivery_task
core.app.features.annotation_reply.annotation_reply -> services.annotation_service
core.app.features.annotation_reply.annotation_reply -> services.dataset_service
core.app.features.annotation_reply.annotation_reply -> services.knowledge.dataset_service
core.app.llm.quota -> services.credit_pool_service
core.app.task_pipeline.message_cycle_manager -> services.account_service
core.app.task_pipeline.message_cycle_manager -> services.annotation_service
core.app.workflow.layers.persistence -> services.workflow.inspector_events
core.datasource.datasource_manager -> services.datasource_provider_service
core.helper.credential_utils -> services.enterprise.plugin_manager_service
core.helper.credential_utils -> services.system_feature_service
core.indexing_runner -> services.vector_space_admission_service
core.mcp.auth_client -> services
core.provider_manager -> services.credential_permission_service
core.provider_manager -> services.credit_pool_service
core.provider_manager -> services.feature_service
core.rag.datasource.retrieval_service -> services.external_knowledge_service
core.rag.datasource.retrieval_service -> services.knowledge.external.service
core.rag.index_processor.index_processor -> services.vector_space_admission_service
core.rag.index_processor.index_processor_base -> services.file_service
core.rag.index_processor.processor.paragraph_index_processor -> services.account_service
core.rag.index_processor.processor.paragraph_index_processor -> services.summary_index_service
core.rag.index_processor.processor.parent_child_index_processor -> services.account_service
core.rag.index_processor.processor.parent_child_index_processor -> services.summary_index_service
core.rag.index_processor.processor.qa_index_processor -> services.summary_index_service
core.rag.retrieval.dataset_retrieval -> services.external_knowledge_service
core.rag.index_processor.processor.paragraph_index_processor -> services.knowledge.summaries.adapters
core.rag.index_processor.processor.parent_child_index_processor -> services.knowledge.summaries.adapters
core.rag.index_processor.processor.qa_index_processor -> services.knowledge.summaries.adapters
core.rag.retrieval.dataset_retrieval -> services.knowledge.external.service
core.rag.retrieval.dataset_retrieval -> services.knowledge.retrieval.attachments
core.rag.retrieval.dataset_retrieval -> services.feature_service
core.rag.summary_index.summary_index -> services.summary_index_service
core.rag.summary_index.summary_index -> services.knowledge.summaries.adapters
core.rag.summary_index.summary_index -> tasks.generate_summary_index_task
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.file_service
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.variable_truncator
core.tools.tool_manager -> services
core.workflow.node_runtime -> services
# Node construction delegates datasource dependency assembly to the composition root.
core.workflow.node_factory -> extensions.application_services.data_sources
core.workflow.nodes.agent.message_transformer -> services
core.workflow.nodes.agent_v2.agent_node -> services.agent.prompt_mentions
core.workflow.nodes.agent_v2.agent_node -> services.agent.workspace_service
@@ -99,7 +97,7 @@ ignore_imports =
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
libs.email_i18n -> services.entities.feature_entities
libs.email_i18n -> services.system_feature_service
libs.external_api -> core
libs.external_api -> core.plugin
libs.external_api -> core.errors.error
libs.external_api -> extensions.ext_logging
libs.flask_utils -> models
@@ -114,8 +112,6 @@ ignore_imports =
libs.oauth_bearer -> models
libs.rsa -> extensions.ext_storage
libs.workspace_permission -> services.enterprise.enterprise_service
services.account_service -> controllers
services.account_service -> controllers.console.error
services.app_generate_service -> controllers.console.app.workflow
[importlinter:contract:no-direct-rsa-imports]
@@ -155,18 +151,159 @@ forbidden_modules =
werkzeug
[importlinter:contract:workspace-query-service-boundary]
name = Workspace query application service is framework and persistence neutral
name = Workspace application services and contracts are framework and persistence neutral
type = forbidden
source_modules =
services.workspace_query_service
services.workspace.service
services.workspace.provisioning_service
services.workspace.contracts
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
services.billing_service
services.feature_service
services.workspace.gateways
sqlalchemy
werkzeug
[importlinter:contract:dataset-application-boundary]
name = Dataset application services and contracts are framework and persistence neutral
type = forbidden
source_modules =
core.rag.extractor.entity.extract_setting
services.knowledge.entities.segments
services.knowledge.entities.datasets
services.knowledge.entities.document_creation
services.data_source.binding_application_service
services.data_source.credential_gateway
services.data_source.notion_import_application_service
services.data_source.entities.credential
services.data_source.entities.notion_import
services.knowledge.entities.indexing_estimate
services.knowledge.dataset_access
services.knowledge.datasets.application
services.knowledge.datasets.retrieval
services.knowledge.document_sync
services.knowledge.documents.application
services.knowledge.external.application
services.knowledge.indexing.errors
services.knowledge.indexing.estimate
services.knowledge.indexing.execution
services.knowledge.resource_scope
services.knowledge.segments.application
services.knowledge.metadata.application
services.knowledge.summaries.application
forbidden_modules =
fields
configs
controllers
extensions
flask
models
repositories
sqlalchemy
tasks
werkzeug
services.knowledge.dataset_service
services.data_source.provider_service
services.knowledge.indexing.adapters
services.knowledge.segments.adapters
services.knowledge.segments.indexing
core.datasource.datasource_manager
core.rag.extractor.notion_extractor
[importlinter:contract:knowledge-repository-boundary]
name = Knowledge persistence adapters only depend on their consumer-owned application contracts
type = forbidden
source_modules =
repositories.knowledge
forbidden_modules =
controllers
extensions
flask
services
tasks
werkzeug
allow_indirect_imports = True
ignore_imports =
repositories.knowledge.dataset_api_key_repository -> services.auth.api_key_contracts
repositories.knowledge.dataset_api_key_repository -> services.knowledge.api_key_service
repositories.knowledge.dataset_repository -> services.knowledge.segments.application
repositories.knowledge.dataset_repository -> services.knowledge.dataset_access
repositories.knowledge.dataset_repository -> services.knowledge.indexing.estimate
repositories.knowledge.dataset_repository -> services.knowledge.resource_scope
repositories.knowledge.document_repository -> services.knowledge.document_sync
repositories.knowledge.document_repository -> services.knowledge.indexing.estimate
repositories.knowledge.document_repository -> services.knowledge.indexing.errors
repositories.knowledge.document_repository -> services.knowledge.indexing.execution
repositories.knowledge.document_repository -> services.knowledge.resource_scope
repositories.knowledge.segment_repository -> services.knowledge.resource_scope
repositories.knowledge.segment_repository -> services.knowledge.indexing.execution
repositories.knowledge.segment_repository -> services.knowledge.segments.application
repositories.knowledge.segment_repository -> services.knowledge.entities.segments
repositories.knowledge.metadata_repository -> services.errors.metadata
repositories.knowledge.metadata_repository -> services.knowledge.metadata.application
repositories.knowledge.metadata_repository -> services.knowledge.resource_scope
repositories.knowledge.summary_repository -> services.knowledge.summaries.application
[importlinter:contract:datasource-credential-repository-boundary]
name = Data source credential persistence does not depend on SQLAlchemy service helpers
type = forbidden
source_modules =
repositories.credentials.query_repository
repositories.data_source.credential_repository
forbidden_modules =
services.data_source.provider_service
allow_indirect_imports = True
[importlinter:contract:datasource-provider-persistence-boundary]
name = Datasource provider service consumes injected persistence contracts
type = forbidden
source_modules =
services.data_source.provider_service
forbidden_modules =
repositories
extensions.application_services
extensions.ext_application_services
allow_indirect_imports = True
[importlinter:contract:credential-permission-query-policy-boundary]
name = Credential query contracts do not expose persistence implementations
type = forbidden
source_modules =
services.credentials.query
forbidden_modules =
repositories
sqlalchemy
models
extensions
flask
allow_indirect_imports = True
[importlinter:contract:credential-query-implementation-boundary]
name = Credential query implementations are wired only by composition or other repositories
type = forbidden
source_modules =
controllers
services
core
forbidden_modules =
repositories.credentials.query_repository
allow_indirect_imports = True
[importlinter:contract:pipeline-document-lookup-boundary]
name = Pipeline core depends on its document store port
type = forbidden
source_modules =
core.app.apps.pipeline.pipeline_generator
core.app.apps.pipeline.pipeline_runner
forbidden_modules =
repositories
allow_indirect_imports = True
[importlinter:contract:init-validation-service-boundary]
name = Initialization validation application service is framework and persistence neutral
@@ -295,16 +432,16 @@ source_modules =
services.account_avatar_service
services.account_change_email_ports
services.account_change_email_service
services.account_email_registration_service
services.account.email_registration_service
services.account_deletion_service
services.account_deletion_feedback_service
services.account_education_service
services.account_errors
services.account_forgot_password_service
services.account.forgot_password_service
services.account_initialization_service
services.account_integration_service
services.account_login_service
services.account_oauth_service
services.account.login_service
services.account.oauth_service
services.account_password_service
services.account_ports
services.account_profile_service
@@ -443,6 +580,35 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:web-authentication-service-boundary]
name = Web authentication application service is framework and persistence neutral
type = forbidden
source_modules =
services.web_authentication_service
services.entities.authentication_entities
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:web-authentication-adapters-persistence-boundary]
name = Web authentication adapters do not access persistence directly
type = forbidden
source_modules =
services.web_authentication_adapters
allow_indirect_imports = True
forbidden_modules =
configs
extensions.ext_database
models
repositories
sqlalchemy
[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
@@ -479,7 +645,7 @@ forbidden_modules =
name = Data source OAuth application service is framework and persistence neutral
type = forbidden
source_modules =
services.data_source_oauth_service
services.data_source.oauth_service
forbidden_modules =
configs
controllers
@@ -531,7 +697,7 @@ forbidden_modules =
name = Workspace member query application service is framework and persistence neutral
type = forbidden
source_modules =
services.workspace_member_query_service
services.workspace.member_service
forbidden_modules =
configs
controllers
@@ -540,7 +706,7 @@ forbidden_modules =
models
repositories
services.enterprise
services.workspace_member_role_resolver
services.workspace.gateways
sqlalchemy
werkzeug
@@ -666,6 +832,36 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:api-based-extension-application-service-boundary]
name = API-based extension application service is framework and persistence neutral
type = forbidden
source_modules =
services.api_based_extension_application_service
forbidden_modules =
configs
controllers
core
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:console-extension-controller-boundary]
name = Console extension controller reaches persistence only through the composition root
type = forbidden
source_modules =
controllers.console.extension
forbidden_modules =
extensions.ext_database
models
repositories
sqlalchemy
# The composition root (extensions.ext_application_services) wires repositories,
# so only direct imports are checked here.
allow_indirect_imports = True
[importlinter:contract:tool-file-download-service-boundary]
name = Tool file download application service is framework and persistence neutral
type = forbidden
@@ -697,6 +893,20 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-package-service-boundary]
name = Recommended package application service is framework and persistence neutral
type = forbidden
source_modules =
services.recommended_app_package_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-catalog-gateway-boundary]
name = Recommended app catalog gateway does not depend on Flask
type = forbidden
@@ -786,6 +996,86 @@ forbidden_modules =
tasks
werkzeug
[importlinter:contract:knowledge-segment-presentation-boundary]
name = Knowledge operations do not serialize HTTP responses
type = forbidden
allow_indirect_imports = True
source_modules =
services.knowledge.segments
services.knowledge.documents
services.knowledge.datasets
services.knowledge.external
forbidden_modules =
controllers
fields
[importlinter:contract:knowledge-response-persistence-boundary]
name = Knowledge response schemas do not load database state
type = forbidden
allow_indirect_imports = True
source_modules =
fields.dataset_fields
fields.document_fields
fields.segment_fields
forbidden_modules =
sqlalchemy
models
repositories
services.knowledge
[importlinter:contract:dataset-model-infrastructure-boundary]
name = Dataset models do not execute infrastructure or application operations
type = forbidden
allow_indirect_imports = True
source_modules =
models.dataset
forbidden_modules =
repositories
services
extensions
core.tools
models.engine
[importlinter:contract:app-mcp-server-service-boundary]
name = App MCP server application service is framework and persistence neutral
type = forbidden
source_modules =
services.app.mcp_server_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:console-app-mcp-server-controller-boundary]
name = Console App MCP server controller reaches persistence only through the composition root
type = forbidden
source_modules =
controllers.console.app.mcp_server
forbidden_modules =
extensions.ext_database
models.model
repositories
sqlalchemy
# The composition root (extensions.ext_application_services) wires repositories,
# so only direct imports are checked here.
allow_indirect_imports = True
[importlinter:contract:console-app-wraps-boundary]
name = Console app decorators load App rows without reaching into repositories
type = forbidden
source_modules =
controllers.console.app.wraps
forbidden_modules =
repositories
# models.model / models.workflow lazily import repositories.factory (legacy debt),
# so only direct imports are checked here.
allow_indirect_imports = True
[importlinter:contract:app-audio-boundary]
name = App audio operations and values are framework and persistence neutral
type = forbidden
@@ -801,3 +1091,20 @@ forbidden_modules =
services.audio_service
sqlalchemy
werkzeug
[importlinter:contract:account-lifecycle-boundary]
name = Account lifecycle use cases and contracts are framework and persistence neutral
type = forbidden
source_modules =
services.account.service
services.account.contracts
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
services.account.adapters
sqlalchemy
werkzeug

View File

@@ -15,7 +15,8 @@ Run direct Python commands through `uv run --project api`. Docker-backed integra
## Architecture And Boundaries
- Keep transport parsing and serialization in controllers, orchestration in services, and domain policy in `core/` or its domain owner. Keep `libs/` business-agnostic and reuse existing owners before adding abstractions.
- Treat `api/core/` as a migration-only area: do not add files or move implementations into it. Move responsibilities out; limit changes to existing callers to the adaptations needed for that migration.
- Keep transport parsing and serialization in controllers, orchestration in services, and domain policy in its domain owner. New or extracted implementations must stay outside `core/`. Keep `libs/` business-agnostic and reuse existing owners before adding abstractions.
- Before changing controller schemas, generated API contracts, or `SystemFeatureModel`, read `controllers/API_SCHEMA_GUIDE.md`. Treat `/system-features` as a minimal unauthenticated bootstrap allowlist, not a general configuration registry.
- Scope tenant-owned reads and writes by the complete owner chain, and propagate `tenant_id` across every affected layer. Reconstruct trusted internal references from validated database state after payload or async boundaries.
- Keep write transactions explicit and bounded. Do not perform external I/O inside an open transaction unless a documented consistency contract requires it.

View File

@@ -2,19 +2,11 @@
## Setup and Run
> [!IMPORTANT]
>
> In the v1.3.0 release, `poetry` has been replaced with
> [`uv`](https://docs.astral.sh/uv/) as the package manager
> for Dify API backend service.
`uv` and `pnpm` are required to run the setup and development commands below.
[`uv`](https://docs.astral.sh/uv/), Docker Compose, and the Node.js and pnpm versions pinned in the root `package.json` are required. Run the commands below from the repository root, with each long-running service in its own terminal.
### Using scripts (recommended)
The scripts resolve paths relative to their location, so you can run them from anywhere.
1. Run setup (copies env files and installs dependencies).
1. Run setup for a new checkout (copies env files and installs dependencies). This overwrites `api/.env`, `web/.env.local`, and `docker/middleware.env`; preserve existing configuration before rerunning it.
```bash
./dev/setup
@@ -44,7 +36,7 @@ The scripts resolve paths relative to their location, so you can run them from a
1. Set up your application by visiting `http://localhost:3000`.
1. Start the worker service (async and scheduler tasks, runs from `api`).
1. Start the worker service (executes queued async tasks).
```bash
./dev/start-worker
@@ -62,51 +54,35 @@ The scripts resolve paths relative to their location, so you can run them from a
>
> When the frontend and backend run on different subdomains, set COOKIE_DOMAIN to the site’s top-level domain (e.g., `example.com`). The frontend and backend must be under the same top-level domain in order to share authentication cookies.
- Generate a `SECRET_KEY` in the `.env` file.
bash for Linux
```bash
sed -i "/^SECRET_KEY=/c\\SECRET_KEY=$(openssl rand -base64 42)" .env
```
bash for Mac
```bash
secret_key=$(openssl rand -base64 42)
sed -i '' "/^SECRET_KEY=/c\\
SECRET_KEY=${secret_key}" .env
```
Leave `SECRET_KEY` empty to let Dify generate a persistent key in the storage directory. To manage it explicitly, generate a value with `openssl rand -base64 42` and set `SECRET_KEY` in `api/.env`.
## Testing
1. Install dependencies for both the backend and the test environment
Run from the repository root:
```bash
cd api
uv sync --group dev
```
1. Run the tests locally with mocked system environment variables in `tool.pytest_env` section in `pyproject.toml`, more can check [AGENTS.md](../AGENTS.md)
Continue in the `api` directory from the previous step.
```bash
uv run pytest # Run all tests
uv run pytest tests/unit_tests/ # Unit tests only
uv run pytest tests/integration_tests/ # Integration tests
# Code quality
../dev/reformat # Run all formatters and linters
uv run ruff check --fix ./ # Fix linting issues
uv run ruff format ./ # Format code
uv run pyrefly check # Type checking
```
## Generate TS stub
```
uv run dev/generate_swagger_specs.py --output-dir openapi
```bash
uv sync --project api --group dev
make test
make test TARGET_TESTS=./api/tests/unit_tests/<path>
make lint
make type-check
```
use https://jsontotable.org/openapi-to-typescript to convert to typescript
`make test` includes provider unit tests and runs controller tests separately. Integration suites are CI-only and are not expected to run locally. Test environment defaults live in `api/pyproject.toml` under `tool.pytest_env`; see the backend [agent guide](AGENTS.md) for package conventions.
## API contracts
The [API schema guide](controllers/API_SCHEMA_GUIDE.md) owns schema changes and verification. Generate the OpenAPI specifications and TypeScript/Zod contracts through the workspace script:
```bash
pnpm -C packages/contracts gen-api-contract
```
Refresh the checked-in Markdown reference with:
```bash
uv run --project api python api/dev/generate_swagger_markdown_docs.py \
--swagger-dir packages/contracts/openapi --markdown-dir api/openapi/markdown --keep-swagger-json
```
Update the backend schema owner and regenerate contracts instead of converting types manually or editing generated files.

View File

@@ -1,15 +1,11 @@
import base64
import secrets
import click
from sqlalchemy.orm import Session
from constants.languages import languages
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from libs.helper import email as email_validate
from libs.password import hash_password, password_pattern, valid_password
from services.account_email import normalize_email
from services.account_service import AccountService, RegisterService, TenantService
from libs.password import password_pattern, valid_password
@click.command("reset-password", help="Reset the account password.")
@@ -26,7 +22,7 @@ def reset_password(email, new_password, password_confirm):
return
normalized_email = email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=db.session())
account = application_services().accounts.lifecycle.get_account_by_email_with_case_fallback(email.strip())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -38,19 +34,7 @@ def reset_password(email, new_password, password_confirm):
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
return
# generate password salt
salt = secrets.token_bytes(16)
base64_salt = base64.b64encode(salt).decode()
# encrypt password with salt
password_hashed = hash_password(new_password, salt)
base64_password_hashed = base64.b64encode(password_hashed).decode()
with Session(db.engine) as session:
account = session.merge(account)
account.password = base64_password_hashed
account.password_salt = base64_salt
session.commit()
AccountService.reset_login_error_rate_limit(normalized_email)
application_services().accounts.lifecycle.reset_password(account.id, new_password, email=normalized_email)
click.echo(click.style("Password reset successfully.", fg="green"))
@@ -68,7 +52,7 @@ def reset_email(email, new_email, email_confirm):
return
normalized_new_email = new_email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=db.session())
account = application_services().accounts.lifecycle.get_account_by_email_with_case_fallback(email.strip())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -80,11 +64,7 @@ def reset_email(email, new_email, email_confirm):
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
return
with Session(db.engine) as session:
account = session.merge(account)
account.email = normalized_new_email
account.normalized_email = normalize_email(normalized_new_email)
session.commit()
application_services().accounts.lifecycle.set_email(account.id, normalized_new_email)
click.echo(click.style("Email updated successfully.", fg="green"))
@@ -129,15 +109,14 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
return
# register account
account = RegisterService.register(
account = application_services().accounts.lifecycle.register(
email=email,
name=account_name,
password=new_password,
language=language,
create_workspace_required=False,
session=db.session(),
)
TenantService.create_owner_tenant_if_not_exist(account, name, session=db.session())
application_services().workspaces.provisioning.create_owner_workspace(account.id, name=name, if_missing=True)
click.echo(
click.style(

View File

@@ -463,6 +463,36 @@ def _workspace_member_account_id_batches(tenant_id: str, batch_size: int) -> Ite
last_join_id = str(rows[-1].id)
def _workspace_non_admin_account_id_batches(
tenant_id: str,
operator_account_id: str,
batch_size: int,
) -> Iterator[list[str]]:
"""Yield workspace members except accounts bound to RBAC's builtin Owner/Admin roles.
Resource administrators receive access implicitly through RBAC and their
resource access-policy bindings are locked. Resolve their current RBAC
bindings in batches instead of relying on the legacy workspace role column.
"""
for batch in _workspace_member_account_id_batches(tenant_id, batch_size):
member_roles = RBACService.MemberRoles.batch_get(
tenant_id=tenant_id,
account_id=operator_account_id,
member_account_ids=batch,
)
protected_account_ids = {
member.account_id
for member in member_roles
if any(
role.is_builtin and role.category == "global_system_default" and role.role_tag in {"owner", "admin"}
for role in member.roles
)
}
writable_batch = [account_id for account_id in batch if account_id not in protected_account_ids]
if writable_batch:
yield writable_batch
def _replace_resource_whitelist(
resource_type: str,
*,
@@ -725,7 +755,7 @@ def migrate_resource_whitelist_scopes_to_automatic_include(
migrated_count += 1
if scope is RBACResourceWhitelistScope.ALL:
for batch in _workspace_member_account_id_batches(workspace_id, member_batch_size):
for batch in _workspace_non_admin_account_id_batches(workspace_id, operator_account_id, member_batch_size):
_replace_resource_default_access_policies(
current_resource_type,
tenant_id=workspace_id,
@@ -877,7 +907,7 @@ def migrate_only_me_resource_whitelist_scopes_to_automatic_include(
)
migrated_count += 1
for batch in _workspace_member_account_id_batches(workspace_id, member_batch_size):
for batch in _workspace_non_admin_account_id_batches(workspace_id, operator_account_id, member_batch_size):
_replace_resource_default_access_policies(
current_resource_type,
tenant_id=workspace_id,
@@ -1187,7 +1217,11 @@ def _report_backing_app_specific_whitelist(options: _AgentAccessBootstrapOptions
def _write_agent_access_rows(options: _AgentAccessBootstrapOptions) -> None:
for batch in _workspace_member_account_id_batches(options.tenant_id, options.member_batch_size):
for batch in _workspace_non_admin_account_id_batches(
options.tenant_id,
options.operator_account_id,
options.member_batch_size,
):
RBACService.AgentAccess.replace_user_access_policies(
tenant_id=options.tenant_id,
account_id=options.operator_account_id,

View File

@@ -19,6 +19,7 @@ from models.dataset import Dataset, DatasetCollectionBinding, DatasetMetadata, D
from models.dataset import Document as DatasetDocument
from models.enums import DatasetMetadataType, IndexingStatus, SegmentStatus
from models.model import App, AppAnnotationSetting, MessageAnnotation
from repositories.knowledge.dataset_read_repository import get_segment_child_chunks
@click.command("vdb-migrate", help="Migrate vector db.")
@@ -278,7 +279,7 @@ def migrate_knowledge_vector_database():
},
)
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
child_chunks = segment.get_child_chunks(session=db_session)
child_chunks = get_segment_child_chunks(segment, session=db_session)
if child_chunks:
child_documents = []
for child_chunk in child_chunks:

View File

@@ -1,3 +1,5 @@
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings
@@ -41,3 +43,8 @@ class ArchiveStorageConfig(BaseSettings):
description="Region for storage (use 'auto' if the provider supports it)",
default="auto",
)
ARCHIVE_STORAGE_ADDRESS_STYLE: Literal["path", "virtual", "auto"] = Field(
description="S3 addressing style for archive storage: 'path' (default), 'virtual' (virtual-hosted), or 'auto'",
default="path",
)

View File

@@ -1,4 +1,12 @@
from collections import OrderedDict
from http import HTTPStatus
from typing import cast
from flask import current_app, got_request_exception
from flask_restx import Api
from libs.exception import BaseHTTPException
from services.errors.base import NoPermissionError
class FilenameNotExistsError(BaseHTTPException):
@@ -84,6 +92,39 @@ class NotFoundError(BaseHTTPException):
code = 404
class UnauthorizedError(BaseHTTPException):
error_code = "unauthorized"
code = HTTPStatus.UNAUTHORIZED
description = "Authentication is required."
class InternalServerError(BaseHTTPException):
"""Expose a safe response while retaining the original exception in server logs."""
error_code = "internal_server_error"
code = HTTPStatus.INTERNAL_SERVER_ERROR
description = (
"The server encountered an internal error and was unable to complete your request. "
"Either the server is overloaded or there is an error in the application."
)
class InvalidArgumentError(BaseHTTPException):
error_code = "invalid_param"
code = 400
def register_permission_error_handler(api: Api) -> None:
"""Preserve the default permission-error response for Console and Service API."""
@api.errorhandler(NoPermissionError)
def handle_permission_error(error: NoPermissionError) -> tuple[dict[str, str | int], int]:
got_request_exception.send(current_app, exception=error)
return {
"code": InvalidArgumentError.error_code,
"message": str(error),
"status": HTTPStatus.BAD_REQUEST,
}, HTTPStatus.BAD_REQUEST
# Flask-RESTX picks the first matching handler, including the generic Exception handler.
cast(OrderedDict[type[Exception], object], api.error_handlers).move_to_end(NoPermissionError, last=False)

View File

@@ -4,6 +4,7 @@ from .checks import RBAC_CHECKS_ATTR, RBACCheck, enforce_rbac_checks
from .locators import (
AgentBehindApp,
AgentId,
DatasetByDocument,
DatasetByPipeline,
DatasetId,
PlainApp,
@@ -16,6 +17,7 @@ __all__ = [
"RBAC_CHECKS_ATTR",
"AgentBehindApp",
"AgentId",
"DatasetByDocument",
"DatasetByPipeline",
"DatasetId",
"PlainApp",

View File

@@ -15,6 +15,7 @@ if TYPE_CHECKING:
__all__ = [
"AgentBehindApp",
"AgentId",
"DatasetByDocument",
"DatasetByPipeline",
"DatasetId",
"PlainApp",
@@ -116,7 +117,7 @@ class AgentBehindApp(_ParamLocator):
binding = agent_binding(tenant_id, _required(path_args, self.param))
if binding is None or binding.scope == AgentScope.WORKFLOW_ONLY:
return None
return ResourceIdentity(self.scope, str(binding.id))
return ResourceIdentity(self.scope, binding.id)
def owner_id(self, tenant_id: str, identity: ResourceIdentity) -> str | None:
return None
@@ -133,6 +134,18 @@ class DatasetId(_ParamLocator):
return RBACResourceService.get_dataset_maintainer(db.session, tenant_id, identity.id)
class DatasetByDocument(DatasetId):
default_param = "document_id"
@override
def locate(self, tenant_id: str, path_args: Mapping[str, object]) -> ResourceIdentity | None:
document_id = _required(path_args, self.param)
dataset_id = RBACResourceService.get_dataset_id_by_document(db.session, tenant_id, document_id)
if dataset_id is None:
raise NotFound("Document not found")
return ResourceIdentity(self.scope, dataset_id)
class DatasetByPipeline(DatasetId):
default_param = "pipeline_id"

View File

@@ -6,6 +6,7 @@ from typing import cast
from flask import Blueprint, current_app, got_request_exception
from flask_restx import Namespace
from controllers.common.errors import register_permission_error_handler
from libs.external_api import ExternalApi
from machinery.errors import ActiveWorkspaceRequiredError
@@ -17,6 +18,7 @@ api = ExternalApi(
title="Console API",
description="Console management APIs for app configuration, monitoring, and administration",
)
register_permission_error_handler(api)
@api.errorhandler(ActiveWorkspaceRequiredError)

View File

@@ -24,7 +24,7 @@ from services.auth.api_key_contracts import (
ApiKeyNotFoundError,
ApiKeyResourceNotFoundError,
)
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.knowledge.api_key_service import UnknownDatasetIdsError
API_KEY_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})

View File

@@ -7,14 +7,16 @@ import io
import json
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, Literal
from functools import wraps
from typing import Any, Concatenate, Literal
from uuid import UUID
from flask import Response, request, send_file, url_for
from flask import Response, make_response, request, send_file, url_for
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from controllers.common.fields import BinaryFileResponse
from controllers.common.rbac import AgentId, PlainApp, RBACCheck
from controllers.common.schema import (
query_params_from_model,
@@ -25,6 +27,8 @@ from controllers.common.schema import (
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import AppUnavailableError
from controllers.console.app.preview_admission import get_preview_app
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -36,7 +40,11 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_application_services import application_services
from fields.agent_fields import AgentAppComposerResponse
from fields.base import ResponseModel
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from models.model import App, AppMode
@@ -50,6 +58,8 @@ from services.agent_config_service import (
ConfigPushPayload,
ConfigPushSkillItem,
)
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import AppPreviewRef
class AgentConfigQuery(BaseModel):
@@ -215,6 +225,7 @@ class AgentConfigDeleteResponse(ResponseModel):
register_schema_models(console_ns, AgentConfigFileUploadPayload)
register_response_schema_models(
console_ns,
BinaryFileResponse,
AgentConfigDeleteResponse,
AgentConfigDownloadResponse,
AgentConfigFileItemResponse,
@@ -239,7 +250,7 @@ register_response_schema_models(
class _ResolvedConsoleTarget:
tenant_id: str
agent_id: str
account_id: str
account_id: str | None
version_id: str
version_kind: AgentConfigVersionKind
@@ -247,6 +258,51 @@ class _ResolvedConsoleTarget:
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
class TrialAgentConfigQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUID | None = Field(default=None, description="Must match the current published template snapshot")
class TrialAgentSkillFileQuery(TrialAgentConfigQuery):
path: str = Field(description="Normalized member path inside the published Skill package")
register_schema_models(console_ns, TrialAgentConfigQuery, TrialAgentSkillFileQuery)
def _with_trial_config[T, Q: TrialAgentConfigQuery, **P](
view: Callable[Concatenate[T, _ResolvedConsoleTarget, Q, P], Response | dict[str, object]],
) -> Callable[Concatenate[T, Q, AppPreviewRef, P], Response]:
@wraps(view)
def decorated(self: T, query: Q, app: AppPreviewRef, /, *args: P.args, **kwargs: P.kwargs) -> Response:
try:
state = AgentAppComposerResponse.model_validate(
application_services().app_previews.get_agent_composer(app=app)
)
snapshot = state.active_config_snapshot
if snapshot is None or (query.version_id is not None and str(query.version_id) != snapshot.id):
raise AgentConfigServiceError(
"config_version_not_found", "published template version is unavailable", status_code=404
)
target = _ResolvedConsoleTarget(
tenant_id=app.tenant_id,
agent_id=state.agent.id,
account_id=None,
version_id=snapshot.id,
version_kind=AgentConfigVersionKind.SNAPSHOT,
)
response = make_response(view(self, target, query, *args, **kwargs))
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except AgentConfigServiceError as exc:
response = make_response(_handle(exc))
response.headers["Cache-Control"] = "no-store"
return response
return decorated
def _service() -> AgentConfigService:
return AgentConfigService()
@@ -481,6 +537,7 @@ def _read_single_upload() -> tuple[bytes, str]:
def _skill_upload_response(target: _ResolvedConsoleTarget) -> tuple[dict[str, object], int]:
assert target.account_id is not None
return _upload_skill_for_target(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -517,6 +574,7 @@ def _upload_skill_for_target(
def _file_upload_response(
target: _ResolvedConsoleTarget, payload: AgentConfigFileUploadPayload
) -> tuple[dict[str, object], int]:
assert target.account_id is not None
manifest = _service().push_file_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -616,6 +674,7 @@ def _skill_file_raw_download_response(target: _ResolvedConsoleTarget, name: str,
def _skill_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
assert target.account_id is not None
_service().push_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -654,6 +713,7 @@ def _file_download_response(target: _ResolvedConsoleTarget, name: str) -> Respon
def _file_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
assert target.account_id is not None
_service().push_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -1360,5 +1420,124 @@ class AgentConfigFileApi(Resource):
)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills")
class TrialAgentConfigSkillsApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigSkillListResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
return dump_response(AgentConfigSkillListResponse, _skill_list_response(target))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files")
class TrialAgentConfigFilesApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFileListResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
return dump_response(AgentConfigFileListResponse, _file_list_response(target))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/inspect")
class TrialAgentConfigSkillInspectApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(
200, "Published template resource", console_ns.models[AgentConfigSkillInspectResponse.__name__]
)
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _skill_inspect_response(target, name)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/download")
class TrialAgentConfigSkillDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _skill_download_response(target, name)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/preview")
class TrialAgentConfigSkillFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.response(
200, "Published template resource", console_ns.models[AgentConfigSkillFilePreviewResponse.__name__]
)
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> dict[str, object]:
return dump_response(
AgentConfigSkillFilePreviewResponse, _skill_file_preview_response(target, name, query.path)
)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/download")
class TrialAgentConfigSkillFileDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
assert request.view_args is not None
return _skill_file_download_response(
target,
name=name,
path=query.path,
raw_endpoint="console.trial_agent_config_skill_file_content",
route_params={"app_id": str(request.view_args["app_id"])},
)
@console_ns.route(
"/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/content",
endpoint="trial_agent_config_skill_file_content",
)
class TrialAgentConfigSkillFileContentApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.doc(
produces=["application/octet-stream"],
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/octet-stream"]},
)
@console_ns.response(200, "Published template resource", console_ns.models[BinaryFileResponse.__name__])
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
return _skill_file_raw_download_response(target, name, query.path)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/preview")
class TrialAgentConfigFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFilePreviewResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> dict[str, object]:
return dump_response(AgentConfigFilePreviewResponse, _file_preview_response(target, name))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/download")
class TrialAgentConfigFileDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _file_download_response(target, name)
# pyrefly: ignore [unresolvable-dunder-all]
__all__ = [name for name, value in globals().items() if inspect.isclass(value) and issubclass(value, Resource)]

View File

@@ -79,7 +79,8 @@ from services.entities.app_entities import (
UpdateAppParams,
)
from services.entities.dsl_entities import ImportStatus
from services.entities.knowledge_entities.knowledge_entities import (
from services.errors.base import NoPermissionError
from services.knowledge.entities.knowledge_entities import (
DataSource,
InfoList,
NotionIcon,
@@ -91,7 +92,6 @@ from services.entities.knowledge_entities.knowledge_entities import (
WeightModel,
WeightVectorSetting,
)
from services.errors.account import NoPermissionError
register_enum_models(console_ns, IconType)

View File

@@ -8,6 +8,7 @@ from werkzeug.exceptions import Forbidden
from controllers.common.rbac import PlainApp, RBACCheck, Workspace
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console.app.error import AppNotFoundError
from controllers.console.explore.error import RecommendedAppNotFoundError as RecommendedAppNotFoundHttpError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
@@ -16,7 +17,8 @@ from models.account import TenantAccountRole
from services.agent.errors import InvalidRosterAgentPackageError
from services.app.console_service import ConsoleAppNotFoundError
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.recommended_app_query_service import RecommendedAppNotFoundError
from .. import console_ns
@@ -86,6 +88,8 @@ class AppImportApi(Resource):
result = application_services().apps.console.import_app(context, payload, source=source)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
except RecommendedAppNotFoundError:
raise RecommendedAppNotFoundHttpError() from None
status_code = {ImportStatus.FAILED: HTTPStatus.BAD_REQUEST, ImportStatus.PENDING: HTTPStatus.ACCEPTED}.get(
result.status, HTTPStatus.OK
)

View File

@@ -455,14 +455,15 @@ class AgentTextToSpeechVoicesApi(Resource):
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@model_validate(TextToSpeechVoiceQuery)
def get(
self,
req_data: TextToSpeechVoiceQuery,
session: Session,
current_tenant_id: str,
current_user: Account,
agent_id: UUID,
):
query = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))
enforce_rbac_checks(
tenant_id=current_tenant_id,
account_id=current_user.id,
@@ -474,4 +475,4 @@ class AgentTextToSpeechVoicesApi(Resource):
tenant_id=current_tenant_id,
agent_id=agent_id,
)
return _get_text_to_speech_voices(tenant_id=app_model.tenant_id, language=query.language)
return _get_text_to_speech_voices(tenant_id=app_model.tenant_id, language=req_data.language)

View File

@@ -401,6 +401,7 @@ class InstructionGenerateApi(Resource):
instruction=req_data.instruction,
model_config=req_data.model_config_data,
ideal_output=req_data.ideal_output,
session=session,
)
if req_data.node_id != "" and req_data.current != "": # For workflow node
return LLMGenerator.instruction_modify_workflow(

View File

@@ -1,32 +1,31 @@
import json
from datetime import datetime
from typing import Any
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from werkzeug.exceptions import Conflict, NotFound
from controllers.common.rbac import PlainApp, RBACCheck
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
edit_permission_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
)
from extensions.ext_database import db
from controllers.console.app.error import AppNotFoundError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models.enums import AppMCPServerStatus
from models.model import App, AppMCPServer
from services.app_ref_service import AppRefService
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.app.mcp_server_service import (
AppMCPServerAlreadyExistsError,
AppMCPServerAppNotFoundError,
AppMCPServerNotFoundError,
AppMCPServerStatus,
)
_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
class MCPServerCreatePayload(BaseModel):
@@ -38,13 +37,13 @@ class MCPServerCreatePayload(BaseModel):
class MCPServerUpdatePayload(BaseModel):
id: str = Field(..., description="Server ID")
id: UUID = Field(..., description="Server ID")
description: str | None = Field(default=None, description="Server description")
parameters: dict[str, Any] = Field(
...,
description="Server parameters configuration",
)
status: str | None = Field(default=None, description="Server status")
status: AppMCPServerStatus | None = Field(default=None, description="Server status")
class AppMCPServerResponse(ResponseModel):
@@ -84,13 +83,12 @@ class AppMCPServerController(Resource):
@console_ns.response(
200, "MCP server configuration retrieved successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@login_required
@account_initialization_required
@setup_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@get_app_model
def get(self, app_model: App):
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
@console_account_admission(rbac_checks=[RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())])
def get(self, request_context: RequestContext, app_id: UUID):
try:
server = application_services().app_mcp_servers.get(request_context, str(app_id))
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
if server is None:
return {}
return dump_response(AppMCPServerResponse, server)
@@ -103,30 +101,18 @@ class AppMCPServerController(Resource):
201, "MCP server configuration created successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@console_ns.response(403, "Insufficient permissions")
@account_initialization_required
@login_required
@setup_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
@with_current_tenant_id
@get_app_model
@model_validate(MCPServerCreatePayload)
def post(self, req_data: MCPServerCreatePayload, current_tenant_id: str, app_model: App):
description = req_data.description
if not description:
description = app_model.description or ""
server = AppMCPServer(
name=app_model.name,
description=description,
parameters=json.dumps(req_data.parameters, ensure_ascii=False),
status=AppMCPServerStatus.ACTIVE,
app_id=app_model.id,
tenant_id=current_tenant_id,
server_code=AppMCPServer.generate_server_code(16, session=db.session()),
)
db.session.add(server)
db.session.commit()
@console_ns.response(409, "MCP server already exists for this app")
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
def post(self, request_context: RequestContext, app_id: UUID):
payload = validate_request(MCPServerCreatePayload)
try:
server = application_services().app_mcp_servers.create(
request_context, str(app_id), description=payload.description, parameters=payload.parameters
)
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
except AppMCPServerAlreadyExistsError as error:
raise Conflict(str(error)) from error
return dump_response(AppMCPServerResponse, server), 201
@console_ns.doc("update_app_mcp_server")
@@ -138,43 +124,22 @@ class AppMCPServerController(Resource):
)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found")
@login_required
@setup_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
@get_app_model
@model_validate(MCPServerUpdatePayload)
def put(self, req_data: MCPServerUpdatePayload, app_model: App):
app_ref = AppRefService.create_app_ref(app_model)
server_ref = AppRefService.create_mcp_server_ref(app_ref, req_data.id)
server = db.session.scalar(
select(AppMCPServer)
.where(
AppMCPServer.id == server_ref.server_id,
AppMCPServer.tenant_id == server_ref.app.tenant_id,
AppMCPServer.app_id == server_ref.app.app_id,
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
def put(self, request_context: RequestContext, app_id: UUID):
payload = validate_request(MCPServerUpdatePayload)
try:
server = application_services().app_mcp_servers.update(
request_context,
str(app_id),
server_id=str(payload.id),
description=payload.description,
parameters=payload.parameters,
status=payload.status,
)
.limit(1)
)
if not server:
raise NotFound()
description = req_data.description
if description is None or not description:
server.description = app_model.description or ""
else:
server.description = description
server.name = app_model.name
server.parameters = json.dumps(req_data.parameters, ensure_ascii=False)
if req_data.status:
try:
server.status = AppMCPServerStatus(req_data.status)
except ValueError:
raise ValueError("Invalid status")
db.session.commit()
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
except AppMCPServerNotFoundError as error:
raise NotFound from error
return dump_response(AppMCPServerResponse, server)
@@ -186,21 +151,12 @@ class AppMCPServerRefreshController(Resource):
@console_ns.response(200, "MCP server refreshed successfully", console_ns.models[AppMCPServerResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
@with_current_tenant_id
@get_app_model
def post(self, current_tenant_id: str, app_model: App):
server = db.session.scalar(
select(AppMCPServer)
.where(AppMCPServer.app_id == app_model.id, AppMCPServer.tenant_id == current_tenant_id)
.limit(1)
)
if not server:
raise NotFound()
server.server_code = AppMCPServer.generate_server_code(16, session=db.session())
db.session.commit()
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
def post(self, request_context: RequestContext, app_id: UUID):
try:
server = application_services().app_mcp_servers.refresh(request_context, str(app_id))
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
except AppMCPServerNotFoundError as error:
raise NotFound from error
return dump_response(AppMCPServerResponse, server)

View File

@@ -1,4 +1,5 @@
import logging
from http import HTTPStatus
from typing import Literal
from uuid import UUID
@@ -6,7 +7,7 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
from controllers.common.fields import SimpleResultResponse, TextFileResponse
@@ -16,6 +17,8 @@ from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import (
AppNotFoundError,
AppUnavailableError,
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
@@ -23,6 +26,7 @@ from controllers.console.app.error import (
)
from controllers.console.app.wraps import get_app_model
from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
@@ -33,9 +37,9 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from core.app.entities.app_invoke_entities import InvokeFrom
from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.conversation_fields import (
@@ -46,13 +50,19 @@ from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import dump_response, uuid_value
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.login import login_required
from machinery.context import RequestContext
from models.account import Account
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from services.agent.errors import AgentNotFoundError
from services.app.agent_app_contracts import AgentAppNotFoundError
from services.app.console_service import ConsoleAppNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.conversation_service import ConversationService
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from services.message_service import MessageService, attach_message_extra_contents
from services.message_service import attach_message_extra_contents
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
logger = logging.getLogger(__name__)
@@ -296,21 +306,25 @@ class MessageSuggestedQuestionApi(Resource):
@console_ns.doc(description="Get suggested questions for a message")
@console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"})
@console_ns.response(
200,
HTTPStatus.OK,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@console_ns.response(404, "Message or conversation not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@with_session(write=False)
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def get(self, session: Session, current_user: Account, app_model: App, message_id: UUID):
@console_ns.response(HTTPStatus.BAD_REQUEST, "App or model provider unavailable, or generation failed")
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Account authentication required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions or suggested questions disabled")
@console_ns.response(HTTPStatus.NOT_FOUND, "App, message, or conversation not found")
@console_ns.response(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected server error")
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
def get(self, context: RequestContext, app_id: UUID, message_id: UUID) -> dict[str, object]:
try:
app = application_services().apps.console.get_reference(context, str(app_id))
except ConsoleAppNotFoundError as exc:
raise AppNotFoundError() from exc
if app.mode not in (AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT):
raise AppNotFoundError("App mode does not support suggested questions")
return _get_message_suggested_questions(
session=session, current_user=current_user, app_model=app_model, message_id=message_id
context=context, app_id=app.id, app_mode=app.mode, message_id=message_id
)
@@ -320,26 +334,23 @@ class AgentMessageSuggestedQuestionApi(Resource):
@console_ns.doc(description="Get suggested questions for an Agent App message")
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
@console_ns.response(
200,
HTTPStatus.OK,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@console_ns.response(404, "Agent, message, or conversation not found")
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_TEST_AND_RUN, AgentId()))
@with_current_user
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_runtime_app_model(
session=session,
tenant_id=current_tenant_id,
agent_id=agent_id,
)
@console_ns.response(HTTPStatus.BAD_REQUEST, "App or model provider unavailable, or generation failed")
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Account authentication required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions or suggested questions disabled")
@console_ns.response(HTTPStatus.NOT_FOUND, "Agent, message, or conversation not found")
@console_ns.response(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected server error")
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_TEST_AND_RUN, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID, message_id: UUID) -> dict[str, object]:
try:
app_id = application_services().agent_apps.access.resolve_existing_runtime_app_id(context, str(agent_id))
except AgentAppNotFoundError as exc:
raise AgentNotFoundError() from exc
return _get_message_suggested_questions(
session=session, current_user=current_user, app_model=app_model, message_id=message_id
context=context, app_id=app_id, app_mode=AppMode.AGENT, message_id=message_id
)
@@ -552,17 +563,22 @@ def _update_message_feedback(
return SimpleResultResponse(result="success").model_dump(mode="json")
def _get_message_suggested_questions(*, session: Session, current_user: Account, app_model: App, message_id: UUID):
message_id_str = str(message_id)
def _get_message_suggested_questions(
*, context: RequestContext, app_id: str, app_mode: str, message_id: UUID
) -> dict[str, object]:
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model,
message_id=message_id_str,
user=current_user,
invoke_from=InvokeFrom.DEBUGGER,
session=session,
questions = application_services().message_suggested_questions.get_suggested_questions(
app_id=app_id,
app_owner_tenant_id=context.active_workspace_id,
expected_app_mode=app_mode,
actor=SuggestedQuestionsAccount(account_id=context.account_id, invoke_from="debugger"),
message_id=str(message_id),
)
except AppDefinitionUnavailableError as exc:
raise AppUnavailableError() from exc
except SuggestedQuestionsActorNotFoundError as exc:
raise Unauthorized("Account no longer exists") from exc
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
@@ -577,6 +593,8 @@ def _get_message_suggested_questions(*, session: Session, current_user: Account,
raise CompletionRequestError(e.description)
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except HTTPException:
raise
except Exception:
logger.exception("internal server error.")
raise InternalServerError()

View File

@@ -1,9 +1,6 @@
import json
from typing import Any, cast
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.fields import SimpleResultResponse
@@ -11,6 +8,7 @@ from controllers.common.rbac import PlainApp, RBACCheck
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.app.model_config_payload import AppModelConfigPayload
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -30,47 +28,7 @@ from libs.login import login_required
from models.model import App, AppMode, AppModelConfig
from services.app_model_config_service import AppModelConfigService
class ModelConfigRequest(BaseModel):
provider: str | None = Field(default=None, description="Model provider")
model: str | None = Field(default=None, description="Model name")
configs: dict[str, Any] | None = Field(
default=None,
description="Model configuration parameters",
)
opening_statement: str | None = Field(default=None, description="Opening statement")
suggested_questions: list[str] | None = Field(default=None, description="Suggested questions")
more_like_this: dict[str, Any] | None = Field(
default=None,
description="More like this configuration",
)
speech_to_text: dict[str, Any] | None = Field(
default=None,
description="Speech to text configuration",
)
text_to_speech: dict[str, Any] | None = Field(
default=None,
description="Text to speech configuration",
)
retrieval_model: dict[str, Any] | None = Field(
default=None,
description="Retrieval model configuration",
)
tools: list[dict[str, Any]] | None = Field(
default=None,
description="Available tools",
)
dataset_configs: dict[str, Any] | None = Field(
default=None,
description="Dataset configurations",
)
agent_mode: dict[str, Any] | None = Field(
default=None,
description="Agent mode configuration",
)
register_schema_models(console_ns, ModelConfigRequest)
register_schema_models(console_ns, AppModelConfigPayload)
register_response_schema_models(console_ns, SimpleResultResponse)
@@ -79,7 +37,7 @@ class ModelConfigResource(Resource):
@console_ns.doc("update_app_model_config")
@console_ns.doc(description="Update application model configuration")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[ModelConfigRequest.__name__])
@console_ns.expect(console_ns.models[AppModelConfigPayload.__name__])
@console_ns.response(
200,
"Model configuration updated successfully",
@@ -98,10 +56,11 @@ class ModelConfigResource(Resource):
@get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION])
def post(self, session: Session, current_tenant_id: str, current_user_id: str, app_model: App):
"""Modify the app model config and dataset joins in one request transaction."""
# validate config
payload = AppModelConfigPayload.model_validate(console_ns.payload or {})
model_configuration = AppModelConfigService.validate_configuration(
tenant_id=current_tenant_id,
config=cast(dict, request.json),
# Preserve omission so feature validators retain their existing defaults.
config=payload.model_dump(exclude_unset=True),
app_mode=AppMode.value_of(app_model.mode),
session=session,
)

View File

@@ -0,0 +1,384 @@
from typing import Literal, NotRequired
from pydantic import BaseModel, ConfigDict, Field, with_config
from typing_extensions import TypedDict
from core.entities.agent_entities import PlanningStrategy
from core.rag.entities.metadata_entities import SupportedComparisonOperator
from core.rag.rerank.rerank_type import RerankMode
from core.tools.entities.tool_entities import ToolProviderType
from graphon.file.enums import FileTransferMethod, FileType
from graphon.model_runtime.entities.llm_entities import LLMMode
# The recursive schema also keeps generated clients from turning JSON values into `any`.
type AppConfigJsonValue = str | int | float | bool | None | list[AppConfigJsonValue] | dict[str, AppConfigJsonValue]
# Mypy does not yet support PEP 728 extra_items; pyrefly and Pydantic check these declarations.
# https://github.com/python/mypy/issues/18176
class AppModelSelectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
provider: str
name: str
completion_params: dict[str, AppConfigJsonValue]
mode: NotRequired[str | None]
# These config managers persist nested extensions alongside their known fields.
# Keep those JSON values when an existing configuration is published again.
class AppPromptMessagePayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
text: str
role: str
class AppChatPromptPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
prompt: NotRequired[list[AppPromptMessagePayload]]
class AppConversationRolesPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
user_prefix: str
assistant_prefix: str
class AppCompletionPromptTextPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
text: str
class AppCompletionPromptPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
prompt: NotRequired[AppCompletionPromptTextPayload]
conversation_histories_role: NotRequired[AppConversationRolesPayload]
@with_config(ConfigDict(extra="allow"))
class AppInputFieldBasePayload(TypedDict):
label: str
variable: str
description: NotRequired[str]
required: NotRequired[bool | None]
default: NotRequired[AppConfigJsonValue]
max_length: NotRequired[int | None]
hide: NotRequired[bool]
type: NotRequired[str]
enabled: NotRequired[bool]
config: NotRequired[dict[str, AppConfigJsonValue]]
icon: NotRequired[str | None]
icon_background: NotRequired[str | None]
json_schema: NotRequired[str | dict[str, AppConfigJsonValue] | None]
class AppInputFieldPayload(AppInputFieldBasePayload, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
options: NotRequired[list[str]]
class AppSelectFieldPayload(AppInputFieldBasePayload, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
options: NotRequired[list[str] | None]
class AppTextInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid", serialize_by_alias=True)
text_input: AppInputFieldPayload = Field(alias="text-input")
class AppSelectInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
select: AppSelectFieldPayload
class AppParagraphInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
paragraph: AppInputFieldPayload
class AppNumberInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
number: AppInputFieldPayload
class AppCheckboxInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
checkbox: AppInputFieldPayload
class AppExternalDataInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
external_data_tool: AppInputFieldPayload
type AppUserInputFormPayload = (
AppTextInputPayload
| AppSelectInputPayload
| AppParagraphInputPayload
| AppNumberInputPayload
| AppCheckboxInputPayload
| AppExternalDataInputPayload
)
class AppFeaturePayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
class AppSuggestedQuestionsModelPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
provider: str
name: str
mode: NotRequired[LLMMode | Literal[""]]
completion_params: NotRequired[dict[str, AppConfigJsonValue]]
class AppSuggestedQuestionsPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
prompt: NotRequired[str]
model: NotRequired[AppSuggestedQuestionsModelPayload]
class AppTextToSpeechPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
voice: NotRequired[str]
language: NotRequired[str]
autoPlay: NotRequired[Literal["enabled", "disabled"]]
class AppModerationContentPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
preset_response: NotRequired[str | None]
class AppModerationConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
inputs_config: NotRequired[AppModerationContentPayload | None]
outputs_config: NotRequired[AppModerationContentPayload | None]
keywords: NotRequired[str | None]
api_based_extension_id: NotRequired[str | None]
class AppModerationPayload(BaseModel):
enabled: bool | None = None
type: str | None = None
config: AppModerationConfigPayload | None = None
class AppExternalDataToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
type: NotRequired[str]
label: NotRequired[str]
variable: NotRequired[str]
config: NotRequired[dict[str, AppConfigJsonValue]]
icon: NotRequired[str]
icon_background: NotRequired[str]
class AppDatasetSelectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
id: NotRequired[str]
enabled: NotRequired[bool]
class AppLegacyDatasetSelectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
id: NotRequired[str]
enabled: NotRequired[bool | None]
class AppDatasetToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
dataset: AppDatasetSelectionPayload
class AppEmptyDatasetCollectionPayload(TypedDict, closed=True):
pass
class AppDatasetCollectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
strategy: NotRequired[str]
datasets: list[AppDatasetToolPayload]
class AppRerankingModelPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
reranking_provider_name: NotRequired[str]
reranking_model_name: NotRequired[str]
class AppVectorWeightPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
vector_weight: float
embedding_provider_name: str
embedding_model_name: str
class AppKeywordWeightPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
keyword_weight: float
class AppRetrievalWeightsPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
weight_type: NotRequired[Literal["semantic_first", "keyword_first", "customized"]]
vector_setting: AppVectorWeightPayload
keyword_setting: AppKeywordWeightPayload
class AppMetadataModelPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
provider: NotRequired[str]
name: NotRequired[str]
mode: NotRequired[LLMMode | Literal[""]]
completion_params: NotRequired[dict[str, AppConfigJsonValue]]
class AppMetadataConditionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
id: NotRequired[str]
name: str
metadata_id: NotRequired[str]
comparison_operator: SupportedComparisonOperator
value: NotRequired[str | int | float | list[str] | None]
class AppMetadataFilteringPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
logical_operator: NotRequired[Literal["and", "or"] | None]
conditions: NotRequired[list[AppMetadataConditionPayload] | None]
class AppDatasetConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
retrieval_model: NotRequired[Literal["single", "multiple"]]
datasets: NotRequired[AppDatasetCollectionPayload | AppEmptyDatasetCollectionPayload | None]
top_k: NotRequired[int]
score_threshold: NotRequired[float | None]
score_threshold_enabled: NotRequired[bool]
reranking_model: NotRequired[AppRerankingModelPayload | None]
weights: NotRequired[AppRetrievalWeightsPayload | None]
reranking_enabled: NotRequired[bool]
reranking_enable: NotRequired[bool]
reranking_mode: NotRequired[RerankMode]
metadata_filtering_mode: NotRequired[Literal["disabled", "automatic", "manual"]]
metadata_model_config: NotRequired[AppMetadataModelPayload | None]
metadata_filtering_conditions: NotRequired[AppMetadataFilteringPayload | None]
class AppAgentPromptPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
first_prompt: NotRequired[str]
next_iteration: NotRequired[str]
class AppProviderAgentToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
provider_type: ToolProviderType
provider_id: str
provider_name: NotRequired[str]
tool_name: str
tool_label: NotRequired[str]
tool_parameters: dict[str, AppConfigJsonValue]
plugin_unique_identifier: NotRequired[str | None]
credential_id: NotRequired[str | None]
isDeleted: NotRequired[bool]
notAuthor: NotRequired[bool]
class AppLegacyDatasetToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
dataset: AppLegacyDatasetSelectionPayload
class AppLegacyGoogleSearchToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
google_search: AppFeaturePayload
class AppLegacyWebReaderToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
web_reader: AppFeaturePayload
class AppLegacyWikipediaToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
wikipedia: AppFeaturePayload
class AppLegacyCurrentDatetimeToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
current_datetime: AppFeaturePayload
class AppLegacySensitiveWordConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: bool
words: list[str]
canned_response: str
AppLegacySensitiveWordToolPayload = TypedDict( # type: ignore[misc]
"AppLegacySensitiveWordToolPayload",
{"sensitive-word-avoidance": AppLegacySensitiveWordConfigPayload},
extra_items=AppConfigJsonValue,
)
type AppAgentToolPayload = (
AppProviderAgentToolPayload
| AppLegacyDatasetToolPayload
| AppLegacyGoogleSearchToolPayload
| AppLegacyWebReaderToolPayload
| AppLegacyWikipediaToolPayload
| AppLegacyCurrentDatetimeToolPayload
| AppLegacySensitiveWordToolPayload
)
class AppAgentModePayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
strategy: NotRequired[PlanningStrategy | Literal["cot", "function-calling", ""] | None]
max_iteration: NotRequired[int]
tools: NotRequired[list[AppAgentToolPayload] | None]
prompt: NotRequired[AppAgentPromptPayload | str | None]
class AppImageUploadPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool]
number_limits: NotRequired[int]
detail: NotRequired[Literal["low", "high"] | None]
transfer_methods: NotRequired[list[FileTransferMethod]]
class AppImageConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
number_limits: NotRequired[int]
detail: NotRequired[Literal["low", "high"] | None]
transfer_methods: NotRequired[list[FileTransferMethod]]
class AppFileTypeUploadPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
number_limits: NotRequired[int | None]
transfer_methods: NotRequired[list[FileTransferMethod] | None]
class AppFilePreviewPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
mode: NotRequired[str | None]
file_type_list: NotRequired[list[str] | None]
class AppFileUploadPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool]
image: NotRequired[AppImageUploadPayload]
image_config: NotRequired[AppImageConfigPayload | None]
document: NotRequired[AppFileTypeUploadPayload | None]
audio: NotRequired[AppFileTypeUploadPayload | None]
video: NotRequired[AppFileTypeUploadPayload | None]
custom: NotRequired[AppFileTypeUploadPayload | None]
preview_config: NotRequired[AppFilePreviewPayload | None]
allowed_file_types: NotRequired[list[FileType]]
allowed_file_extensions: NotRequired[list[str]]
allowed_file_upload_methods: NotRequired[list[FileTransferMethod]]
number_limits: NotRequired[int]
class AppModelConfigPayload(BaseModel):
"""Write transport; app-mode validators own defaults and feature-specific rules."""
model: AppModelSelectionPayload
prompt_type: Literal["simple", "advanced", ""] | None = None
pre_prompt: str | None = None
chat_prompt_config: AppChatPromptPayload | None = None
completion_prompt_config: AppCompletionPromptPayload | None = None
user_input_form: list[AppUserInputFormPayload] | None = None
dataset_query_variable: str | None = None
dataset_configs: AppDatasetConfigPayload | None = None
agent_mode: AppAgentModePayload | None = None
external_data_tools: list[AppExternalDataToolPayload] | None = None
file_upload: AppFileUploadPayload | None = None
opening_statement: str | None = None
suggested_questions: list[str] | None = None
suggested_questions_after_answer: AppSuggestedQuestionsPayload | None = None
more_like_this: AppFeaturePayload | None = None
speech_to_text: AppFeaturePayload | None = None
text_to_speech: AppTextToSpeechPayload | None = None
retriever_resource: AppFeaturePayload | None = None
sensitive_word_avoidance: AppModerationPayload | None = None

View File

@@ -16,6 +16,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountWithRole
@@ -31,7 +32,6 @@ from models.comment import (
from models.comment import (
WorkflowCommentReply as WorkflowCommentReplyModel,
)
from services.account_service import TenantService
from services.workflow_comment_service import WorkflowCommentService
logger = logging.getLogger(__name__)
@@ -585,7 +585,7 @@ class WorkflowCommentMentionUsersApi(Resource):
current_tenant = current_user.current_tenant # need the tenant object here
if current_tenant is None:
raise ValueError("current tenant is required")
members = TenantService.get_tenant_members(current_tenant, session=db.session())
members = application_services().workspaces.member_queries.list_members(current_tenant.id)
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = WorkflowCommentMentionUsersPayload(users=users)
return response.model_dump(mode="json"), 200

View File

@@ -18,6 +18,7 @@ from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models import App, AppMode
from models.agent import AgentScope
from models.enums import AppStatus
__all__ = [
"get_app_model",
@@ -25,31 +26,28 @@ __all__ = [
]
def _is_hidden_backing_app(app_model: App, session: Session | scoped_session) -> bool:
binding = app_model.agent_app_binding_with_session(session=session, include_archived=True)
return binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY
def _load_app_model(session: Session, app_id: str) -> App | None:
"""Load the tenant-scoped app row with the request session owned by `with_session`."""
def _load_app_model(session: Session | scoped_session, app_id: str) -> App | None:
"""Load the tenant-scoped Console app row through the given session."""
_, current_tenant_id = current_account_with_tenant()
app_model = session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
select(App)
.where(
App.id == app_id,
App.tenant_id == current_tenant_id,
App.status == AppStatus.NORMAL,
)
.limit(1)
)
if app_model is not None and _is_hidden_backing_app(app_model, session):
return None
if app_model is not None:
binding = app_model.agent_app_binding_with_session(session=session, include_archived=True)
if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY:
return None
return app_model
def _load_app_model_from_scoped_session(app_id: str) -> App | None:
"""Load the app row for legacy handlers that have not adopted request session injection yet."""
_, current_tenant_id = current_account_with_tenant()
app_model = db.session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
)
if app_model is not None and _is_hidden_backing_app(app_model, db.session):
return None
return app_model
return _load_app_model(db.session, app_id)
def _get_injected_session(args: tuple[object, ...]) -> Session | None:

View File

@@ -12,14 +12,8 @@ from extensions.ext_application_services import application_services
from libs.helper import EmailStr, dump_response, timezone
from libs.login import current_account_with_tenant
from libs.token import extract_access_token
from services.account_activation_service import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.account_activation_service import (
FrozenAccountError,
InvalidInvitationError,
InvitationAccountMismatchError,
)
from services.account_errors import AccountEmailDomainSuspendedError as EmailDomainSuspendedRegistrationError
from services.account_errors import FrozenAccountError, InvalidInvitationError, InvitationAccountMismatchError
from services.entities.account_activation_entities import ActivationCommand, InvitationLookup
@@ -91,7 +85,7 @@ class ActivateCheckApi(Resource):
)
@model_validate(ActivateCheckQuery)
def get(self, args: ActivateCheckQuery):
result = application_services().account_activation.check(
result = application_services().accounts.activation.check(
InvitationLookup(
workspace_id=args.workspace_id,
email=args.email,
@@ -115,20 +109,20 @@ class ActivateApi(Resource):
console_ns.models[ActivationResponse.__name__],
)
@console_ns.response(400, "Already activated or invalid token")
def post(self):
@model_validate(ActivatePayload)
def post(self, args: ActivatePayload):
"""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
try:
application_services().account_activation.activate(
application_services().accounts.activation.activate(
ActivationCommand(
invitation=InvitationLookup(
workspace_id=args.workspace_id,

View File

@@ -27,7 +27,7 @@ from services.auth.errors import (
InvalidDataSourceApiKeyAuthCredentialsError,
UnsupportedDataSourceApiKeyAuthProviderError,
)
from services.entities.data_source_api_key_auth_entities import (
from services.data_source.entities.api_key_auth import (
DataSourceApiKeyAuthBindingCreate,
DataSourceApiKeyAuthCredentials,
)
@@ -80,7 +80,7 @@ class ApiKeyAuthDataSource(Resource):
@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)
bindings = application_services().data_sources.api_key_auth.list_bindings(request_context)
return dump_response(
ApiKeyAuthDataSourceListResponse,
{
@@ -120,7 +120,7 @@ class ApiKeyAuthDataSourceBinding(Resource):
),
)
try:
application_services().data_source_api_key_auth.create_binding(request_context, command)
application_services().data_sources.api_key_auth.create_binding(request_context, command)
except UnsupportedDataSourceApiKeyAuthProviderError as exc:
raise DataSourceApiKeyAuthProviderNotSupportedError() from exc
except InvalidDataSourceApiKeyAuthCredentialsError as exc:
@@ -140,5 +140,5 @@ class ApiKeyAuthDataSourceBindingDelete(Resource):
rbac_checks=[RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace())],
)
def delete(self, request_context: RequestContext, binding_id: UUID):
application_services().data_source_api_key_auth.delete_binding(request_context, str(binding_id))
application_services().data_sources.api_key_auth.delete_binding(request_context, str(binding_id))
return "", HTTPStatus.NO_CONTENT

View File

@@ -24,13 +24,13 @@ from fields.base import ResponseModel
from libs.helper import dump_response
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.data_source_oauth_service import (
from services.data_source.entities.oauth import DataSourceOAuthCallback
from services.data_source.oauth_service import (
DataSourceOAuthConfigurationError,
DataSourceOAuthError,
InvalidDataSourceOAuthCodeError,
InvalidDataSourceOAuthProviderError,
)
from services.entities.data_source_oauth_entities import DataSourceOAuthCallback
from .. import console_ns
@@ -116,7 +116,7 @@ class OAuthDataSource(Resource):
)
def get(self, request_context: RequestContext, provider: str):
try:
service = application_services().resolve_data_source_oauth(provider)
service = application_services().data_sources.resolve_oauth(provider)
authorization = service.start_authorization(request_context)
except InvalidDataSourceOAuthProviderError:
return _invalid_provider_response()
@@ -141,7 +141,7 @@ class OAuthDataSourceCallback(Resource):
def get(self, provider: str):
query = query_params_from_request(OAuthDataSourceCallbackQuery)
try:
service = application_services().resolve_data_source_oauth(provider)
service = application_services().data_sources.resolve_oauth(provider)
callback = service.complete_callback(
code=query.code,
error=query.error,
@@ -172,7 +172,7 @@ class OAuthDataSourceBinding(Resource):
if not query.code:
return {"error": "Invalid code"}, HTTPStatus.BAD_REQUEST
try:
service = application_services().resolve_data_source_oauth(provider)
service = application_services().data_sources.resolve_oauth(provider)
service.bind(request_context, code=query.code)
except InvalidDataSourceOAuthProviderError:
return _invalid_provider_response()
@@ -198,7 +198,7 @@ class OAuthDataSourceSync(Resource):
@console_account_admission()
def get(self, request_context: RequestContext, provider: str, binding_id: UUID):
try:
service = application_services().resolve_data_source_oauth(provider)
service = application_services().data_sources.resolve_oauth(provider)
service.sync(
request_context,
binding_id=str(binding_id),

View File

@@ -154,7 +154,7 @@ class EmailRegisterResetApi(Resource):
@model_validate(EmailRegisterResetPayload)
def post(self, args: EmailRegisterResetPayload):
try:
token_pair = application_services().accounts.email_registration.register(
tokens = application_services().accounts.email_registration.register(
remote_ip=extract_remote_ip(request),
token=args.token,
new_password=args.new_password,
@@ -179,5 +179,5 @@ class EmailRegisterResetApi(Resource):
return dump_response(
EmailRegisterResetResponse,
{"result": "success", "data": token_pair},
{"result": "success", "data": tokens},
)

View File

@@ -60,12 +60,8 @@ from libs.token import (
set_refresh_token_to_cookie,
)
from services import account_errors
from services.entities.account_login_entities import (
AuthTokenPair,
EmailCodeLoginCommand,
EmailCodeSendCommand,
PasswordLoginCommand,
)
from services.entities.account_entities import AccountSessionTokens
from services.entities.account_login_entities import EmailCodeLoginCommand, EmailCodeSendCommand, PasswordLoginCommand
from services.entities.auth_entities import LoginPayloadBase
@@ -141,7 +137,7 @@ class LoginApi(Resource):
except account_errors.AccountApplicationError as error:
_raise_request_error(error)
if not result.workspace_found or result.token_pair is None:
if not result.workspace_found or result.tokens is None:
return dump_response(
SimpleResultOptionalDataResponse,
{
@@ -149,7 +145,7 @@ class LoginApi(Resource):
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
},
)
return _token_response(result.token_pair, SimpleResultOptionalDataResponse, {"result": "success"})
return _token_response(result.tokens, SimpleResultOptionalDataResponse, {"result": "success"})
@console_ns.route("/logout")
@@ -218,7 +214,7 @@ class EmailCodeLoginApi(Resource):
@model_validate(EmailCodeLoginPayload)
def post(self, req_data: EmailCodeLoginPayload):
try:
token_pair = application_services().accounts.authentication.login_with_email_code(
tokens = application_services().accounts.authentication.login_with_email_code(
EmailCodeLoginCommand(
email=req_data.email,
code=req_data.code,
@@ -231,7 +227,7 @@ class EmailCodeLoginApi(Resource):
)
except account_errors.AccountApplicationError as error:
_raise_request_error(error)
return _token_response(token_pair, SimpleResultResponse, {"result": "success"})
return _token_response(tokens, SimpleResultResponse, {"result": "success"})
@console_ns.route("/refresh-token")
@@ -247,21 +243,21 @@ class RefreshTokenApi(Resource):
), HTTPStatus.UNAUTHORIZED
try:
token_pair = application_services().accounts.authentication.refresh(refresh_token)
tokens = application_services().accounts.authentication.refresh(refresh_token)
except account_errors.InvalidRefreshTokenError as error:
return dump_response(
SimpleResultMessageResponse,
{"result": "fail", "message": str(error)},
), HTTPStatus.UNAUTHORIZED
return _token_response(token_pair, SimpleResultResponse, {"result": "success"})
return _token_response(tokens, SimpleResultResponse, {"result": "success"})
def _token_response(token_pair: AuthTokenPair, response_model: type[BaseModel], data: object):
def _token_response(tokens: AccountSessionTokens, response_model: type[BaseModel], data: object):
# response-contract:ignore cookie-bearing Flask response
response = make_response(dump_response(response_model, data))
set_csrf_token_to_cookie(request, response, token_pair.csrf_token)
set_access_token_to_cookie(request, response, token_pair.access_token)
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
set_csrf_token_to_cookie(request, response, tokens.csrf_token)
set_access_token_to_cookie(request, response, tokens.access_token)
set_refresh_token_to_cookie(request, response, tokens.refresh_token)
return response

View File

@@ -1,53 +1,59 @@
import json
from collections.abc import Generator
from datetime import datetime
from typing import Any, Literal, cast
from typing import Never
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_serializer
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from configs import dify_config
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.common.fields import SimpleResultResponse, TextContentResponse
from controllers.common.rbac import DatasetId, RBACCheck, Workspace
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from core.datasource.entities.datasource_entities import DatasourceProviderType, OnlineDocumentPagesMessage
from core.datasource.online_document.online_document_plugin import OnlineDocumentDatasourcePlugin
from core.entities.knowledge_entities import IndexingEstimate
from core.indexing_runner import IndexingRunner
from core.rag.extractor.entity.datasource_type import DatasourceType
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo
from core.rag.extractor.notion_extractor import NotionExtractor
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.datetime_utils import naive_utc_now
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models import Account, DataSourceOauthBinding, Document
from services.dataset_service import DatasetService, DocumentService
from services.datasource_provider_service import DatasourceProviderService
from tasks.document_indexing_sync_task import document_indexing_sync_task
from .. import console_ns
from ..wraps import (
RBACPermission,
account_initialization_required,
is_admin_or_owner_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
from controllers.console import console_ns
from controllers.console.app.error import ProviderNotInitializeError
from controllers.console.datasets.error import (
DatasetAccessDeniedRequestError,
IndexingEstimateError,
InvalidActionError,
)
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from core.entities.knowledge_entities import IndexingEstimate
from core.rbac import RBACPermission
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from fields.dataset_fields import NotionEstimateWorkspacePayload
from libs.helper import dump_response, to_timestamp
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.data_source.binding_application_service import (
BindingAction,
DataSourceBindingNotFoundError,
DataSourceBindingStateError,
)
from services.data_source.entities.notion_import import NotionPageType
from services.data_source.notion_import_application_service import (
DatasetIsNotNotionSourceError,
NotionImportCredentialUnavailableError,
)
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
from services.knowledge.document_sync import SyncDocumentNotFoundError, SyncDocumentSourceError
from services.knowledge.entities.indexing_estimate import NewSourcesEstimateCommand, NotionEstimateSource
from services.knowledge.indexing.estimate import (
EstimateSourceNotFoundError,
IndexingEstimateCredentialUnavailableError,
IndexingEstimateExecutionError,
IndexingEstimateProviderUnavailableError,
)
_ADMIN_OR_OWNER_ROLES = frozenset({TenantAccountRole.ADMIN, TenantAccountRole.OWNER})
_DATA_SOURCE_OAUTH_BASE_PATH = "/console/api/oauth/data-source"
class NotionEstimatePayload(BaseModel):
notion_info_list: list[dict[str, Any]]
process_rule: dict[str, Any]
notion_info_list: list[NotionEstimateWorkspacePayload] = Field(min_length=1)
process_rule: dict[str, object]
doc_form: str = Field(default="text_model")
doc_language: str = Field(default="English")
@@ -72,7 +78,7 @@ class DataSourceIntegratePageResponse(ResponseModel):
page_id: str
page_icon: DataSourceIntegrateIconResponse | None
parent_id: str
type: str
type: NotionPageType
class DataSourceIntegrateWorkspaceResponse(ResponseModel):
@@ -106,7 +112,7 @@ class NotionIntegratePageResponse(ResponseModel):
page_id: str
page_icon: DataSourceIntegrateIconResponse | None
parent_id: str | None
type: str
page_type: NotionPageType = Field(alias="type")
is_bound: bool
@@ -132,325 +138,204 @@ register_response_schema_models(
)
@console_ns.route(
"/data-source/integrates",
"/data-source/integrates/<uuid:binding_id>/<string:action>",
)
class DataSourceApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()))
@account_initialization_required
def _raise_dataset_access_error(error: DatasetNotFoundError | DatasetAccessDeniedError) -> Never:
if isinstance(error, DatasetNotFoundError):
raise NotFoundError(description="Dataset not found.") from None
raise DatasetAccessDeniedRequestError(description=str(error)) from None
@console_ns.route("/data-source/integrates")
class DataSourceIntegrationListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[DataSourceIntegrateListResponse.__name__])
@with_current_tenant_id
def get(self, current_tenant_id: str) -> tuple[dict[str, Any], int]:
# get workspace data source integrates
data_source_integrates = db.session.scalars(
select(DataSourceOauthBinding).where(
DataSourceOauthBinding.tenant_id == current_tenant_id,
DataSourceOauthBinding.disabled == False,
)
).all()
@console_account_admission(
allowed_roles=_ADMIN_OR_OWNER_ROLES,
rbac_checks=(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()),),
)
def get(self, request_context: RequestContext) -> tuple[dict[str, object], int]:
bindings = application_services().data_sources.bindings.list_integrations(request_context)
base_url = dify_config.CONSOLE_API_URL.rstrip("/")
data = [
{
"id": binding.id,
"provider": binding.provider,
"created_at": binding.created_at,
"is_bound": True,
"disabled": binding.disabled,
"source_info": binding.source_info,
"link": f"{base_url}{_DATA_SOURCE_OAUTH_BASE_PATH}/{binding.provider}",
}
for binding in bindings
]
return dump_response(DataSourceIntegrateListResponse, {"data": data}), 200
base_url = request.url_root.rstrip("/")
data_source_oauth_base_path = "/console/api/oauth/data-source"
providers = ["notion"]
integrate_data: list[dict[str, bool | datetime | dict[str, Any] | str | None]] = []
for provider in providers:
# existing_integrate = next((ai for ai in data_source_integrates if ai.provider == provider), None)
existing_integrates = filter(lambda item: item.provider == provider, data_source_integrates)
if existing_integrates:
for existing_integrate in list(existing_integrates):
integrate_data.append(
{
"id": existing_integrate.id,
"provider": provider,
"created_at": existing_integrate.created_at,
"is_bound": True,
"disabled": existing_integrate.disabled,
"source_info": existing_integrate.source_info,
"link": f"{base_url}{data_source_oauth_base_path}/{provider}",
}
)
else:
integrate_data.append(
{
"id": None,
"provider": provider,
"created_at": None,
"source_info": None,
"is_bound": False,
"disabled": None,
"link": f"{base_url}{data_source_oauth_base_path}/{provider}",
}
)
return dump_response(DataSourceIntegrateListResponse, {"data": integrate_data}), 200
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()))
@account_initialization_required
@console_ns.route("/data-source/integrates/<uuid:binding_id>/<string:action>")
class DataSourceIntegrationApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_tenant_id
@with_session
@console_account_admission(
allowed_roles=_ADMIN_OR_OWNER_ROLES,
rbac_checks=(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()),),
)
def patch(
self, session: Session, current_tenant_id: str, binding_id: UUID, action: Literal["enable", "disable"]
) -> tuple[dict[str, str], int]:
binding_id_str = str(binding_id)
data_source_binding = session.scalar(
select(DataSourceOauthBinding).where(
DataSourceOauthBinding.id == binding_id_str, DataSourceOauthBinding.tenant_id == current_tenant_id
self,
request_context: RequestContext,
binding_id: UUID,
action: str,
) -> tuple[dict[str, object], int]:
try:
validated_action = BindingAction(action)
except ValueError as error:
raise InvalidActionError() from error
try:
application_services().data_sources.bindings.change_state(
request_context,
str(binding_id),
validated_action,
)
)
if data_source_binding is None:
raise NotFound("Data source binding not found.")
# enable binding
match action:
case "enable":
if data_source_binding.disabled:
data_source_binding.disabled = False
data_source_binding.updated_at = naive_utc_now()
else:
raise ValueError("Data source is not disabled.")
# disable binding
case "disable":
if not data_source_binding.disabled:
data_source_binding.disabled = True
data_source_binding.updated_at = naive_utc_now()
else:
raise ValueError("Data source is disabled.")
return {"result": "success"}, 200
except DataSourceBindingNotFoundError as error:
raise NotFoundError(description="Data source binding not found.") from error
except DataSourceBindingStateError as error:
raise InvalidArgumentError(description=str(error)) from error
return dump_response(SimpleResultResponse, {"result": "success"}), 200
@console_ns.route("/notion/pre-import/pages")
class DataSourceNotionListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.doc(params=query_params_from_model(DataSourceNotionListQuery))
@console_ns.response(200, "Success", console_ns.models[NotionIntegrateInfoListResponse.__name__])
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@console_account_admission()
@model_validate(DataSourceNotionListQuery)
def get(
self,
req_data: DataSourceNotionListQuery,
session: Session,
current_tenant_id: str,
current_user: Account,
) -> tuple[dict[str, Any], int]:
datasource_provider_service = DatasourceProviderService()
credential = datasource_provider_service.get_datasource_credentials(
tenant_id=current_tenant_id,
credential_id=req_data.credential_id,
provider="notion_datasource",
plugin_id="langgenius/notion_datasource",
)
if not credential:
raise NotFound("Credential not found.")
exist_page_ids = []
# import notion in the exist dataset
if req_data.dataset_id:
dataset = DatasetService.get_dataset(req_data.dataset_id, session)
if not dataset:
raise NotFound("Dataset not found.")
if dataset.data_source_type != "notion_import":
raise ValueError("Dataset is not notion type.")
documents = session.scalars(
select(Document).where(
Document.dataset_id == req_data.dataset_id,
Document.tenant_id == current_tenant_id,
Document.data_source_type == "notion_import",
Document.enabled.is_(True),
)
).all()
if documents:
for document in documents:
data_source_info = json.loads(document.data_source_info)
exist_page_ids.append(data_source_info["notion_page_id"])
# get all authorized pages
from core.datasource.datasource_manager import DatasourceManager
datasource_runtime = DatasourceManager.get_datasource_runtime(
provider_id="langgenius/notion_datasource/notion_datasource",
datasource_name="notion_datasource",
tenant_id=current_tenant_id,
datasource_type=DatasourceProviderType.ONLINE_DOCUMENT,
)
datasource_provider_service = DatasourceProviderService()
if credential:
datasource_runtime.runtime.credentials = credential
datasource_runtime = cast(OnlineDocumentDatasourcePlugin, datasource_runtime)
online_document_result: Generator[OnlineDocumentPagesMessage, None, None] = (
datasource_runtime.get_online_document_pages(
user_id=current_user.id,
datasource_parameters={},
provider_type=datasource_runtime.datasource_provider_type(),
)
)
request_context: RequestContext,
) -> tuple[dict[str, object], int]:
try:
pages = []
workspace_info = {}
for message in online_document_result:
result = message.result
for info in result:
workspace_info = {
"workspace_id": info.workspace_id,
"workspace_name": info.workspace_name,
"workspace_icon": info.workspace_icon,
}
for page in info.pages:
page_info = {
"page_id": page.page_id,
"page_name": page.page_name,
"type": page.type,
"parent_id": page.parent_id,
"is_bound": page.page_id in exist_page_ids,
"page_icon": page.page_icon,
}
pages.append(page_info)
except Exception as e:
raise e
notion_info = [{**workspace_info, "pages": pages}] if workspace_info else []
return dump_response(NotionIntegrateInfoListResponse, {"notion_info": notion_info}), 200
result = application_services().data_sources.notion_imports.list_pages(
request_context,
credential_id=req_data.credential_id,
dataset_id=req_data.dataset_id,
)
except NotionImportCredentialUnavailableError as error:
raise NotFoundError(description="Credential not found.") from error
except (DatasetNotFoundError, DatasetAccessDeniedError) as error:
_raise_dataset_access_error(error)
except DatasetIsNotNotionSourceError as error:
raise InvalidArgumentError(description="Dataset is not notion type.") from error
return dump_response(NotionIntegrateInfoListResponse, {"notion_info": result.workspaces}), 200
@console_ns.route("/notion/pages/<uuid:page_id>/<string:page_type>/preview")
class DataSourceNotionPreviewApi(Resource):
"""Preview one authorized Notion page through the datasource credential."""
@setup_required
@login_required
@account_initialization_required
@console_ns.doc(params=query_params_from_model(DataSourceNotionPreviewQuery))
@console_ns.response(200, "Success", console_ns.models[TextContentResponse.__name__])
@with_current_tenant_id
@console_account_admission()
@model_validate(DataSourceNotionPreviewQuery)
def get(
self,
req_data: DataSourceNotionPreviewQuery,
current_tenant_id: str,
request_context: RequestContext,
page_id: UUID,
page_type: str,
) -> tuple[dict[str, str], int]:
datasource_provider_service = DatasourceProviderService()
credential = datasource_provider_service.get_datasource_credentials(
tenant_id=current_tenant_id,
credential_id=req_data.credential_id,
provider="notion_datasource",
plugin_id="langgenius/notion_datasource",
)
page_id_str = str(page_id)
extractor = NotionExtractor(
notion_workspace_id="",
notion_obj_id=page_id_str,
notion_page_type=page_type,
notion_access_token=credential.get("integration_secret"),
tenant_id=current_tenant_id,
)
text_docs = extractor.extract()
return {"content": "\n".join([doc.page_content for doc in text_docs])}, 200
) -> tuple[dict[str, object], int]:
try:
validated_page_type = NotionPageType(page_type)
except ValueError as error:
raise InvalidArgumentError(description="Invalid Notion page type.") from error
try:
content = application_services().data_sources.notion_imports.preview_page(
request_context,
credential_id=req_data.credential_id,
page_id=str(page_id),
page_type=validated_page_type,
)
except NotionImportCredentialUnavailableError as error:
raise NotFoundError(description="Credential not found.") from error
return dump_response(TextContentResponse, {"content": content}), 200
@console_ns.route("/datasets/notion-indexing-estimate")
class DataSourceNotionIndexingEstimateApi(Resource):
"""Estimate indexing work for selected Notion pages."""
@setup_required
@login_required
@account_initialization_required
@console_ns.expect(console_ns.models[NotionEstimatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[IndexingEstimate.__name__])
@with_current_tenant_id
@with_session
@console_account_admission()
@model_validate(NotionEstimatePayload)
def post(
self,
req_data: NotionEstimatePayload,
session: Session,
current_tenant_id: str,
) -> tuple[dict[str, Any], int]:
args = req_data.model_dump()
# validate args
DocumentService.estimate_args_validate(args)
notion_info_list = req_data.notion_info_list
extract_settings = []
for notion_info in notion_info_list:
workspace_id = notion_info["workspace_id"]
credential_id = notion_info.get("credential_id")
for page in notion_info["pages"]:
extract_setting = ExtractSetting(
datasource_type=DatasourceType.NOTION,
notion_info=NotionInfo.model_validate(
{
"credential_id": credential_id,
"notion_workspace_id": workspace_id,
"notion_obj_id": page["page_id"],
"notion_page_type": page["type"],
"tenant_id": current_tenant_id,
}
),
document_model=args["doc_form"],
request_context: RequestContext,
) -> tuple[dict[str, object], int]:
command = NewSourcesEstimateCommand(
sources=tuple(
NotionEstimateSource(
workspace_id=workspace.workspace_id,
page_id=page.page_id,
page_type=page.page_type,
credential_id=workspace.credential_id,
)
extract_settings.append(extract_setting)
indexing_runner = IndexingRunner()
response = indexing_runner.indexing_estimate(
tenant_id=current_tenant_id,
extract_settings=extract_settings,
tmp_processing_rule=args["process_rule"],
doc_form=args["doc_form"],
doc_language=args["doc_language"],
session=session,
for workspace in req_data.notion_info_list
for page in workspace.pages
),
process_rule=req_data.process_rule,
doc_form=req_data.doc_form,
doc_language=req_data.doc_language,
)
try:
response = application_services().knowledge.indexing_estimates.estimate_new_sources(
request_context,
command,
)
except IndexingEstimateCredentialUnavailableError as error:
raise NotFoundError(description="Credential not found.") from error
except EstimateSourceNotFoundError as error:
raise NotFoundError(description=str(error)) from error
except IndexingEstimateProviderUnavailableError as error:
raise ProviderNotInitializeError(str(error)) from error
except IndexingEstimateExecutionError as error:
raise IndexingEstimateError(str(error)) from error
return dump_response(IndexingEstimate, response), 200
@console_ns.route("/datasets/<uuid:dataset_id>/notion/sync")
class DataSourceNotionDatasetSyncApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_session(write=False)
def get(self, session: Session, dataset_id: UUID) -> tuple[dict[str, str], int]:
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
if dataset is None:
raise NotFound("Dataset not found.")
documents = DocumentService.get_document_by_dataset_id(dataset_id_str, session)
for document in documents:
document_indexing_sync_task.delay(dataset_id_str, document.id)
return {"result": "success"}, 200
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
)
def get(self, request_context: RequestContext, dataset_id: UUID) -> tuple[dict[str, object], int]:
try:
application_services().knowledge.document_sync.sync_dataset(request_context, str(dataset_id))
except (DatasetNotFoundError, DatasetAccessDeniedError) as error:
_raise_dataset_access_error(error)
return dump_response(SimpleResultResponse, {"result": "success"}), 200
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/notion/sync")
class DataSourceNotionDocumentSyncApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_session(write=False)
def get(self, session: Session, dataset_id: UUID, document_id: UUID) -> tuple[dict[str, str], int]:
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
if dataset is None:
raise NotFound("Dataset not found.")
document = DocumentService.get_document(dataset_id_str, document_id_str, session=session)
if document is None:
raise NotFound("Document not found.")
document_indexing_sync_task.delay(dataset_id_str, document_id_str)
return {"result": "success"}, 200
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
)
def get(
self,
request_context: RequestContext,
dataset_id: UUID,
document_id: UUID,
) -> tuple[dict[str, object], int]:
try:
application_services().knowledge.document_sync.sync_document(
request_context,
str(dataset_id),
str(document_id),
)
except (DatasetNotFoundError, DatasetAccessDeniedError) as error:
_raise_dataset_access_error(error)
except SyncDocumentNotFoundError as error:
raise NotFoundError(description="Document not found.") from error
except SyncDocumentSourceError as error:
raise InvalidArgumentError(description=str(error)) from error
return dump_response(SimpleResultResponse, {"result": "success"}), 200

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,12 @@ class InvalidActionError(BaseHTTPException):
code = 400
class DatasetAccessDeniedRequestError(BaseHTTPException):
error_code = "dataset_access_denied"
description = "Dataset access denied."
code = 403
class DocumentAlreadyFinishedError(BaseHTTPException):
error_code = "document_already_finished"
description = "The document has been processed. Please refresh the page or go to the document details."

View File

@@ -1,16 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from typing import Any, Never
from uuid import UUID
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
from controllers.common.fields import UsageCountResponse
from controllers.common.rbac import DatasetId, RBACCheck, Workspace
from controllers.common.schema import (
@@ -18,31 +15,42 @@ from controllers.common.schema import (
register_response_schema_models,
register_schema_models,
)
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.datasets.error import DatasetNameDuplicateError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
edit_permission_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source
from fields.dataset_fields import (
DatasetDetailResponse,
)
from libs.helper import dump_response
from libs.login import login_required
from libs.pagination import clamp_pagination
from models import Account
from models.dataset import ExternalKnowledgeApis
from services.dataset_service import DatasetService
from services.enterprise import rbac_service as enterprise_rbac_service
from machinery.context import RequestContext
from models.account import TenantAccountRole
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.errors.dataset import DatasetNameDuplicateError as DatasetNameDuplicateFailure
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
from services.knowledge.external.application import ExternalHitTestingError, ExternalTemplateNotFoundError
_DATASET_EDIT_ROLES = frozenset(
{TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.DATASET_OPERATOR}
)
_EXTERNAL_CONNECT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
def _raise_external_error(error: Exception) -> Never:
if isinstance(error, (DatasetNotFoundError, ExternalTemplateNotFoundError)):
raise NotFound(str(error)) from error
if isinstance(error, DatasetAccessDeniedError):
raise Forbidden(str(error)) from error
if isinstance(error, DatasetNameDuplicateFailure):
raise DatasetNameDuplicateError() from error
if isinstance(error, ExternalHitTestingError):
raise InternalServerError(str(error)) from error
raise error
class ExternalKnowledgeApiPayload(BaseModel):
@@ -85,28 +93,6 @@ class ExternalKnowledgeApiResponse(ResponseModel):
return value
@dataclass(frozen=True)
class ExternalKnowledgeApiResponseSource:
external_knowledge_api: ExternalKnowledgeApis
session: Session
@property
def dataset_bindings(self) -> Any:
return self.external_knowledge_api.get_dataset_bindings(session=self.session)
def __getattr__(self, name: str) -> Any:
return getattr(self.external_knowledge_api, name) # guard-ignore: no-new-getattr -- delegates model fields
def external_knowledge_api_response(
external_knowledge_api: ExternalKnowledgeApis, *, session: Session
) -> ExternalKnowledgeApiResponse:
return ExternalKnowledgeApiResponse.model_validate(
ExternalKnowledgeApiResponseSource(external_knowledge_api=external_knowledge_api, session=session),
from_attributes=True,
)
class ExternalKnowledgeApiListResponse(ResponseModel):
data: list[ExternalKnowledgeApiResponse]
has_more: bool
@@ -161,25 +147,13 @@ class ExternalApiTemplateListApi(Resource):
"External API templates retrieved successfully",
console_ns.models[ExternalKnowledgeApiListResponse.__name__],
)
@setup_required
@login_required
@with_current_tenant_id
@account_initialization_required
@with_session(write=False)
@console_account_admission()
@model_validate(ExternalApiTemplateListQuery)
def get(self, req_data: ExternalApiTemplateListQuery, session: Session, current_tenant_id: str):
effective_page, effective_limit = clamp_pagination(req_data.page, req_data.limit, 100)
external_knowledge_apis, total = ExternalDatasetService.get_external_knowledge_apis(
effective_page, effective_limit, current_tenant_id, req_data.keyword, session=session
def get(self, req_data: ExternalApiTemplateListQuery, request_context: RequestContext):
result = application_services().knowledge.external.list_templates(
request_context, page=req_data.page, limit=req_data.limit, keyword=req_data.keyword
)
return ExternalKnowledgeApiListResponse(
data=[external_knowledge_api_response(item, session=session) for item in external_knowledge_apis],
has_more=effective_page * effective_limit < total,
limit=effective_limit,
total=total,
page=effective_page,
).model_dump(mode="json"), 200
return dump_response(ExternalKnowledgeApiListResponse, result), 200
@console_ns.doc("create_external_api_template")
@console_ns.doc(description="Create external knowledge API template")
@@ -190,48 +164,16 @@ class ExternalApiTemplateListApi(Resource):
console_ns.models[ExternalKnowledgeApiResponse.__name__],
)
@console_ns.response(403, "Permission denied")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
@console_account_admission(allowed_roles=_DATASET_EDIT_ROLES)
@model_validate(ExternalKnowledgeApiPayload)
def post(
self,
req_data: ExternalKnowledgeApiPayload,
session: Session,
current_tenant_id: str,
current_user: Account,
):
ExternalDatasetService.validate_api_list(req_data.settings)
# The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
if not current_user.is_dataset_editor:
raise Forbidden()
def post(self, req_data: ExternalKnowledgeApiPayload, request_context: RequestContext):
try:
external_knowledge_api = ExternalDatasetService.create_external_knowledge_api(
tenant_id=current_tenant_id,
user_id=current_user.id,
args=req_data.model_dump(),
session=session,
result = application_services().knowledge.external.create_template(
request_context, name=req_data.name, settings=req_data.settings
)
except services.errors.dataset.DatasetNameDuplicateError:
raise DatasetNameDuplicateError()
# if dify_config.RBAC_ENABLED:
# enterprise_rbac_service.RBACService.DatasetAccess.replace_whitelist(
# current_tenant_id,
# current_user.id,
# external_knowledge_api.id,
# enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True),
# )
# initialize_created_app_rbac_access_task.delay(
# current_tenant_id, current_user.id, dataset_id=external_knowledge_api.id)
return external_knowledge_api_response(external_knowledge_api, session=session).model_dump(mode="json"), 201
except Exception as error:
_raise_external_error(error)
return dump_response(ExternalKnowledgeApiResponse, result), 201
@console_ns.route("/datasets/external-knowledge-api/<uuid:external_knowledge_api_id>")
@@ -245,20 +187,15 @@ class ExternalApiTemplateApi(Resource):
console_ns.models[ExternalKnowledgeApiResponse.__name__],
)
@console_ns.response(404, "Template not found")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session
def get(self, session: Session, current_tenant_id: str, external_knowledge_api_id: UUID):
external_knowledge_api_id_str = str(external_knowledge_api_id)
external_knowledge_api = ExternalDatasetService.get_external_knowledge_api(
external_knowledge_api_id=external_knowledge_api_id_str, tenant_id=current_tenant_id, session=session
)
if external_knowledge_api is None:
raise NotFound("API template not found.")
return external_knowledge_api_response(external_knowledge_api, session=session).model_dump(mode="json"), 200
@console_account_admission()
def get(self, request_context: RequestContext, external_knowledge_api_id: UUID):
try:
result = application_services().knowledge.external.get_template(
request_context, template_id=str(external_knowledge_api_id)
)
except Exception as error:
_raise_external_error(error)
return dump_response(ExternalKnowledgeApiResponse, result), 200
@console_ns.doc("update_external_api_template")
@console_ns.doc(description="Update external knowledge API template")
@@ -270,51 +207,31 @@ class ExternalApiTemplateApi(Resource):
console_ns.models[ExternalKnowledgeApiResponse.__name__],
)
@console_ns.response(404, "Template not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
@console_account_admission()
@model_validate(ExternalKnowledgeApiPayload)
def patch(
self,
req_data: ExternalKnowledgeApiPayload,
session: Session,
current_tenant_id: str,
current_user: Account,
external_knowledge_api_id: UUID,
self, req_data: ExternalKnowledgeApiPayload, request_context: RequestContext, external_knowledge_api_id: UUID
):
external_knowledge_api_id_str = str(external_knowledge_api_id)
try:
result = application_services().knowledge.external.update_template(
request_context,
template_id=str(external_knowledge_api_id),
name=req_data.name,
settings=req_data.settings,
)
except Exception as error:
_raise_external_error(error)
return dump_response(ExternalKnowledgeApiResponse, result), 200
ExternalDatasetService.validate_api_list(req_data.settings)
external_knowledge_api = ExternalDatasetService.update_external_knowledge_api(
tenant_id=current_tenant_id,
user_id=current_user.id,
external_knowledge_api_id=external_knowledge_api_id_str,
args=req_data.model_dump(),
session=session,
)
return external_knowledge_api_response(external_knowledge_api, session=session).model_dump(mode="json"), 200
@setup_required
@login_required
@account_initialization_required
@console_ns.response(204, "External knowledge API deleted successfully")
@with_current_user
@with_current_tenant_id
@with_session
def delete(self, session: Session, current_tenant_id: str, current_user: Account, external_knowledge_api_id: UUID):
external_knowledge_api_id_str = str(external_knowledge_api_id)
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
raise Forbidden()
ExternalDatasetService.delete_external_knowledge_api(
current_tenant_id, external_knowledge_api_id_str, session=session
)
@console_account_admission(allowed_roles=_DATASET_EDIT_ROLES)
def delete(self, request_context: RequestContext, external_knowledge_api_id: UUID):
try:
application_services().knowledge.external.delete_template(
request_context, template_id=str(external_knowledge_api_id)
)
except Exception as error:
_raise_external_error(error)
return "", 204
@@ -324,18 +241,12 @@ class ExternalApiUseCheckApi(Resource):
@console_ns.doc(description="Check if external knowledge API is being used")
@console_ns.doc(params={"external_knowledge_api_id": "External knowledge API ID"})
@console_ns.response(200, "Usage check completed successfully", console_ns.models[UsageCountResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session
def get(self, session: Session, current_tenant_id: str, external_knowledge_api_id: UUID):
external_knowledge_api_id_str = str(external_knowledge_api_id)
external_knowledge_api_is_using, count = ExternalDatasetService.external_knowledge_api_use_check(
external_knowledge_api_id_str, current_tenant_id, session=session
@console_account_admission()
def get(self, request_context: RequestContext, external_knowledge_api_id: UUID):
is_using, count = application_services().knowledge.external.template_usage(
request_context, template_id=str(external_knowledge_api_id)
)
return UsageCountResponse(is_using=external_knowledge_api_is_using, count=count).model_dump(mode="json"), 200
return dump_response(UsageCountResponse, {"is_using": is_using, "count": count}), 200
@console_ns.route("/datasets/external")
@@ -348,50 +259,17 @@ class ExternalDatasetCreateApi(Resource):
)
@console_ns.response(400, "Invalid parameters")
@console_ns.response(403, "Permission denied")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EXTERNAL_CONNECT, Workspace()))
@with_current_user
@with_current_tenant_id
@with_session
@console_account_admission(
allowed_roles=_EXTERNAL_CONNECT_ROLES,
rbac_checks=(RBACCheck(RBACPermission.DATASET_EXTERNAL_CONNECT, Workspace()),),
)
@model_validate(ExternalDatasetCreatePayload)
def post(
self,
req_data: ExternalDatasetCreatePayload,
session: Session,
current_tenant_id: str,
current_user: Account,
):
# The role of the current user in the ta table must be admin, owner, or editor
# The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
if not current_user.is_dataset_editor:
raise Forbidden()
def post(self, req_data: ExternalDatasetCreatePayload, request_context: RequestContext):
try:
dataset = ExternalDatasetService.create_external_dataset(
tenant_id=current_tenant_id,
user_id=current_user.id,
args=req_data,
session=session,
)
except services.errors.dataset.DatasetNameDuplicateError:
raise DatasetNameDuplicateError()
dataset_id_str = str(dataset.id)
permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get(
str(current_tenant_id),
current_user.id,
[dataset_id_str],
session=session,
)
data = DatasetDetailResponse.model_validate(
dataset_detail_response_source(dataset, session=session)
).model_dump(mode="json")
data["permission_keys"] = permission_keys_map.get(dataset_id_str, [])
return data, 201
result = application_services().knowledge.external.create_dataset(request_context, payload=req_data)
except Exception as error:
_raise_external_error(error)
return dump_response(DatasetDetailResponse, result), 201
@console_ns.route("/datasets/<uuid:dataset_id>/external-hit-testing")
@@ -407,36 +285,17 @@ class ExternalKnowledgeHitTestingApi(Resource):
)
@console_ns.response(404, "Dataset not found")
@console_ns.response(400, "Invalid parameters")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_PIPELINE_TEST, DatasetId()))
@with_session
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.DATASET_PIPELINE_TEST, DatasetId()),))
@model_validate(ExternalHitTestingPayload)
def post(self, req_data: ExternalHitTestingPayload, session: Session, current_user: Account, 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.")
def post(self, req_data: ExternalHitTestingPayload, request_context: RequestContext, dataset_id: UUID):
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
HitTestingService.hit_testing_args_check(req_data.model_dump())
try:
response = HitTestingService.external_retrieve(
session=session,
dataset=dataset,
result = application_services().knowledge.external.hit_testing(
request_context,
dataset_id=str(dataset_id),
query=req_data.query,
account=current_user,
external_retrieval_model=req_data.external_retrieval_model,
metadata_filtering_conditions=req_data.metadata_filtering_conditions,
retrieval_model=req_data.external_retrieval_model,
metadata_filters=req_data.metadata_filtering_conditions,
)
return dump_response(ExternalHitTestingResponse, response)
except Exception as e:
raise InternalServerError(str(e))
except Exception as error:
_raise_external_error(error)
return dump_response(ExternalHitTestingResponse, result)

View File

@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
import services.errors.base
from controllers.console.app.error import (
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
@@ -23,9 +23,9 @@ from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import resolve_account_fallback
from models.account import Account
from models.dataset import Dataset
from services.dataset_service import DatasetService
from services.entities.knowledge_entities.knowledge_entities import ExternalRetrievalModel, RetrievalModel
from services.hit_testing_service import HitTestingService
from services.knowledge.dataset_service import DatasetService
from services.knowledge.entities.knowledge_entities import ExternalRetrievalModel, RetrievalModel
logger = logging.getLogger(__name__)
@@ -97,7 +97,7 @@ class DatasetsHitTestingBase:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
except services.errors.base.NoPermissionError as e:
raise Forbidden(str(e))
return dataset

View File

@@ -1,43 +1,47 @@
from typing import Literal
from collections.abc import Callable
from functools import wraps
from uuid import UUID
from flask_restx import Resource
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
import services
from controllers.common.controller_schemas import MetadataUpdatePayload
from controllers.common.rbac import DatasetId, RBACCheck
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
enterprise_license_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from core.rbac import RBACPermission
from extensions.ext_application_services import application_services
from fields.dataset_fields import (
DatasetMetadataBuiltInFieldsResponse,
DatasetMetadataListResponse,
DatasetMetadataResponse,
)
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from services.dataset_service import DatasetService
from services.entities.knowledge_entities.knowledge_entities import (
from machinery.context import RequestContext
from services.errors.metadata import MetadataResourceNotFoundError
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
from services.knowledge.entities.knowledge_entities import (
DocumentMetadataOperation,
MetadataArgs,
MetadataDetail,
MetadataOperationData,
)
from services.errors.metadata import MetadataResourceNotFoundError
from services.metadata_service import MetadataService
def metadata_errors[**P, T](method: Callable[P, T]) -> Callable[P, T]:
@wraps(method)
def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
try:
return method(*args, **kwargs)
except (DatasetNotFoundError, MetadataResourceNotFoundError) as error:
raise NotFound(str(error)) from error
except DatasetAccessDeniedError as error:
raise Forbidden(str(error)) from error
return wrapped
register_schema_models(
console_ns, MetadataArgs, MetadataOperationData, MetadataUpdatePayload, DocumentMetadataOperation, MetadataDetail
@@ -52,199 +56,111 @@ register_response_schema_models(
@console_ns.route("/datasets/<uuid:dataset_id>/metadata")
class DatasetMetadataCreateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.response(201, "Metadata created successfully", console_ns.models[DatasetMetadataResponse.__name__])
@console_ns.expect(console_ns.models[MetadataArgs.__name__])
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_session
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
)
@metadata_errors
@model_validate(MetadataArgs)
def post(
self,
req_data: MetadataArgs,
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)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user, session)
metadata = MetadataService.create_metadata(
dataset_id_str, req_data, current_user, current_tenant_id, session=session
)
def post(self, req_data: MetadataArgs, request_context: RequestContext, dataset_id: UUID):
service = application_services().knowledge.metadata
ref = service.require_dataset(request_context, str(dataset_id))
metadata = service.create_metadata(ref, req_data, actor_id=request_context.account_id)
return dump_response(DatasetMetadataResponse, metadata), 201
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.response(
200, "Metadata retrieved successfully", console_ns.models[DatasetMetadataListResponse.__name__]
)
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()))
@with_session(write=False)
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_for_tenant(dataset_id_str, current_tenant_id, session=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))
metadata = MetadataService.get_dataset_metadatas(dataset, session)
return dump_response(DatasetMetadataListResponse, metadata), 200
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()),),
)
@metadata_errors
def get(self, request_context: RequestContext, dataset_id: UUID):
service = application_services().knowledge.metadata
ref = service.require_dataset(request_context, str(dataset_id))
return dump_response(DatasetMetadataListResponse, service.get_dataset_metadatas(ref)), 200
@console_ns.route("/datasets/<uuid:dataset_id>/metadata/<uuid:metadata_id>")
class DatasetMetadataApi(Resource):
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.response(200, "Metadata updated successfully", console_ns.models[DatasetMetadataResponse.__name__])
@console_ns.expect(console_ns.models[MetadataUpdatePayload.__name__])
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_session
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
)
@metadata_errors
@model_validate(MetadataUpdatePayload)
def patch(
self,
req_data: MetadataUpdatePayload,
session: Session,
current_tenant_id: str,
current_user: Account,
dataset_id: UUID,
metadata_id: UUID,
self, req_data: MetadataUpdatePayload, request_context: RequestContext, dataset_id: UUID, metadata_id: UUID
):
name = req_data.name
dataset_id_str = str(dataset_id)
metadata_id_str = str(metadata_id)
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, metadata_id_str, name, current_user, session=session)
service = application_services().knowledge.metadata
ref = service.require_dataset(request_context, str(dataset_id))
metadata = service.update_metadata_name(
ref, str(metadata_id), req_data.name, actor_id=request_context.account_id
)
return dump_response(DatasetMetadataResponse, metadata), 200
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.response(204, "Metadata deleted successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_session
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_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, metadata_id_str, session)
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
)
@metadata_errors
def delete(self, request_context: RequestContext, dataset_id: UUID, metadata_id: UUID):
service = application_services().knowledge.metadata
ref = service.require_dataset(request_context, str(dataset_id))
service.delete_metadata(ref, str(metadata_id))
return "", 204
@console_ns.route("/datasets/metadata/built-in")
class DatasetMetadataBuiltInFieldApi(Resource):
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.response(
200,
"Built-in fields retrieved successfully",
console_ns.models[DatasetMetadataBuiltInFieldsResponse.__name__],
)
def get(self):
built_in_fields = MetadataService.get_built_in_fields()
return dump_response(DatasetMetadataBuiltInFieldsResponse, {"fields": built_in_fields}), 200
@console_account_admission()
@metadata_errors
def get(self, request_context: RequestContext):
fields = application_services().knowledge.metadata.get_built_in_fields()
return dump_response(DatasetMetadataBuiltInFieldsResponse, {"fields": fields}), 200
@console_ns.route("/datasets/<uuid:dataset_id>/metadata/built-in/<string:action>")
class DatasetMetadataBuiltInFieldActionApi(Resource):
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.response(204, "Action completed successfully")
@with_current_user
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_session
def post(self, session: Session, current_user: Account, dataset_id: UUID, action: Literal["enable", "disable"]):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user, session)
match action:
case "enable":
MetadataService.enable_built_in_field(dataset, session)
case "disable":
MetadataService.disable_built_in_field(dataset, session)
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
)
@metadata_errors
def post(self, request_context: RequestContext, dataset_id: UUID, action: str):
service = application_services().knowledge.metadata
ref = service.require_dataset(request_context, str(dataset_id))
if action == "enable":
service.enable_built_in_field(ref)
elif action == "disable":
service.disable_built_in_field(ref)
else:
raise ValueError("Invalid action.")
return "", 204
@console_ns.route("/datasets/<uuid:dataset_id>/documents/metadata")
class DocumentMetadataEditApi(Resource):
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.expect(console_ns.models[MetadataOperationData.__name__])
@console_ns.response(
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(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_session
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
)
@metadata_errors
@model_validate(MetadataOperationData)
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)
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.
def post(self, req_data: MetadataOperationData, request_context: RequestContext, dataset_id: UUID):
service = application_services().knowledge.metadata
ref = service.require_dataset(request_context, str(dataset_id))
service.update_documents_metadata(ref, req_data, actor_id=request_context.account_id)
return "", 204

View File

@@ -24,6 +24,7 @@ from core.entities.provider_entities import ProviderConfig
from core.plugin.entities.plugin_daemon import PluginOAuthAuthorizationUrlResponse
from core.plugin.impl.oauth import OAuthHandler
from core.tools.entities.common_entities import I18nObject
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
@@ -32,7 +33,6 @@ 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
@@ -176,7 +176,7 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
datasource_provider_id = DatasourceProviderID(provider_id)
provider_name = datasource_provider_id.provider_name
plugin_id = datasource_provider_id.plugin_id
oauth_config = DatasourceProviderService().get_oauth_client(
oauth_config = application_services().data_sources.providers.get_oauth_client(
tenant_id=tenant_id,
datasource_provider_id=datasource_provider_id,
)
@@ -246,7 +246,7 @@ class DatasourceOAuthCallback(Resource):
tenant_id: str = context["tenant_id"]
datasource_provider_id = DatasourceProviderID(provider_id)
plugin_id = datasource_provider_id.plugin_id
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
oauth_client_params = datasource_provider_service.get_oauth_client(
tenant_id=tenant_id,
datasource_provider_id=datasource_provider_id,
@@ -315,7 +315,7 @@ class DatasourceAuth(Resource):
@model_validate(DatasourceCredentialPayload)
def post(self, req_data: DatasourceCredentialPayload, current_tenant_id: str, provider_id: str):
datasource_provider_id = DatasourceProviderID(provider_id)
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
try:
datasource_provider_service.add_datasource_api_key_provider(
@@ -342,14 +342,14 @@ class DatasourceAuth(Resource):
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account, provider_id: str):
datasource_provider_id = DatasourceProviderID(provider_id)
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
datasources = datasource_provider_service.list_datasource_credentials(
tenant_id=current_tenant_id,
provider=datasource_provider_id.provider_name,
plugin_id=datasource_provider_id.plugin_id,
user=user,
session=db.session(),
credential_query=application_services().credential_queries,
)
return dump_response(DatasourceCredentialListResponse, {"result": datasources}), 200
@@ -370,7 +370,7 @@ class DatasourceAuthDeleteApi(Resource):
plugin_id = datasource_provider_id.plugin_id
provider_name = datasource_provider_id.provider_name
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
datasource_provider_service.remove_datasource_credentials(
tenant_id=current_tenant_id,
auth_id=req_data.credential_id,
@@ -397,7 +397,7 @@ class DatasourceAuthUpdateApi(Resource):
def post(self, req_data: DatasourceCredentialUpdatePayload, current_tenant_id: str, provider_id: str):
datasource_provider_id = DatasourceProviderID(provider_id)
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
datasource_provider_service.update_datasource_credentials(
tenant_id=current_tenant_id,
auth_id=req_data.credential_id,
@@ -422,9 +422,12 @@ class DatasourceAuthListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account):
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
datasources = datasource_provider_service.get_all_datasource_credentials(
tenant_id=current_tenant_id, session=db.session(), user=user
tenant_id=current_tenant_id,
session=db.session(),
user=user,
credential_query=application_services().credential_queries,
)
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
@@ -442,9 +445,12 @@ class DatasourceHardCodeAuthListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account):
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
datasources = datasource_provider_service.get_hard_code_datasource_credentials(
tenant_id=current_tenant_id, session=db.session(), user=user
tenant_id=current_tenant_id,
session=db.session(),
user=user,
credential_query=application_services().credential_queries,
)
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
@@ -464,7 +470,7 @@ class DatasourceAuthOauthCustomClient(Resource):
@model_validate(DatasourceCustomClientPayload)
def post(self, req_data: DatasourceCustomClientPayload, current_tenant_id: str, provider_id: str):
datasource_provider_id = DatasourceProviderID(provider_id)
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
datasource_provider_service.setup_oauth_custom_client_params(
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,
@@ -482,7 +488,7 @@ class DatasourceAuthOauthCustomClient(Resource):
@with_current_tenant_id
def delete(self, current_tenant_id: str, provider_id: str):
datasource_provider_id = DatasourceProviderID(provider_id)
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
datasource_provider_service.remove_oauth_custom_client_params(
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,
@@ -503,7 +509,7 @@ class DatasourceAuthDefaultApi(Resource):
@model_validate(DatasourceDefaultPayload)
def post(self, req_data: DatasourceDefaultPayload, current_tenant_id: str, provider_id: str):
datasource_provider_id = DatasourceProviderID(provider_id)
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
datasource_provider_service.set_default_datasource_provider(
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,
@@ -525,7 +531,7 @@ class DatasourceUpdateProviderNameApi(Resource):
@model_validate(DatasourceUpdateNamePayload)
def post(self, req_data: DatasourceUpdateNamePayload, current_tenant_id: str, provider_id: str):
datasource_provider_id = DatasourceProviderID(provider_id)
datasource_provider_service = DatasourceProviderService()
datasource_provider_service = application_services().data_sources.providers
datasource_provider_service.update_datasource_provider_name(
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,

View File

@@ -9,6 +9,7 @@ from controllers.common.schema import register_schema_models
from controllers.console import console_ns
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 extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import login_required
from models import Account
@@ -51,5 +52,6 @@ class DataSourceContentPreviewApi(Resource):
datasource_type=datasource_type,
is_published=True,
credential_id=req_data.credential_id,
datasource_providers=application_services().data_sources.providers,
)
return preview_content, 200

View File

@@ -35,10 +35,11 @@ from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from models.dataset import Pipeline
from services.dataset_service import DatasetService
from repositories.knowledge.dataset_read_repository import get_pipeline_dataset
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.errors.rag_pipeline import RagPipelineResourceNotFoundError
from services.knowledge.dataset_service import DatasetService
from services.rag_pipeline.rag_pipeline import RagPipelineService
logger: logging.Logger = logging.getLogger(__name__)
@@ -232,7 +233,7 @@ class PublishCustomizedPipelineTemplateApi(Resource):
pipeline: Pipeline,
) -> tuple[str, int]:
session = db.session()
dataset = pipeline.retrieve_dataset(session=session)
dataset = get_pipeline_dataset(pipeline, session=session)
if dataset is None:
raise NotFound("Dataset not found")

View File

@@ -17,14 +17,17 @@ from controllers.console.wraps import (
with_current_user,
)
from extensions.ext_database import db
from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source
from fields.dataset_fields import (
DatasetDetailResponse,
)
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.dataset import DatasetPermissionEnum
from services.dataset_service import DatasetPermissionService, DatasetService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, RagPipelineDatasetCreateEntity
from services.knowledge.dataset_read_service import load_dataset_detail
from services.knowledge.dataset_service import DatasetPermissionService, DatasetService
from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelineDslService
@@ -136,4 +139,4 @@ class CreateEmptyRagPipelineDatasetApi(Resource):
),
session=session,
)
return dump_response(DatasetDetailResponse, dataset_detail_response_source(dataset, session=session)), 201
return dump_response(DatasetDetailResponse, load_dataset_detail(dataset, session=session)), 201

View File

@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, RootModel, ValidationError
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
import services.errors.base
from configs import dify_config
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.fields import SimpleResultResponse
@@ -46,6 +46,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from factories import variable_factory
from fields.base import ResponseModel
@@ -66,10 +67,10 @@ 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.knowledge.dataset_service import DatasetService
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
@@ -303,7 +304,13 @@ class RagPipelineDraftRunIterationNodeApi(Resource):
try:
response = PipelineGenerateService.generate_single_iteration(
pipeline=pipeline, user=current_user, node_id=node_id, args=args, session=db.session(), streaming=True
generator=application_services().knowledge.pipeline_generator,
pipeline=pipeline,
user=current_user,
node_id=node_id,
args=args,
session=db.session(),
streaming=True,
)
return helper.compact_generate_response(response)
@@ -338,7 +345,13 @@ class RagPipelineDraftRunLoopNodeApi(Resource):
try:
response = PipelineGenerateService.generate_single_loop(
pipeline=pipeline, user=current_user, node_id=node_id, args=args, session=db.session(), streaming=True
generator=application_services().knowledge.pipeline_generator,
pipeline=pipeline,
user=current_user,
node_id=node_id,
args=args,
session=db.session(),
streaming=True,
)
return helper.compact_generate_response(response)
@@ -374,6 +387,7 @@ class DraftRagPipelineRunApi(Resource):
try:
response = PipelineGenerateService.generate(
generator=application_services().knowledge.pipeline_generator,
session=session,
pipeline=pipeline,
user=current_user,
@@ -409,6 +423,7 @@ class PublishedRagPipelineRunApi(Resource):
try:
response = PipelineGenerateService.generate(
generator=application_services().knowledge.pipeline_generator,
session=session,
pipeline=pipeline,
user=current_user,
@@ -450,6 +465,7 @@ class RagPipelinePublishedDatasourceNodeRunApi(Resource):
datasource_type=req_data.datasource_type,
is_published=False,
credential_id=req_data.credential_id,
datasource_providers=application_services().data_sources.providers,
)
)
)
@@ -483,6 +499,7 @@ class RagPipelineDraftDatasourceNodeRunApi(Resource):
datasource_type=req_data.datasource_type,
is_published=False,
credential_id=req_data.credential_id,
datasource_providers=application_services().data_sources.providers,
)
)
)
@@ -1016,7 +1033,9 @@ class DatasourceListApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
providers = RagPipelineManageService.list_rag_pipeline_datasources(current_tenant_id)
providers = RagPipelineManageService.list_rag_pipeline_datasources(
current_tenant_id, datasource_providers=application_services().data_sources.providers
)
return RagPipelineDatasourceListResponse.model_validate(providers, from_attributes=True).model_dump(mode="json")
@@ -1069,7 +1088,7 @@ class RagPipelineTransformApi(Resource):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as exc:
except services.errors.base.NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
try:

View File

@@ -7,8 +7,9 @@ from controllers.common.schema import query_params_from_model, register_response
from controllers.console import console_ns
from controllers.console.datasets.error import WebsiteCrawlError
from controllers.console.wraps import account_initialization_required, model_validate, setup_required
from extensions.ext_application_services import application_services
from libs.login import login_required
from services.website_service import WebsiteCrawlApiRequest, WebsiteCrawlStatusApiRequest, WebsiteService
from services.data_source.website_service import WebsiteCrawlApiRequest, WebsiteCrawlStatusApiRequest, WebsiteService
class WebsiteCrawlPayload(BaseModel):
@@ -49,7 +50,7 @@ class WebsiteCrawlApi(Resource):
# Crawl URL using typed request
try:
result = WebsiteService.crawl_url(api_request)
result = WebsiteService(providers=application_services().data_sources.providers).crawl_url(api_request)
except Exception as e:
raise WebsiteCrawlError(str(e))
return result, 200
@@ -77,7 +78,9 @@ class WebsiteCrawlStatusApi(Resource):
# Get crawl status using typed request
try:
result = WebsiteService.get_crawl_status_typed(api_request)
result = WebsiteService(providers=application_services().data_sources.providers).get_crawl_status_typed(
api_request
)
except Exception as e:
raise WebsiteCrawlError(str(e))
return result, 200

View File

@@ -3,13 +3,14 @@
import logging
from collections.abc import Callable
from functools import wraps
from http import HTTPStatus
from flask import Response, request
from flask_restx import Resource
from werkzeug.exceptions import HTTPException, InternalServerError
from controllers.common.audio_response import audio_binary_response
from controllers.common.controller_schemas import TextToAudioPayload
from controllers.common.errors import InternalServerError
from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse
from controllers.common.schema import register_response_schema_models, register_schema_model
from controllers.console import console_ns
@@ -26,14 +27,17 @@ from controllers.console.app.error import (
SpeechToTextDisabledError,
UnsupportedAudioTypeError,
)
from controllers.console.explore.error import AgentVersionNotFoundHTTPError
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_application_services import application_services
from graphon.model_runtime.errors.invoke import InvokeError
from libs.exception import BaseHTTPException
from libs.helper import dump_response
from machinery.context import RequestContext
from services.agent.errors import AgentVersionNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.audio_types import AudioAppRef, AudioUpload
from services.errors.app_model_config import AppModelConfigBrokenError
@@ -83,7 +87,9 @@ def _audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
raise ProviderModelCurrentlyNotSupportError() from error
except InvokeError as error:
raise CompletionRequestError(error.description) from error
except (HTTPException, ValueError):
except AgentVersionNotFoundError as error:
raise AgentVersionNotFoundHTTPError() from error
except (BaseHTTPException, ValueError):
raise
except Exception as error:
logger.exception("Installed-app audio operation failed")
@@ -97,13 +103,18 @@ def _audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
endpoint="installed_app_audio",
)
class ChatAudioApi(Resource):
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@console_account_admission()
@get_installed_app
@_audio_errors
def post(self, request_context: RequestContext, installed_app: InstalledAppRef) -> dict[str, object]:
# Let Flask handle multipart parsing errors before mapping audio operation failures.
file = request.files.get("file")
audio = AudioUpload(stream=file.stream, mime_type=file.mimetype) if file is not None else None
return self._transcribe(installed_app, audio)
@_audio_errors
def _transcribe(self, installed_app: InstalledAppRef, audio: AudioUpload | None) -> dict[str, object]:
"""Transcribe an upload; None lets the audio service report a missing file."""
transcript = application_services().app_audio.transcript_asr(
app=AudioAppRef(
app_id=installed_app.app_id,
@@ -121,7 +132,7 @@ class ChatAudioApi(Resource):
)
class ChatTextApi(Resource):
@console_ns.expect(console_ns.models[TextToAudioPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioBinaryResponse.__name__])
@console_account_admission()
@get_installed_app
@model_validate(TextToAudioPayload)

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from http import HTTPStatus
from typing import cast
from flask_restx import Namespace, Resource
@@ -60,7 +61,7 @@ class BannerApi(Resource):
"""Resource for banner list."""
@api.doc(params=query_params_from_model(BannerListQuery))
@api.response(200, "Success", api.models[BannerListResponse.__name__])
@api.response(HTTPStatus.OK, "Success", api.models[BannerListResponse.__name__])
@model_validate(BannerListQuery)
def get(self, req_data: BannerListQuery):
"""Get banner list."""

View File

@@ -1,13 +1,14 @@
import logging
from http import HTTPStatus
from typing import Any, Literal
from uuid import UUID
from flask import Response
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import InternalServerError, NotFound, Unauthorized
import services
from controllers.common.errors import InternalServerError, NotFoundError, UnauthorizedError
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.app.error import (
@@ -87,7 +88,7 @@ register_response_schema_models(console_ns, SimpleResultResponse)
)
class CompletionApi(Resource):
@console_ns.expect(console_ns.models[CompletionMessageExplorePayload.__name__])
@console_ns.response(200, "Success")
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_installed_app
@model_validate(CompletionMessageExplorePayload)
@@ -98,7 +99,7 @@ class CompletionApi(Resource):
installed_app: InstalledAppRef,
) -> Response:
try:
response = application_services().installed_app_generation.generate_completion(
response = application_services().installed_apps.generation.generate_completion(
installed_app=installed_app,
account_id=request_context.account_id,
args=req_data.model_dump(exclude_none=True),
@@ -111,11 +112,11 @@ class CompletionApi(Resource):
except InstalledAppNotCompletionError:
raise NotCompletionAppError() from None
except InstalledAppNotFoundError:
raise NotFound("Installed app not found") from None
raise NotFoundError("Installed app not found") from None
except AccountNotFoundError:
raise Unauthorized("Account no longer exists.") from None
raise UnauthorizedError("Account no longer exists.") from None
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
raise NotFoundError("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
@@ -141,7 +142,7 @@ class CompletionApi(Resource):
endpoint="installed_app_stop_completion",
)
class CompletionStopApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_installed_app
def post(
@@ -158,7 +159,7 @@ class CompletionStopApi(Resource):
app_mode=AppMode.value_of(app_mode),
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return SimpleResultResponse(result="success").model_dump(mode="json"), HTTPStatus.OK
@console_ns.route(
@@ -167,7 +168,7 @@ class CompletionStopApi(Resource):
)
class ChatApi(Resource):
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
@console_ns.response(200, "Success")
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_installed_app
@model_validate(ChatMessagePayload)
@@ -178,7 +179,7 @@ class ChatApi(Resource):
installed_app: InstalledAppRef,
) -> Response:
try:
response = application_services().installed_app_generation.generate_chat(
response = application_services().installed_apps.generation.generate_chat(
installed_app=installed_app,
account_id=request_context.account_id,
args=req_data.model_dump(exclude_none=True),
@@ -191,11 +192,11 @@ class ChatApi(Resource):
except InstalledAppNotChatError:
raise NotChatAppError() from None
except InstalledAppNotFoundError:
raise NotFound("Installed app not found") from None
raise NotFoundError("Installed app not found") from None
except AccountNotFoundError:
raise Unauthorized("Account no longer exists.") from None
raise UnauthorizedError("Account no longer exists.") from None
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
raise NotFoundError("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
@@ -223,7 +224,7 @@ class ChatApi(Resource):
endpoint="installed_app_stop_chat_completion",
)
class ChatStopApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_installed_app
def post(
@@ -240,4 +241,4 @@ class ChatStopApi(Resource):
app_mode=app_mode,
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return SimpleResultResponse(result="success").model_dump(mode="json"), HTTPStatus.OK

View File

@@ -2,6 +2,7 @@
from collections.abc import Callable
from functools import wraps
from http import HTTPStatus
from uuid import UUID
from flask_restx import Resource
@@ -84,7 +85,7 @@ def _conversation_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@console_ns.route("/installed-apps/<uuid:installed_app_id>/conversations", endpoint="installed_app_conversations")
class ConversationListApi(Resource):
@console_ns.doc(params=query_params_from_model(ConversationListQuery))
@console_ns.response(200, "Success", console_ns.models[ConversationInfiniteScrollPagination.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ConversationInfiniteScrollPagination.__name__])
@console_account_admission()
@get_installed_app
@model_validate(ConversationListQuery)
@@ -92,7 +93,7 @@ class ConversationListApi(Resource):
def get(
self, query: ConversationListQuery, request_context: RequestContext, installed_app: InstalledAppRef
) -> dict[str, object]:
page = application_services().installed_app_conversations.get_page(
page = application_services().installed_apps.conversations.get_page(
installed_app=installed_app,
account_id=request_context.account_id,
last_id=query.last_id or None,
@@ -106,15 +107,15 @@ class ConversationListApi(Resource):
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>", endpoint="installed_app_conversation"
)
class ConversationApi(Resource):
@console_ns.response(204, "Conversation deleted successfully")
@console_ns.response(HTTPStatus.NO_CONTENT, "Conversation deleted successfully")
@console_account_admission()
@get_installed_app
@_conversation_errors
def delete(self, request_context: RequestContext, installed_app: InstalledAppRef, c_id: UUID) -> tuple[str, int]:
application_services().installed_app_conversations.delete(
application_services().installed_apps.conversations.delete(
installed_app=installed_app, account_id=request_context.account_id, conversation_id=str(c_id)
)
return "", 204
return "", HTTPStatus.NO_CONTENT
@console_ns.route(
@@ -123,7 +124,9 @@ class ConversationApi(Resource):
)
class ConversationRenameApi(Resource):
@console_ns.expect(console_ns.models[ConversationRenamePayload.__name__])
@console_ns.response(200, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__])
@console_ns.response(
HTTPStatus.OK, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__]
)
@console_account_admission()
@get_installed_app
@model_validate(ConversationRenamePayload)
@@ -135,7 +138,7 @@ class ConversationRenameApi(Resource):
installed_app: InstalledAppRef,
c_id: UUID,
) -> dict[str, object]:
conversation = application_services().installed_app_conversations.rename(
conversation = application_services().installed_apps.conversations.rename(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=str(c_id),
@@ -149,12 +152,12 @@ class ConversationRenameApi(Resource):
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>/pin", endpoint="installed_app_conversation_pin"
)
class ConversationPinApi(Resource):
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ResultResponse.__name__])
@console_account_admission()
@get_installed_app
@_conversation_errors
def patch(self, request_context: RequestContext, installed_app: InstalledAppRef, c_id: UUID) -> dict[str, object]:
application_services().installed_app_conversations.set_pinned(
application_services().installed_apps.conversations.set_pinned(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=str(c_id),
@@ -168,12 +171,12 @@ class ConversationPinApi(Resource):
endpoint="installed_app_conversation_unpin",
)
class ConversationUnPinApi(Resource):
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ResultResponse.__name__])
@console_account_admission()
@get_installed_app
@_conversation_errors
def patch(self, request_context: RequestContext, installed_app: InstalledAppRef, c_id: UUID) -> dict[str, object]:
application_services().installed_app_conversations.set_pinned(
application_services().installed_apps.conversations.set_pinned(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=str(c_id),

View File

@@ -1,7 +1,15 @@
from http import HTTPStatus
from core.logging.context import get_request_id
from libs.exception import BaseHTTPException
class AgentVersionNotFoundHTTPError(BaseHTTPException):
error_code = "agent_version_not_found_error"
description = "Agent config version not found."
code = HTTPStatus.NOT_FOUND
class InstalledAppHTTPError(BaseHTTPException):
"""A Console error with safe details and a request ID for troubleshooting."""
@@ -14,115 +22,115 @@ class InstalledAppHTTPError(BaseHTTPException):
class InstalledAppNotFoundHTTPError(InstalledAppHTTPError):
error_code = "installed_app_not_found"
description = "The app was not found in this workspace."
code = 404
code = HTTPStatus.NOT_FOUND
class InstalledAppUnavailableHTTPError(InstalledAppHTTPError):
error_code = "installed_app_unavailable"
description = "The app is not available in this app library."
code = 404
code = HTTPStatus.NOT_FOUND
class InstalledAppInvalidCursorError(InstalledAppHTTPError):
error_code = "invalid_cursor"
description = "The app list cursor is invalid. Refresh the list and try again."
code = 400
code = HTTPStatus.BAD_REQUEST
class WebAppAccessUnavailableHTTPError(InstalledAppHTTPError):
error_code = "web_app_access_unavailable"
description = "The app access service is unavailable. Try again later."
code = 503
code = HTTPStatus.SERVICE_UNAVAILABLE
class ConversationNotFoundHTTPError(InstalledAppHTTPError):
error_code = "conversation_not_found"
description = "The conversation was not found for this account and app."
code = 404
code = HTTPStatus.NOT_FOUND
class ConversationCursorNotFoundHTTPError(InstalledAppHTTPError):
error_code = "conversation_cursor_not_found"
description = "The conversation cursor is not in the current list. Refresh the list and try again."
code = 404
code = HTTPStatus.NOT_FOUND
class ConversationFirstMessageNotFoundHTTPError(InstalledAppHTTPError):
error_code = "conversation_first_message_not_found"
description = "The conversation has no message from which to generate a name."
code = 404
code = HTTPStatus.NOT_FOUND
class ConversationNameRequiredHTTPError(InstalledAppHTTPError):
error_code = "conversation_name_required"
description = "A name is required when automatic naming is disabled."
code = 400
code = HTTPStatus.BAD_REQUEST
class MessageNotFoundHTTPError(InstalledAppHTTPError):
error_code = "message_not_found"
description = "The message was not found for this account and app."
code = 404
code = HTTPStatus.NOT_FOUND
class MessageCursorNotFoundHTTPError(InstalledAppHTTPError):
error_code = "message_cursor_not_found"
description = "The message cursor is not in this conversation. Refresh the list and try again."
code = 404
code = HTTPStatus.NOT_FOUND
class MessageFeedbackRatingRequiredHTTPError(InstalledAppHTTPError):
error_code = "message_feedback_rating_required"
description = "A rating is required when there is no existing feedback to remove."
code = 400
code = HTTPStatus.BAD_REQUEST
class NotCompletionAppError(BaseHTTPException):
error_code = "not_completion_app"
description = "Not Completion App"
code = 400
code = HTTPStatus.BAD_REQUEST
class NotChatAppError(BaseHTTPException):
error_code = "not_chat_app"
description = "App mode is invalid."
code = 400
code = HTTPStatus.BAD_REQUEST
class NotWorkflowAppError(BaseHTTPException):
error_code = "not_workflow_app"
description = "Only support workflow app."
code = 400
code = HTTPStatus.BAD_REQUEST
class AppSuggestedQuestionsAfterAnswerDisabledError(BaseHTTPException):
error_code = "app_suggested_questions_after_answer_disabled"
description = "Function Suggested questions after answer disabled."
code = 403
code = HTTPStatus.FORBIDDEN
class AppAccessDeniedError(BaseHTTPException):
error_code = "access_denied"
description = "App access denied."
code = 403
code = HTTPStatus.FORBIDDEN
class RecommendedAppNotFoundError(BaseHTTPException):
error_code = "recommended_app_not_found"
description = "Recommended app not found."
code = 404
code = HTTPStatus.NOT_FOUND
class AppPreviewSiteUnavailableError(BaseHTTPException):
error_code = "app_site_unavailable"
description = "The app preview site is unavailable."
code = 403
code = HTTPStatus.FORBIDDEN
class AppPreviewOwnerUnavailableError(BaseHTTPException):
error_code = "app_owner_unavailable"
description = "The app preview owner is unavailable."
code = 403
code = HTTPStatus.FORBIDDEN
class TrialAppNotAllowed(BaseHTTPException):
@@ -132,7 +140,7 @@ class TrialAppNotAllowed(BaseHTTPException):
"""
error_code = "trial_app_not_allowed"
code = 403
code = HTTPStatus.FORBIDDEN
description = "the app is not allowed to be trial."
@@ -143,11 +151,11 @@ class TrialAppLimitExceeded(BaseHTTPException):
"""
error_code = "trial_app_limit_exceeded"
code = 403
code = HTTPStatus.FORBIDDEN
description = "The user has exceeded the trial app limit."
class TrialAppFeatureDisabledError(BaseHTTPException):
error_code = "trial_app_feature_disabled"
code = 403
code = HTTPStatus.FORBIDDEN
description = "Trial app feature is not enabled."

View File

@@ -1,6 +1,7 @@
import base64
import binascii
import logging
from http import HTTPStatus
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field
@@ -139,13 +140,13 @@ register_response_schema_models(
@console_ns.route("/installed-apps")
class InstalledAppsListApi(Resource):
@console_ns.doc(params=query_params_from_model(InstalledAppsListQuery))
@console_ns.response(200, "Success", console_ns.models[InstalledAppListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[InstalledAppListResponse.__name__])
@console_account_admission()
@model_validate(InstalledAppsListQuery)
def get(self, query: InstalledAppsListQuery, request_context: RequestContext) -> dict[str, object]:
cursor = _decode_installed_app_cursor(query.cursor)
try:
page = application_services().installed_apps.get_visible_page(
page = application_services().installed_apps.management.get_visible_page(
tenant_id=request_context.active_workspace_id,
user_id=request_context.account_id,
cursor=cursor,
@@ -184,7 +185,7 @@ class InstalledAppsListApi(Resource):
class InstalledAppApi(Resource):
"""Read or update an admitted workspace installation."""
@console_ns.response(200, "Success", console_ns.models[InstalledAppResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[InstalledAppResponse.__name__])
@console_account_admission()
@get_installed_app
def get(
@@ -193,7 +194,7 @@ class InstalledAppApi(Resource):
installed_app: InstalledAppRef,
) -> dict[str, object]:
try:
detail = application_services().installed_apps.get_detail(
detail = application_services().installed_apps.management.get_detail(
installed_app=installed_app, account_id=request_context.account_id
)
except InstalledAppUnavailableError as error:
@@ -208,7 +209,7 @@ class InstalledAppApi(Resource):
),
)
@console_ns.response(200, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
@console_ns.expect(console_ns.models[InstalledAppUpdatePayload.__name__])
@console_account_admission()
@get_installed_app
@@ -217,7 +218,9 @@ class InstalledAppApi(Resource):
self, req_data: InstalledAppUpdatePayload, request_context: RequestContext, installed_app: InstalledAppRef
) -> dict[str, str]:
try:
application_services().installed_apps.set_pinned(installed_app=installed_app, is_pinned=req_data.is_pinned)
application_services().installed_apps.management.set_pinned(
installed_app=installed_app, is_pinned=req_data.is_pinned
)
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error

View File

@@ -33,7 +33,7 @@ def get_installed_app[T, **P, R](
if installed_app_id is None:
raise RuntimeError("The installed-app admission route must provide installed_app_id")
try:
installed_app = application_services().installed_app_access.get_access(
installed_app = application_services().installed_apps.access.get_access(
installed_app_id=str(installed_app_id),
tenant_id=request_context.active_workspace_id,
account_id=request_context.account_id,

View File

@@ -3,15 +3,16 @@
import logging
from collections.abc import Callable
from functools import wraps
from http import HTTPStatus
from typing import Literal
from uuid import UUID
from flask import Response
from flask_restx import Resource
from pydantic import BaseModel
from werkzeug.exceptions import HTTPException, InternalServerError, Unauthorized
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
from controllers.common.errors import InternalServerError, UnauthorizedError
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.app.error import (
@@ -41,6 +42,7 @@ from fields.conversation_fields import ResultResponse
from fields.message_fields import ExploreMessageInfiniteScrollPagination, SuggestedQuestionsResponse
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.exception import BaseHTTPException
from machinery.context import RequestContext
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
@@ -55,6 +57,7 @@ from services.errors.message import (
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_generation_service import InstalledAppNotCompletionError
from services.installed_app_message_service import FeedbackRatingRequiredError, MessageNotChatAppError
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
logger = logging.getLogger(__name__)
@@ -81,8 +84,8 @@ def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
raise InstalledAppNotFoundHTTPError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
except (AccountNotFoundError, SuggestedQuestionsActorNotFoundError) as error:
raise UnauthorizedError("Account no longer exists.") from error
except MessageNotChatAppError as error:
raise NotChatAppError() from error
except InstalledAppNotCompletionError as error:
@@ -107,7 +110,7 @@ def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
raise ProviderModelCurrentlyNotSupportError() from error
except InvokeError as error:
raise CompletionRequestError(error.description) from error
except (HTTPException, ValueError):
except (BaseHTTPException, ValueError):
raise
except Exception as error:
logger.exception("Installed-app message operation failed")
@@ -122,7 +125,7 @@ def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
)
class MessageListApi(Resource):
@console_ns.doc(params=query_params_from_model(MessageListQuery))
@console_ns.response(200, "Success", console_ns.models[ExploreMessageInfiniteScrollPagination.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ExploreMessageInfiniteScrollPagination.__name__])
@console_account_admission()
@get_installed_app
@model_validate(MessageListQuery)
@@ -130,7 +133,7 @@ class MessageListApi(Resource):
def get(
self, query: MessageListQuery, request_context: RequestContext, installed_app: InstalledAppRef
) -> dict[str, object]:
page = application_services().installed_app_messages.get_page(
page = application_services().installed_apps.messages.get_page(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=query.conversation_id,
@@ -146,7 +149,7 @@ class MessageListApi(Resource):
)
class MessageFeedbackApi(Resource):
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
@console_ns.response(200, "Feedback submitted successfully", console_ns.models[ResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Feedback submitted successfully", console_ns.models[ResultResponse.__name__])
@console_account_admission()
@get_installed_app
@model_validate(MessageFeedbackPayload)
@@ -158,7 +161,7 @@ class MessageFeedbackApi(Resource):
installed_app: InstalledAppRef,
message_id: UUID,
) -> dict[str, object]:
application_services().installed_app_messages.set_feedback(
application_services().installed_apps.messages.set_feedback(
installed_app=installed_app,
account_id=request_context.account_id,
message_id=str(message_id),
@@ -174,7 +177,7 @@ class MessageFeedbackApi(Resource):
)
class MessageMoreLikeThisApi(Resource):
@console_ns.doc(params=query_params_from_model(MoreLikeThisQuery))
@console_ns.response(200, "Success")
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_installed_app
@model_validate(MoreLikeThisQuery)
@@ -186,7 +189,7 @@ class MessageMoreLikeThisApi(Resource):
installed_app: InstalledAppRef,
message_id: UUID,
) -> Response:
response = application_services().installed_app_generation.generate_more_like_this(
response = application_services().installed_apps.generation.generate_more_like_this(
installed_app=installed_app,
account_id=request_context.account_id,
message_id=str(message_id),
@@ -201,17 +204,22 @@ class MessageMoreLikeThisApi(Resource):
endpoint="installed_app_suggested_question",
)
class MessageSuggestedQuestionApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
@console_account_admission()
@get_installed_app
@_message_errors
def get(
self, request_context: RequestContext, installed_app: InstalledAppRef, message_id: UUID
) -> dict[str, object]:
if installed_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
raise NotChatAppError()
try:
questions = application_services().installed_app_messages.get_suggested_questions(
installed_app=installed_app,
account_id=request_context.account_id,
questions = application_services().message_suggested_questions.get_suggested_questions(
app_id=installed_app.app_id,
app_owner_tenant_id=installed_app.app_owner_tenant_id,
expected_app_mode=installed_app.app_mode,
actor=SuggestedQuestionsAccount(account_id=request_context.account_id, invoke_from="explore"),
message_id=str(message_id),
)
except (

View File

@@ -1,3 +1,4 @@
from http import HTTPStatus
from typing import Any
from flask_restx import Resource
@@ -32,7 +33,7 @@ register_response_schema_models(console_ns, Parameters, ExploreAppMetaResponse)
class AppParameterApi(Resource):
"""Resource for app variables."""
@console_ns.response(200, "Success", console_ns.models[Parameters.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[Parameters.__name__])
@console_account_admission()
@get_installed_app
def get(self, request_context: RequestContext, installed_app: InstalledAppRef) -> dict[str, object]:
@@ -47,7 +48,7 @@ class AppParameterApi(Resource):
@console_ns.route("/installed-apps/<uuid:installed_app_id>/meta", endpoint="installed_app_meta")
class ExploreAppMetaApi(Resource):
@console_ns.response(200, "Success", console_ns.models[ExploreAppMetaResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ExploreAppMetaResponse.__name__])
@console_account_admission()
@get_installed_app
def get(self, request_context: RequestContext, installed_app: InstalledAppRef) -> dict[str, object]:

View File

@@ -1,8 +1,10 @@
from typing import Any
from http import HTTPStatus
from typing import Any, Literal
from uuid import UUID
from flask import make_response, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field, field_validator
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
@@ -10,7 +12,9 @@ from controllers.console.explore.error import RecommendedAppNotFoundError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from extensions.ext_application_services import application_services
from fields.app_export_fields import AppExportResponse
from fields.base import ResponseModel
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
from libs.helper import build_icon_url, dump_response
from machinery.context import RequestContext
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
@@ -20,6 +24,13 @@ class RecommendedAppsQuery(BaseModel):
language: str = Field(default="en-US", description="Language code for recommended app localization")
class RecommendedAgentExportQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUID = Field(description="Current published template snapshot ID")
format: Literal["yaml", "ifpkg"] = Field(default="ifpkg", description="Export format; defaults to ifpkg")
class RecommendedAppInfoResponse(ResponseModel):
id: str
name: str | None = None
@@ -77,11 +88,14 @@ class RecommendedAppDetailResponse(ResponseModel):
mode: str
export_data: str
can_trial: bool
package_url: str | None = Field(default=None, description="Download URL for a New Agent .ifpkg template")
version_id: str | None = Field(default=None, description="Published version for direct local template creation")
register_schema_models(
console_ns,
RecommendedAppsQuery,
RecommendedAgentExportQuery,
)
register_response_schema_models(
console_ns,
@@ -90,13 +104,14 @@ register_response_schema_models(
RecommendedAppListResponse,
LearnDifyAppListResponse,
RecommendedAppDetailResponse,
AppExportResponse,
)
@console_ns.route("/explore/apps")
class RecommendedAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@console_ns.response(200, "Success", console_ns.models[RecommendedAppListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[RecommendedAppListResponse.__name__])
@console_account_admission()
@model_validate(RecommendedAppsQuery)
def get(self, req_data: RecommendedAppsQuery, _request_context: RequestContext):
@@ -111,7 +126,7 @@ class RecommendedAppListApi(Resource):
@console_ns.route("/explore/apps/learn-dify")
class LearnDifyAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
@console_account_admission()
@model_validate(RecommendedAppsQuery)
def get(self, req_data: RecommendedAppsQuery, _request_context: RequestContext):
@@ -125,8 +140,8 @@ class LearnDifyAppListApi(Resource):
@console_ns.route("/explore/apps/<uuid:app_id>")
class RecommendedAppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
@console_ns.response(404, "Recommended app not found")
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
@console_ns.response(HTTPStatus.NOT_FOUND, "Recommended app not found")
@console_account_admission()
def get(self, _request_context: RequestContext, app_id: UUID):
try:
@@ -134,3 +149,37 @@ class RecommendedAppApi(Resource):
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
return dump_response(RecommendedAppDetailResponse, result)
@console_ns.route("/trial-apps/<uuid:app_id>/export")
class RecommendedAgentExportApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAgentExportQuery))
@console_ns.doc(
produces=["application/json", "application/zip"],
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/zip"]},
)
@console_ns.response(200, "Published Agent template export", console_ns.models[AppExportResponse.__name__])
@console_ns.response(404, "Published template unavailable")
@model_validate(RecommendedAgentExportQuery)
def get(self, query: RecommendedAgentExportQuery, app_id: UUID):
# Both formats require public catalog membership and the current published version.
try:
exported = application_services().recommended_app_packages.export(
app_id=str(app_id), version_id=query.version_id, format=query.format
)
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
if isinstance(exported, str):
response = make_response(dump_response(AppExportResponse, {"data": exported}))
response.headers["Cache-Control"] = "no-store"
return response
try:
response = send_file(
exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename
)
except Exception:
exported.close()
raise
response.headers["Cache-Control"] = "no-store"
response.call_on_close(exported.close)
return response

View File

@@ -1,9 +1,10 @@
from http import HTTPStatus
from uuid import UUID
from flask_restx import Resource
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery
from controllers.common.errors import NotFoundError
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 NotCompletionAppError
@@ -32,7 +33,7 @@ def _require_completion_app(installed_app: InstalledAppRef) -> str:
@console_ns.route("/installed-apps/<uuid:installed_app_id>/saved-messages", endpoint="installed_app_saved_messages")
class SavedMessageListApi(Resource):
@console_ns.doc(params=query_params_from_model(SavedMessageListQuery))
@console_ns.response(200, "Success", console_ns.models[SavedMessageInfiniteScrollPagination.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SavedMessageInfiniteScrollPagination.__name__])
@console_account_admission()
@get_installed_app
@model_validate(SavedMessageListQuery)
@@ -52,7 +53,7 @@ class SavedMessageListApi(Resource):
return dump_response(SavedMessageInfiniteScrollPagination, pagination)
@console_ns.expect(console_ns.models[SavedMessageCreatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ResultResponse.__name__])
@console_account_admission()
@get_installed_app
@model_validate(SavedMessageCreatePayload)
@@ -71,7 +72,7 @@ class SavedMessageListApi(Resource):
message_id=str(req_data.message_id),
)
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
raise NotFoundError("Message Not Exists.")
return ResultResponse(result="success").model_dump(mode="json")
@@ -80,7 +81,7 @@ class SavedMessageListApi(Resource):
"/installed-apps/<uuid:installed_app_id>/saved-messages/<uuid:message_id>", endpoint="installed_app_saved_message"
)
class SavedMessageApi(Resource):
@console_ns.response(204, "Saved message deleted successfully")
@console_ns.response(HTTPStatus.NO_CONTENT, "Saved message deleted successfully")
@console_account_admission()
@get_installed_app
def delete(
@@ -96,4 +97,4 @@ class SavedMessageApi(Resource):
message_id=str(message_id),
)
return "", 204
return "", HTTPStatus.NO_CONTENT

View File

@@ -3,17 +3,18 @@ from collections.abc import Callable, Mapping
from dataclasses import asdict
from datetime import datetime
from functools import wraps
from http import HTTPStatus
from typing import Any, Literal
from uuid import UUID
from flask import Response, request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
import services
from configs import dify_config
from controllers.common.audio_response import audio_binary_response
from controllers.common.errors import InternalServerError, NotFoundError, UnauthorizedError
from controllers.common.fields import (
AudioBinaryResponse,
AudioTranscriptResponse,
@@ -45,23 +46,26 @@ from controllers.console.app.error import (
)
from controllers.console.app.preview_admission import get_preview_app
from controllers.console.explore.error import (
AppPreviewOwnerUnavailableError as AppPreviewOwnerUnavailableHttpError,
)
from controllers.console.explore.error import (
AppPreviewSiteUnavailableError as AppPreviewSiteUnavailableHttpError,
)
from controllers.console.explore.error import (
AgentVersionNotFoundHTTPError,
AppSuggestedQuestionsAfterAnswerDisabledError,
NotChatAppError,
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.trial_app_admission import get_trial_app
from controllers.console.explore.error import (
AppPreviewOwnerUnavailableError as AppPreviewOwnerUnavailableHttpError,
)
from controllers.console.explore.error import (
AppPreviewSiteUnavailableError as AppPreviewSiteUnavailableHttpError,
)
from controllers.console.explore.trial_app_admission import get_trial_app, get_trial_app_for_stop
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request_context
from controllers.console.flask_admission import console_account_admission
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
AppInvokeQuotaExceededError,
ModelCurrentlyNotSupportError,
@@ -71,6 +75,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 fields.agent_fields import AgentAppComposerResponse
from fields.base import ResponseModel
from fields.conversation_variable_fields import WorkflowConversationVariableResponse
from fields.file_fields import FileResponse, FileWithSignedUrl
@@ -78,12 +83,15 @@ from fields.message_fields import SuggestedQuestionsResponse
from graphon.model_runtime.errors.invoke import InvokeError
from graphon.variables import SecretVariable, VariableBase
from libs import helper
from libs.exception import BaseHTTPException
from libs.helper import dump_response, to_timestamp, uuid_value
from libs.stream import close_stream
from libs.url_utils import normalize_api_base_url
from machinery.context import RequestContext
from models.enums import CreatorUserRole
from models.model import AppMode
from services.account_errors import AccountNotFoundError
from services.agent.errors import AgentVersionNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import (
AppPreviewOwnerUnavailableError,
@@ -152,7 +160,7 @@ class TrialDatasetListQuery(BaseModel):
ids: list[str] = Field(default_factory=list, description="Dataset IDs")
type TrialAppMode = Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
type TrialAppMode = Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
type TrialIconType = Literal["emoji", "image", "link"]
type JsonObject = dict[str, Any]
@@ -447,6 +455,7 @@ register_response_schema_models(
SiteResponse,
SuggestedQuestionsResponse,
TrialAppDetailResponse,
AgentAppComposerResponse,
TrialDatasetListResponse,
TrialWorkflowResponse,
)
@@ -460,7 +469,7 @@ simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
)
class TrialAppFileUploadApi(Resource):
@console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS)
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__])
@console_ns.response(HTTPStatus.CREATED, "File uploaded successfully", console_ns.models[FileResponse.__name__])
@console_account_admission()
@get_trial_app
@cloud_edition_billing_resource_check("documents")
@@ -470,7 +479,7 @@ class TrialAppFileUploadApi(Resource):
request_context=request_context,
resource_tenant_id=trial_app.tenant_id,
)
return dump_response(FileResponse, upload_file), 201
return dump_response(FileResponse, upload_file), HTTPStatus.CREATED
@console_ns.route(
@@ -479,7 +488,9 @@ class TrialAppFileUploadApi(Resource):
)
class TrialAppRemoteFileUploadApi(Resource):
@console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__])
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__])
@console_ns.response(
HTTPStatus.CREATED, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__]
)
@console_account_admission()
@get_trial_app
@cloud_edition_billing_resource_check("documents")
@@ -493,7 +504,7 @@ class TrialAppRemoteFileUploadApi(Resource):
current_user=FileUploadActor(id=request_context.account_id, creator_role=CreatorUserRole.ACCOUNT),
resource_tenant_id=trial_app.tenant_id,
)
return dump_response(FileWithSignedUrl, remote_file), 201
return dump_response(FileWithSignedUrl, remote_file), HTTPStatus.CREATED
@console_ns.route(
@@ -502,7 +513,7 @@ class TrialAppRemoteFileUploadApi(Resource):
)
class TrialAppWorkflowRunApi(Resource):
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
@console_ns.response(200, "Success")
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_trial_app
@model_validate(WorkflowRunRequest)
@@ -511,7 +522,7 @@ class TrialAppWorkflowRunApi(Resource):
Run workflow
"""
try:
response = application_services().trial_app_generation.generate_workflow(
response = application_services().trial_apps.generation.generate_workflow(
trial_app=trial_app,
account_id=request_context.account_id,
args=req_data.model_dump(),
@@ -523,7 +534,7 @@ class TrialAppWorkflowRunApi(Resource):
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
raise UnauthorizedError("Account no longer exists.") from error
except services.errors.app_model_config.AppModelConfigBrokenError as error:
raise AppUnavailableError() from error
except ProviderTokenNotInitError as ex:
@@ -545,7 +556,7 @@ class TrialAppWorkflowRunApi(Resource):
@console_ns.route("/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop")
class TrialAppWorkflowTaskStopApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_trial_app
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str) -> dict[str, object]:
@@ -566,7 +577,7 @@ class TrialAppWorkflowTaskStopApi(Resource):
)
class TrialChatApi(Resource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@console_ns.response(200, "Success")
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_trial_app
@model_validate(ChatRequest)
@@ -580,7 +591,7 @@ class TrialChatApi(Resource):
args["parent_message_id"] = uuid_value(args["parent_message_id"])
try:
response = application_services().trial_app_generation.generate_chat(
response = application_services().trial_apps.generation.generate_chat(
trial_app=trial_app,
account_id=request_context.account_id,
args=args,
@@ -592,14 +603,16 @@ class TrialChatApi(Resource):
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
raise UnauthorizedError("Account no longer exists.") from error
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
raise NotFoundError("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except (AgentAppGeneratorError, AgentAppNotPublishedError):
raise AppUnavailableError() from None
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -617,16 +630,37 @@ class TrialChatApi(Resource):
raise InternalServerError()
@console_ns.route(
"/trial-apps/<uuid:app_id>/chat-messages/<string:task_id>/stop",
endpoint="trial_app_stop_chat_completion",
)
class TrialChatTaskStopApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_trial_app_for_stop
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str):
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise NotChatAppError()
application_services().app_tasks.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=request_context.account_id,
app_mode=AppMode.value_of(trial_app.app_mode),
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route(
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
endpoint="trial_app_suggested_question",
)
class TrialMessageSuggestedQuestionApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
@console_account_admission()
@get_trial_app
def get(self, request_context: RequestContext, trial_app: TrialAppRef, message_id: UUID) -> dict[str, object]:
if trial_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise NotChatAppError()
try:
@@ -640,11 +674,11 @@ class TrialMessageSuggestedQuestionApi(Resource):
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except SuggestedQuestionsActorNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
raise UnauthorizedError("Account no longer exists.") from error
except MessageNotExistsError:
raise NotFound("Message not found")
raise NotFoundError("Message not found")
except ConversationNotExistsError:
raise NotFound("Conversation not found")
raise NotFoundError("Conversation not found")
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except ProviderTokenNotInitError as ex:
@@ -687,7 +721,9 @@ def _trial_audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
raise ProviderModelCurrentlyNotSupportError() from error
except InvokeError as error:
raise CompletionRequestError(error.description) from error
except (HTTPException, ValueError):
except AgentVersionNotFoundError as error:
raise AgentVersionNotFoundHTTPError() from error
except (BaseHTTPException, ValueError):
raise
except Exception as error:
logger.exception("Trial audio operation failed")
@@ -701,18 +737,25 @@ def _trial_audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
endpoint="trial_app_audio",
)
class TrialChatAudioApi(Resource):
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@console_account_admission()
@get_trial_app
@_trial_audio_errors
def post(self, request_context: RequestContext, trial_app: TrialAppRef) -> dict[str, object]:
# Let Flask handle multipart parsing errors before mapping audio operation failures.
file = request.files.get("file")
audio = AudioUpload(stream=file.stream, mime_type=file.mimetype) if file is not None else None
return self._transcript_asr(request_context=request_context, trial_app=trial_app, audio=audio)
@_trial_audio_errors
def _transcript_asr(
self, *, request_context: RequestContext, trial_app: TrialAppRef, audio: AudioUpload | None
) -> dict[str, object]:
"""Transcribe an upload; None lets the audio service report a missing file."""
transcript = application_services().app_audio.transcript_asr(
app=AudioAppRef(app_id=trial_app.app_id, tenant_id=trial_app.tenant_id, app_mode=trial_app.app_mode),
audio=audio,
)
application_services().trial_app_usage.record(app_id=trial_app.app_id, account_id=request_context.account_id)
application_services().trial_apps.usage.record(app_id=trial_app.app_id, account_id=request_context.account_id)
return dump_response(AudioTranscriptResponse, transcript)
@@ -722,7 +765,7 @@ class TrialChatAudioApi(Resource):
)
class TrialChatTextApi(Resource):
@console_ns.expect(console_ns.models[TextToSpeechRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioBinaryResponse.__name__])
@console_account_admission()
@get_trial_app
@model_validate(TextToSpeechRequest)
@@ -741,7 +784,7 @@ class TrialChatTextApi(Resource):
try:
# Preserve usage after MIME inspection, including a missing message's
# null response. Early provider/MIME failures do not consume a trial.
application_services().trial_app_usage.record(
application_services().trial_apps.usage.record(
app_id=trial_app.app_id, account_id=request_context.account_id
)
except BaseException:
@@ -758,13 +801,13 @@ class TrialChatTextApi(Resource):
)
class TrialCompletionApi(Resource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@console_ns.response(200, "Success")
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_trial_app
@model_validate(CompletionRequest)
def post(self, req_data: CompletionRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
try:
response = application_services().trial_app_generation.generate_completion(
response = application_services().trial_apps.generation.generate_completion(
trial_app=trial_app,
account_id=request_context.account_id,
args=req_data.model_dump(),
@@ -776,9 +819,9 @@ class TrialCompletionApi(Resource):
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
raise UnauthorizedError("Account no longer exists.") from error
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
raise NotFoundError("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
@@ -805,7 +848,7 @@ class TrialCompletionApi(Resource):
class TrialSitApi(Resource):
"""Resource for trial app sites."""
@console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SiteResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
"""Retrieve app site info.
@@ -828,13 +871,13 @@ class TrialSitApi(Resource):
class TrialAppParameterApi(Resource):
"""Resource for app variables."""
@console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ParametersResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
"""Retrieve app parameters."""
try:
parameters = application_services().app_definitions.get_parameters(app.app_id)
parameters = application_services().app_definitions.get_public_parameters(app.app_id)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
@@ -843,7 +886,7 @@ class TrialAppParameterApi(Resource):
@console_ns.route("/trial-apps/<uuid:app_id>", endpoint="trial_app")
class AppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialAppDetailResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TrialAppDetailResponse.__name__])
@console_account_admission()
@get_preview_app
def get(self, app: AppPreviewRef, request_context: RequestContext) -> dict[str, object]:
@@ -859,7 +902,7 @@ class AppApi(Resource):
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
raise UnauthorizedError("Account no longer exists.") from error
except AppPreviewSiteUnavailableError as error:
raise AppPreviewSiteUnavailableHttpError(str(error)) from error
@@ -872,12 +915,24 @@ class AppApi(Resource):
return dump_response(TrialAppDetailResponse, source)
@console_ns.route("/trial-apps/<uuid:app_id>/agent-composer")
class TrialAgentComposerApi(Resource):
@console_ns.response(200, "Published Agent configuration", console_ns.models[AgentAppComposerResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef):
try:
preview = application_services().app_previews.get_agent_composer(app=app)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
return dump_response(AgentAppComposerResponse, preview)
@console_ns.route(
"/trial-apps/<uuid:app_id>/workflows",
endpoint="trial_app_workflow",
)
class AppWorkflowApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialWorkflowResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TrialWorkflowResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
"""Get a detached workflow definition after catalog preview admission."""
@@ -896,7 +951,7 @@ class AppWorkflowApi(Resource):
)
class DatasetListApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialDatasetListQuery))
@console_ns.response(200, "Success", console_ns.models[TrialDatasetListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TrialDatasetListResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
# These legacy fields are response metadata: the query returns all

View File

@@ -28,8 +28,10 @@ def trial_feature_enable[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def get_trial_app[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
*,
check_usage: bool = True,
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Check trial availability and inject an admitted app after Console admission."""
"""Admit the trial app, optionally skipping generation quota for task cancellation."""
@wraps(view)
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
@@ -37,8 +39,8 @@ def get_trial_app[T, **P, R](
if app_id is None:
raise RuntimeError("The trial app admission route must provide app_id")
try:
trial_app = application_services().trial_app_access.get_access(
app_id=str(app_id), account_id=request_context.account_id
trial_app = application_services().trial_apps.access.get_access(
app_id=str(app_id), account_id=request_context.account_id, check_usage=check_usage
)
except TrialAppUnavailableError as error:
raise TrialAppNotAllowed() from error
@@ -47,3 +49,10 @@ def get_trial_app[T, **P, R](
return view(self, request_context, trial_app, *args, **kwargs)
return trial_feature_enable(decorated)
def get_trial_app_for_stop[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Keep trial access checks without requiring remaining generation quota."""
return get_trial_app(view, check_usage=False)

View File

@@ -1,10 +1,11 @@
import logging
from http import HTTPStatus
from flask import Response
from flask_restx import Resource
from werkzeug.exceptions import InternalServerError, Unauthorized
from controllers.common.controller_schemas import WorkflowRunPayload
from controllers.common.errors import InternalServerError, UnauthorizedError
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_model
from controllers.console.app.error import (
@@ -44,7 +45,7 @@ register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/workflows/run")
class InstalledAppWorkflowRunApi(Resource):
@console_ns.expect(console_ns.models[WorkflowRunPayload.__name__])
@console_ns.response(200, "Success")
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_installed_app
@model_validate(WorkflowRunPayload)
@@ -58,7 +59,7 @@ class InstalledAppWorkflowRunApi(Resource):
Run workflow
"""
try:
response = application_services().installed_app_generation.generate_workflow(
response = application_services().installed_apps.generation.generate_workflow(
installed_app=installed_app,
account_id=request_context.account_id,
args=req_data.model_dump(exclude_none=True),
@@ -69,7 +70,7 @@ class InstalledAppWorkflowRunApi(Resource):
except (AppDefinitionUnavailableError, InstalledAppNotWorkflowError):
raise NotWorkflowAppError() from None
except AccountNotFoundError:
raise Unauthorized("Account no longer exists.") from None
raise UnauthorizedError("Account no longer exists.") from None
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -89,7 +90,7 @@ class InstalledAppWorkflowRunApi(Resource):
@console_ns.route("/installed-apps/<uuid:installed_app_id>/workflows/tasks/<string:task_id>/stop")
class InstalledAppWorkflowTaskStopApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_installed_app
def post(self, request_context: RequestContext, installed_app: InstalledAppRef, task_id: str) -> dict[str, object]:

View File

@@ -6,17 +6,23 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, field_validator
from constants import HIDDEN_VALUE
from extensions.ext_database import db
from controllers.console.flask_admission import console_account_admission
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models.api_based_extension import APIBasedExtension
from services.api_based_extension_service import APIBasedExtensionService
from machinery.context import RequestContext
from services.api_based_extension_application_service import (
APIBasedExtensionError,
APIBasedExtensionInput,
APIBasedExtensionRecord,
APIBasedExtensionUpdate,
)
from services.code_based_extension_service import CodeBasedExtensionService
from ..common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from . import console_ns
from .wraps import account_initialization_required, model_validate, setup_required, with_current_tenant_id
from .wraps import account_initialization_required, model_validate, setup_required
class CodeBasedExtensionQuery(BaseModel):
@@ -77,6 +83,10 @@ register_response_schema_models(
)
def _extension_response(extension: APIBasedExtensionRecord) -> dict[str, Any]:
return dump_response(APIBasedExtensionResponse, extension)
@console_ns.route("/code-based-extension")
class CodeBasedExtensionAPI(Resource):
@console_ns.doc("get_code_based_extension")
@@ -103,41 +113,32 @@ class APIBasedExtensionAPI(Resource):
@console_ns.doc("get_api_based_extensions")
@console_ns.doc(description="Get all API-based extensions for current tenant")
@console_ns.response(200, "Success", console_ns.models[APIBasedExtensionListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
return dump_response(
APIBasedExtensionListResponse,
APIBasedExtensionService.get_all_by_tenant_id(current_tenant_id, session=db.session()),
)
@console_account_admission()
def get(self, request_context: RequestContext):
extensions = application_services().api_based_extensions.list_extensions(request_context)
return dump_response(APIBasedExtensionListResponse, extensions)
@console_ns.doc("create_api_based_extension")
@console_ns.doc(description="Create a new API-based extension")
@console_ns.expect(console_ns.models[APIBasedExtensionPayload.__name__])
@console_ns.response(201, "Extension created successfully", console_ns.models[APIBasedExtensionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@console_account_admission()
@model_validate(APIBasedExtensionPayload)
def post(self, req_data: APIBasedExtensionPayload, current_tenant_id: str):
extension_data = APIBasedExtension(
tenant_id=current_tenant_id,
name=req_data.name,
api_endpoint=req_data.api_endpoint,
api_key=req_data.api_key,
)
def post(self, req_data: APIBasedExtensionPayload, request_context: RequestContext):
try:
extension = application_services().api_based_extensions.create_extension(
request_context,
APIBasedExtensionInput(
name=req_data.name,
api_endpoint=req_data.api_endpoint,
api_key=req_data.api_key,
),
)
except APIBasedExtensionError as error:
# Legacy clients receive the 400 `invalid_param` envelope for every extension failure.
raise ValueError(str(error)) from None
extension = APIBasedExtensionService.save(extension_data, session=db.session())
return APIBasedExtensionResponse(
id=extension.id,
name=extension.name,
api_endpoint=extension.api_endpoint,
api_key=req_data.api_key,
created_at=to_timestamp(extension.created_at),
).model_dump(mode="json"), 201
return _extension_response(extension), 201
@console_ns.route("/api-based-extension/<uuid:id>")
@@ -146,70 +147,48 @@ class APIBasedExtensionDetailAPI(Resource):
@console_ns.doc(description="Get API-based extension by ID")
@console_ns.doc(params={"id": "Extension ID"})
@console_ns.response(200, "Success", console_ns.models[APIBasedExtensionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, id: UUID):
api_based_extension_id = str(id)
@console_account_admission()
def get(self, request_context: RequestContext, id: UUID):
try:
extension = application_services().api_based_extensions.get_extension(request_context, str(id))
except APIBasedExtensionError as error:
raise ValueError(str(error)) from None
return dump_response(
APIBasedExtensionResponse,
APIBasedExtensionService.get_with_tenant_id(
current_tenant_id, api_based_extension_id, session=db.session()
),
)
return _extension_response(extension)
@console_ns.doc("update_api_based_extension")
@console_ns.doc(description="Update API-based extension")
@console_ns.doc(params={"id": "Extension ID"})
@console_ns.expect(console_ns.models[APIBasedExtensionPayload.__name__])
@console_ns.response(200, "Extension updated successfully", console_ns.models[APIBasedExtensionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@console_account_admission()
@model_validate(APIBasedExtensionPayload)
def post(self, req_data: APIBasedExtensionPayload, current_tenant_id: str, id: UUID):
api_based_extension_id = str(id)
def post(self, req_data: APIBasedExtensionPayload, request_context: RequestContext, id: UUID):
try:
extension = application_services().api_based_extensions.update_extension(
request_context,
str(id),
APIBasedExtensionUpdate(
name=req_data.name,
api_endpoint=req_data.api_endpoint,
# The console echoes the masked key back; keep the stored key when it is sent unchanged.
api_key=None if req_data.api_key == HIDDEN_VALUE else req_data.api_key,
),
)
except APIBasedExtensionError as error:
raise ValueError(str(error)) from None
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(
current_tenant_id, api_based_extension_id, session=db.session()
)
api_key_for_response = extension_data_from_db.api_key
extension_data_from_db.name = req_data.name
extension_data_from_db.api_endpoint = req_data.api_endpoint
if req_data.api_key != HIDDEN_VALUE:
extension_data_from_db.api_key = req_data.api_key
api_key_for_response = req_data.api_key
APIBasedExtensionService.save(extension_data_from_db, session=db.session())
return APIBasedExtensionResponse(
id=extension_data_from_db.id,
name=extension_data_from_db.name,
api_endpoint=extension_data_from_db.api_endpoint,
api_key=api_key_for_response,
created_at=to_timestamp(extension_data_from_db.created_at),
).model_dump(mode="json")
return _extension_response(extension)
@console_ns.doc("delete_api_based_extension")
@console_ns.doc(description="Delete API-based extension")
@console_ns.doc(params={"id": "Extension ID"})
@console_ns.response(204, "Extension deleted successfully")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def delete(self, current_tenant_id: str, id: UUID):
api_based_extension_id = str(id)
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(
current_tenant_id, api_based_extension_id, session=db.session()
)
APIBasedExtensionService.delete(extension_data_from_db, session=db.session())
@console_account_admission()
def delete(self, request_context: RequestContext, id: UUID):
try:
application_services().api_based_extensions.delete_extension(request_context, str(id))
except APIBasedExtensionError as error:
raise ValueError(str(error)) from None
return "", 204

View File

@@ -10,22 +10,32 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.rbac import RBAC_CHECKS_ATTR, RBACCheck, enforce_rbac_checks
from controllers.console.admin import admin_required
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
enable_change_email,
enterprise_license_required,
is_allow_transfer_owner,
setup_required,
)
from core.logging.context import get_request_id, get_trace_id
from enums import DeploymentEdition
from enums.account import TenantAccountRole
from libs.login import current_account_with_tenant, login_required
from libs.oauth_bearer import bearer_feature_required
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.system_feature_service import SystemFeatureService
def console_admin_admission[T, **P, R](
view: Callable[Concatenate[T, P], R],
) -> Callable[Concatenate[T, P], R]:
"""Admit a configured admin API key without requiring a Console account."""
return setup_required(admin_required(view))
def console_email_registration_admission[T, **P, R](
view: Callable[Concatenate[T, P], R],
) -> Callable[Concatenate[T, P], R | Response]:
@@ -49,29 +59,26 @@ def console_account_admission[T, **P, R](
require_change_email_enabled: bool = False,
require_initialized: bool = True,
require_valid_enterprise_license: bool = False,
require_owner_transfer_enabled: bool = False,
billing_resource: str | None = None,
require_oauth_bearer_enabled: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac_checks: Sequence[RBACCheck] | None = None,
billing_resource: str | None = None,
) -> Callable[
[Callable[Concatenate[T, RequestContext, P], R]],
Callable[Concatenate[T, P], R | Response],
]:
"""Declare Console account admission and inject a stable RequestContext.
All combinations use this decorator factory. Requirements are data, while
the execution order stays fixed: edition, setup, login/CSRF, optional
account initialization, optional enterprise license and feature checks,
role/RBAC checks, then context construction and optional billing-resource
admission.
Checks run in a fixed order: setup, login/CSRF, optional account
initialization, edition, license, feature and billing checks, role/RBAC
checks, then context construction.
"""
def decorator(
view: Callable[Concatenate[T, RequestContext, P], R],
) -> Callable[Concatenate[T, P], R | Response]:
admitted_view = cloud_edition_billing_resource_check(billing_resource)(view) if billing_resource else view
@wraps(view, updated=())
@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
@@ -91,32 +98,40 @@ def console_account_admission[T, **P, R](
request_id=get_request_id(),
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
)
return admitted_view(self, request_context, *args, **kwargs)
return view(self, request_context, *args, **kwargs)
if rbac_checks is not None:
setattr(inject_request_context, RBAC_CHECKS_ATTR, rbac_checks)
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
if billing_resource is not None:
admitted = cloud_edition_billing_resource_check(billing_resource)(admitted)
if require_change_email_enabled:
admitted = enable_change_email(admitted)
if require_owner_transfer_enabled:
admitted = is_allow_transfer_owner(admitted)
if require_oauth_bearer_enabled:
admitted = bearer_feature_required(admitted)
if require_valid_enterprise_license:
admitted = enterprise_license_required(admitted)
if editions is not None:
admitted = enforce_edition(admitted)
if require_initialized:
admitted = account_initialization_required(admitted)
admitted = login_required(admitted)
admitted = setup_required(admitted)
if editions is None:
return admitted
return admitted
@wraps(view)
def enforce_edition(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
if dify_config.DEPLOYMENT_EDITION not in editions:
def enforce_edition(
admitted: Callable[Concatenate[T, P], R | Response],
) -> Callable[Concatenate[T, P], R | Response]:
@wraps(admitted)
def checked(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
if editions is not None and dify_config.DEPLOYMENT_EDITION not in editions:
abort(HTTPStatus.NOT_FOUND)
return admitted(self, *args, **kwargs)
return enforce_edition
return checked
return decorator

View File

@@ -7,11 +7,8 @@ from controllers.fastopenapi import console_router
from extensions.ext_application_services import application_services
from libs.helper import EmailStr, extract_remote_ip
from libs.password import valid_password
from services.setup_service import (
InitializationValidationRequiredError,
SetupAlreadyCompletedError,
SetupInput,
)
from services.account.contracts import SetupInput
from services.setup_service import InitializationValidationRequiredError, SetupAlreadyCompletedError
from .error import AlreadySetupError, NotInitValidateError
from .init_validate import is_init_validated

View File

@@ -5,11 +5,11 @@ from typing import cast
from flask import Request as FlaskRequest
from core.db.session_factory import session_factory
from extensions.ext_application_services import application_services
from extensions.ext_socketio import sio
from libs.passport import PassportService
from libs.token import extract_access_token
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
from services.account_service import AccountService
from services.workflow_collaboration_service import WorkflowCollaborationService
repository = WorkflowCollaborationRepository()
@@ -43,8 +43,8 @@ def socket_connect(sid, environ, auth):
logging.warning("Socket connect rejected: missing user_id (sid=%s)", sid)
return False
with sio.app.app_context(), session_factory.create_session() as session:
user = AccountService.load_logged_in_account(account_id=user_id, session=session)
with sio.app.app_context():
user = application_services().accounts.identity.load_user(user_id)
if not user:
logging.warning("Socket connect rejected: user not found (user_id=%s, sid=%s)", user_id, sid)
return False

View File

@@ -1,15 +1,13 @@
from http import HTTPStatus
from typing import Annotated, Literal
from typing import Annotated, Literal, NoReturn
from urllib import parse
from uuid import UUID
from flask import abort, request
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from werkzeug.exceptions import NotFound
from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
from controllers.common.fields import SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
@@ -22,30 +20,25 @@ from controllers.console.auth.error import (
MemberNotInTenantError,
NotOwnerError,
OwnerTransferLimitError,
OwnerTransferRateLimitExceededError,
)
from controllers.console.error import EmailSendIpLimitError, SeatsLimitExceeded, WorkspaceMembersLimitExceeded
from controllers.console.flask_admission import console_account_admission
from controllers.console.workspace.error import InvalidMemberRoleError
from controllers.console.wraps import (
account_initialization_required,
is_allow_transfer_owner,
setup_required,
with_current_user,
)
from enums import DeploymentEdition
from enums.account import TenantAccountRole
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
from fields.member_fields import AccountWithRoleListResponse, AccountWithRoleResponse
from libs.helper import dump_response, extract_remote_ip
from libs.login import login_required
from machinery.context import RequestContext
from models.account import Account, TenantAccountJoin, TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import AccountAlreadyInTenantError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from services.account_errors import AccountNotFoundError
from services.errors import workspace as workspace_errors
from services.errors.base import NoPermissionError
from services.errors.workspace import (
InvalidWorkspaceMemberRoleError,
WorkspaceApplicationError,
WorkspaceInvitationQuotaError,
)
class MemberInvitePayload(BaseModel):
@@ -142,62 +135,6 @@ register_response_schema_models(
)
def _is_role_enabled(role: TenantAccountRole | str) -> bool:
if role != TenantAccountRole.DATASET_OPERATOR:
return True
return dify_config.DATASET_OPERATOR_ENABLED
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> tuple[int, int]:
new_member_count = 0
new_account_count = 0
for email in emails:
account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session())
if not account:
new_member_count += 1
new_account_count += 1
continue
exists = db.session.scalar(
select(TenantAccountJoin.id)
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == account.id)
.limit(1)
)
if not exists:
new_member_count += 1
return new_member_count, new_account_count
def _count_current_members(tenant_id: str) -> int:
return (
db.session.scalar(select(func.count(TenantAccountJoin.id)).where(TenantAccountJoin.tenant_id == tenant_id)) or 0
)
def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_account_count: int) -> None:
if new_member_count <= 0:
return
features = FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True)
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()
if new_account_count > 0:
seats = SystemFeatureService.get_license().seats
if not seats.is_available(new_account_count):
raise SeatsLimitExceeded()
return
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:
raise WorkspaceMembersLimitExceeded()
@console_ns.route("/workspaces/current/members")
class MemberListApi(Resource):
"""List all members of current tenant."""
@@ -205,7 +142,7 @@ class MemberListApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
@console_account_admission()
def get(self, request_context: RequestContext):
members = application_services().workspace_member_queries.list_current(request_context)
members = application_services().workspaces.member_queries.list_current(request_context)
serialized_members = [
{
"id": member.id,
@@ -235,123 +172,68 @@ class MemberInviteEmailApi(Resource):
"Invalid role or workspace member limit exceeded",
console_ns.models[MemberInviteErrorResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
args = MemberInvitePayload.model_validate(payload)
invitee_emails = args.emails
invitee_role = args.role
interface_language = args.language
if not dify_config.RBAC_ENABLED:
if not TenantAccountRole.is_valid_role(invitee_role):
raise InvalidMemberRoleError()
if not TenantAccountRole.is_non_owner_role(TenantAccountRole(invitee_role)):
raise InvalidMemberRoleError()
inviter = current_user
if not inviter.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(invitee_role):
raise InvalidMemberRoleError()
# Check workspace permission for member invitations
from libs.workspace_permission import check_workspace_member_invite_permission
check_workspace_member_invite_permission(inviter.current_tenant.id)
invitation_results: list[MemberInviteResultResponse] = []
console_web_url = dify_config.CONSOLE_WEB_URL
tenant_id = inviter.current_tenant.id
with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60):
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)
for invitee_email in invitee_emails:
try:
if not inviter.current_tenant:
raise ValueError("No current tenant")
token = RegisterService.invite_new_member(
tenant=inviter.current_tenant,
email=invitee_email,
language=interface_language,
role=invitee_role,
inviter=inviter,
session=db.session(),
@console_account_admission()
def post(self, request_context: RequestContext):
args = MemberInvitePayload.model_validate(console_ns.payload or {})
try:
results = application_services().workspaces.invitations.invite_many(
request_context,
emails=args.emails,
language=args.language,
role=args.role,
)
except InvalidWorkspaceMemberRoleError:
raise InvalidMemberRoleError() from None
except NoPermissionError as error:
raise Forbidden(str(error)) from error
except WorkspaceInvitationQuotaError as error:
if error.seats:
raise SeatsLimitExceeded() from error
raise WorkspaceMembersLimitExceeded() from error
serialized: list[MemberInviteResultResponse] = []
for result in results:
if result.status == "success":
email = parse.quote(result.email)
serialized.append(
MemberInviteSuccessResponse(
status="success",
email=result.email,
url=f"{dify_config.CONSOLE_WEB_URL}/activate?email={email}&token={result.token}",
)
encoded_invitee_email = parse.quote(invitee_email)
invitation_results.append(
MemberInviteSuccessResponse(
status="success",
email=invitee_email,
url=f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
)
)
elif result.status == "already_member":
serialized.append(
MemberInviteAlreadyMemberResponse(
status="already_member", email=result.email, message=result.message or ""
)
except AccountAlreadyInTenantError:
invitation_results.append(
MemberInviteAlreadyMemberResponse(
status="already_member",
email=invitee_email,
message="Account already in workspace.",
)
)
except Exception as e:
invitation_results.append(
MemberInviteFailedResponse(status="failed", email=invitee_email, message=str(e))
)
return (
dump_response(
MemberInviteResponse,
{
"result": "success",
"invitation_results": invitation_results,
"tenant_id": inviter.current_tenant.id if inviter.current_tenant else "",
},
),
HTTPStatus.CREATED,
)
)
else:
serialized.append(
MemberInviteFailedResponse(status="failed", email=result.email, message=result.message or "")
)
return dump_response(
MemberInviteResponse,
{"result": "success", "invitation_results": serialized, "tenant_id": request_context.active_workspace_id},
), HTTPStatus.CREATED
@console_ns.route("/workspaces/current/members/<uuid:member_id>")
class MemberCancelInviteApi(Resource):
"""Cancel an invitation by member id."""
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[MemberActionResponse.__name__])
@with_current_user
def delete(self, current_user: Account, member_id: UUID):
if not current_user.current_tenant:
raise ValueError("No current tenant")
member = db.session.get(Account, str(member_id))
if member is None:
abort(HTTPStatus.NOT_FOUND)
else:
try:
TenantService.remove_member_from_tenant(
current_user.current_tenant, member, current_user, session=db.session()
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
except Exception as e:
raise ValueError(str(e))
@console_account_admission()
def delete(self, request_context: RequestContext, member_id: UUID):
try:
application_services().workspaces.members.remove(
request_context.active_workspace_id, str(member_id), request_context.account_id
)
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
return _member_mutation_error_response(error)
return MemberActionResponse(
result="success",
tenant_id=current_user.current_tenant.id if current_user.current_tenant else "",
tenant_id=request_context.active_workspace_id,
).model_dump(mode="json"), HTTPStatus.OK
@@ -361,40 +243,16 @@ class MemberUpdateRoleApi(Resource):
@console_ns.expect(console_ns.models[MemberRoleUpdatePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
def put(self, current_user: Account, member_id: UUID):
@console_account_admission()
def put(self, request_context: RequestContext, member_id: UUID):
payload = console_ns.payload or {}
args = MemberRoleUpdatePayload.model_validate(payload)
new_role = args.role
if not TenantAccountRole.is_valid_role(new_role):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(new_role):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
member = db.session.get(Account, str(member_id))
if not member:
abort(HTTPStatus.NOT_FOUND)
try:
assert member is not None, "Member not found"
TenantService.update_member_role(
current_user.current_tenant, member, new_role, current_user, session=db.session()
application_services().workspaces.members.update_role(
request_context.active_workspace_id, str(member_id), args.role, request_context.account_id
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
except services.errors.account.RoleAlreadyAssignedError as e:
return {"code": "role-already-assigned", "message": str(e)}, HTTPStatus.BAD_REQUEST
except Exception as e:
raise ValueError(str(e))
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
return _member_mutation_error_response(error)
return SimpleResultResponse(result="success").model_dump(mode="json")
@@ -403,15 +261,12 @@ class MemberUpdateRoleApi(Resource):
class DatasetOperatorMemberListApi(Resource):
"""List all members of current tenant."""
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
@with_current_user
def get(self, current_user: Account):
if not current_user.current_tenant:
raise ValueError("No current tenant")
members = TenantService.get_dataset_operator_members(current_user.current_tenant, session=db.session())
@console_account_admission()
def get(self, request_context: RequestContext):
members = application_services().workspaces.member_queries.list_members(
request_context.active_workspace_id, dataset_operators_only=True
)
return dump_response(AccountWithRoleListResponse, {"accounts": members}), HTTPStatus.OK
@@ -421,37 +276,17 @@ class SendOwnerTransferEmailApi(Resource):
@console_ns.expect(console_ns.models[OwnerTransferEmailPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_allow_transfer_owner
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
args = OwnerTransferEmailPayload.model_validate(payload)
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
raise EmailSendIpLimitError()
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
raise NotOwnerError()
if args.language is not None and args.language == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
email = current_user.email
token = AccountService.send_owner_transfer_email(
account=current_user,
email=email,
language=language,
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
)
@console_account_admission(require_owner_transfer_enabled=True)
def post(self, request_context: RequestContext):
args = OwnerTransferEmailPayload.model_validate(console_ns.payload or {})
try:
token = application_services().workspaces.owner_transfer.send_code(
request_context,
ip_address=extract_remote_ip(request),
language=args.language,
)
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
_raise_owner_transfer_error(error)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@@ -459,107 +294,73 @@ class SendOwnerTransferEmailApi(Resource):
class OwnerTransferCheckApi(Resource):
@console_ns.expect(console_ns.models[OwnerTransferCheckPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_allow_transfer_owner
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
args = OwnerTransferCheckPayload.model_validate(payload)
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
raise NotOwnerError()
user_email = current_user.email
is_owner_transfer_error_rate_limit = AccountService.is_owner_transfer_error_rate_limit(user_email)
if is_owner_transfer_error_rate_limit:
raise OwnerTransferLimitError()
token_data = AccountService.get_owner_transfer_data(args.token)
if token_data is None:
raise InvalidTokenError()
if user_email != token_data.get("email"):
raise InvalidEmailError()
if args.code != token_data.get("code"):
AccountService.add_owner_transfer_error_rate_limit(user_email)
raise EmailCodeError()
# Verified, revoke the first token
AccountService.revoke_owner_transfer_token(args.token)
# Refresh token data by generating a new token
_, new_token = AccountService.generate_owner_transfer_token(user_email, code=args.code, additional_data={})
AccountService.reset_owner_transfer_error_rate_limit(user_email)
return VerificationTokenResponse(is_valid=True, email=user_email, token=new_token).model_dump(mode="json")
@console_account_admission(require_owner_transfer_enabled=True)
def post(self, request_context: RequestContext):
args = OwnerTransferCheckPayload.model_validate(console_ns.payload or {})
try:
email, token = application_services().workspaces.owner_transfer.verify_code(
request_context, token=args.token, code=args.code
)
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
_raise_owner_transfer_error(error)
return VerificationTokenResponse(is_valid=True, email=email, token=token).model_dump(mode="json")
@console_ns.route("/workspaces/current/members/<uuid:member_id>/owner-transfer")
class OwnerTransfer(Resource):
@console_ns.expect(console_ns.models[OwnerTransferPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_allow_transfer_owner
@with_current_user
def post(self, current_user: Account, member_id: UUID):
payload = console_ns.payload or {}
args = OwnerTransferPayload.model_validate(payload)
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
raise NotOwnerError()
if current_user.id == str(member_id):
raise CannotTransferOwnerToSelfError()
transfer_token_data = AccountService.get_owner_transfer_data(args.token)
if not transfer_token_data:
raise InvalidTokenError()
if transfer_token_data.get("email") != current_user.email:
raise InvalidEmailError()
AccountService.revoke_owner_transfer_token(args.token)
member = db.session.get(Account, str(member_id))
if not member:
raise NotFound()
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_member(member, current_user.current_tenant, session=db.session()):
raise MemberNotInTenantError()
@console_account_admission(require_owner_transfer_enabled=True)
def post(self, request_context: RequestContext, member_id: UUID):
args = OwnerTransferPayload.model_validate(console_ns.payload or {})
try:
assert member is not None, "Member not found"
TenantService.update_member_role(
current_user.current_tenant, member, "owner", current_user, session=db.session()
application_services().workspaces.owner_transfer.transfer(
request_context, member_id=str(member_id), token=args.token
)
AccountService.send_new_owner_transfer_notify_email(
account=member,
email=member.email,
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
)
AccountService.send_old_owner_transfer_notify_email(
account=current_user,
email=current_user.email,
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
new_owner_email=member.email,
)
except Exception as e:
raise ValueError(str(e))
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
_raise_owner_transfer_error(error)
return SimpleResultResponse(result="success").model_dump(mode="json")
def _member_mutation_error_response(
error: WorkspaceApplicationError | AccountNotFoundError | NoPermissionError,
) -> tuple[dict[str, str], HTTPStatus]:
if isinstance(error, (AccountNotFoundError, workspace_errors.WorkspaceNotFoundError)):
raise NotFound() from error
if isinstance(error, InvalidWorkspaceMemberRoleError):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
if isinstance(error, workspace_errors.CannotOperateSelfError):
return {"code": "cannot-operate-self", "message": str(error)}, HTTPStatus.BAD_REQUEST
if isinstance(error, NoPermissionError):
return {"code": "forbidden", "message": str(error)}, HTTPStatus.FORBIDDEN
if isinstance(error, workspace_errors.MemberNotInTenantError):
return {"code": "member-not-found", "message": str(error)}, HTTPStatus.NOT_FOUND
if isinstance(error, workspace_errors.RoleAlreadyAssignedError):
return {"code": "role-already-assigned", "message": str(error)}, HTTPStatus.BAD_REQUEST
raise error
def _raise_owner_transfer_error(
error: WorkspaceApplicationError | AccountNotFoundError | NoPermissionError,
) -> NoReturn:
if isinstance(error, NoPermissionError):
raise NotOwnerError() from error
if isinstance(error, (AccountNotFoundError, workspace_errors.WorkspaceNotFoundError)):
raise NotFound() from error
if isinstance(error, workspace_errors.OwnerTransferSendIPLimitedError):
raise EmailSendIpLimitError() from error
if isinstance(error, workspace_errors.OwnerTransferSendRateLimitError):
raise OwnerTransferRateLimitExceededError(error.retry_after_minutes) from error
if isinstance(error, workspace_errors.OwnerTransferVerificationLimitError):
raise OwnerTransferLimitError() from error
if isinstance(error, workspace_errors.InvalidOwnerTransferTokenError):
raise InvalidTokenError() from error
if isinstance(error, workspace_errors.InvalidOwnerTransferEmailError):
raise InvalidEmailError() from error
if isinstance(error, workspace_errors.InvalidOwnerTransferCodeError):
raise EmailCodeError() from error
if isinstance(error, workspace_errors.CannotOperateSelfError):
raise CannotTransferOwnerToSelfError() from error
if isinstance(error, workspace_errors.MemberNotInTenantError):
raise MemberNotInTenantError() from error
raise error

View File

@@ -4,12 +4,10 @@ from typing import Any, Literal
from flask import send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from controllers.common.fields import SimpleResultResponse, ValidationResultResponse
from controllers.common.rbac import RBACCheck, Workspace
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
@@ -21,6 +19,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
@@ -33,8 +32,7 @@ from services.entities.model_provider_entities import (
ModelProviderSummaryResponse,
ProviderResponse,
)
from services.model_provider_service import ModelProviderService
from services.workspace_service import WorkspaceService
from services.model_provider.service import ModelProviderService
class ParserModelList(BaseModel):
@@ -191,9 +189,8 @@ class ModelProviderCreditsApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str):
credit_pool = WorkspaceService.get_effective_credit_pool(tenant_id, session=session)
def get(self, tenant_id: str):
credit_pool = application_services().workspaces.management.get_effective_credit_pool(tenant_id)
return dump_response(ModelProviderCreditsResponse, credit_pool)

View File

@@ -25,6 +25,7 @@ from controllers.console.wraps import (
)
from core.entities.model_entities import DefaultModelSetting
from core.entities.provider_entities import CredentialConfiguration
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule
@@ -38,7 +39,7 @@ from services.entities.model_provider_entities import (
ProviderWithModelsResponse,
)
from services.model_load_balancing_service import ModelLoadBalancingService
from services.model_provider_service import ModelProviderService
from services.model_provider.service import ModelProviderService
logger = logging.getLogger(__name__)
@@ -376,6 +377,7 @@ class ModelProviderModelCredentialApi(Resource):
tenant_id=tenant_id,
provider=provider,
user=user,
credential_query=application_services().credential_queries,
)
else:
available_credentials = model_provider_service.get_provider_model_available_credentials(

View File

@@ -62,6 +62,7 @@ from core.tools.entities.tool_entities import (
WorkflowToolParameterConfiguration,
)
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import alphanumeric, dump_response, uuid_value
@@ -664,9 +665,9 @@ class ToolBuiltinProviderGetCredentialsApi(Resource):
BuiltinToolManageService.get_builtin_tool_provider_credentials(
tenant_id=tenant_id,
provider_name=provider,
session=db.session(),
user=user,
include_credential_ids=query.include_credential_ids or None,
credential_query=application_services().credential_queries,
),
)
@@ -1363,9 +1364,9 @@ class ToolBuiltinProviderGetCredentialInfoApi(Resource):
BuiltinToolManageService.get_builtin_tool_provider_credential_info(
tenant_id=tenant_id,
provider=provider,
session=db.session(),
user=user,
include_credential_ids=query.include_credential_ids or None,
credential_query=application_services().credential_queries,
),
)

View File

@@ -22,6 +22,7 @@ from core.trigger.entities.api_entities import (
)
from core.trigger.entities.entities import RequestLog, SubscriptionBuilderUpdater
from core.trigger.trigger_manager import TriggerManager
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response
@@ -213,6 +214,7 @@ class TriggerSubscriptionListApi(Resource):
tenant_id=tenant_id,
provider_id=TriggerProviderID(provider),
user=user,
credential_query=application_services().credential_queries,
),
)
except ValueError as e:

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