Compare commits

..
Author SHA1 Message Date
Jingyi-Dify 6a08d8dd6f fix(workflow): polish start selector states 2026-06-08 20:08:03 -07:00
Jingyi-Dify 7b030ff1bc fix(workflow): use scroll area for trigger actions 2026-06-08 19:38:14 -07:00
Jingyi-Dify 60d779152c feat(workflow): align start selector design 2026-06-08 19:13:11 -07:00
Jingyi-Dify ff97259011 feat(workflow): add start placeholder panel 2026-06-08 19:10:09 -07:00
Jingyi-Dify 4163b4dd81 feat(workflow): restore start placeholder drafts 2026-06-08 19:08:33 -07:00
Jingyi-Dify 02c9b6e5ce feat(workflow): add start node placeholder panel
Add a workflow-only start placeholder node that opens a right-panel start selector for the initial workflow entry choice.

Keep the placeholder frontend-local by filtering it from draft sync payloads, while preserving existing start templates for other app modes.
2026-06-08 14:19:39 -07:00
Joel 83db975374 feat: app star contract 2026-06-08 18:32:33 +08:00
Joel 18efc8ed88 chore: menu pos and menu action 2026-06-08 18:08:19 +08:00
Joel 46e1a5a6db chore: publish pipeline template copywriting and icon 2026-06-08 18:02:00 +08:00
Joel a2267276ae chore: knowledge toggle icon 2026-06-08 18:01:59 +08:00
yyh 38af34a742 fix: keep global nav account expanded 2026-06-08 17:44:02 +08:00
Joel a6084ca3c7 chore: chunking Setting move place 2026-06-08 17:27:42 +08:00
Joel d8b847dcf0 chore: i18n and split in menu 2026-06-08 17:15:03 +08:00
yyh 2509682e07 fix: align secondary sidebar help icon 2026-06-08 17:10:26 +08:00
Joel 5582b35d56 chroe: copywriting 2026-06-08 17:04:28 +08:00
Joel db83df9f9c chore: knowledge icon 2026-06-08 17:04:28 +08:00
Joel 0683c0e7a7 chore: sidbar hover toggle and no data icon 2026-06-08 17:04:28 +08:00
hjlarry 42b7bf8152 feat: gen app starred openAPI 2026-06-08 16:53:35 +08:00
hjlarry bc33ef1b97 feat: app star and its list 2026-06-08 16:47:26 +08:00
Joel 0f493a52a1 fix: annotation page highlight 2026-06-08 16:35:17 +08:00
yyh fc4f9db79a fix: creators filter styles 2026-06-08 16:31:10 +08:00
yyh e9b8a1606e fix: remove unnecessary title prop 2026-06-08 16:28:31 +08:00
Joel 453c4c4c5f chore: update annotations 2026-06-08 16:20:44 +08:00
Joel 58e25d0534 chore: toogle icon 2026-06-08 16:20:44 +08:00
yyh d7b9f2a86b Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-08 16:08:22 +08:00
Joel 18d2423ed1 feat: apps support sort by 2026-06-08 15:55:57 +08:00
yyh 4176e7d146 fix: font style 2026-06-08 15:43:01 +08:00
Joel 3556611c2b chore: header filter 2026-06-08 15:36:15 +08:00
yyh e37b4af0e8 feat(web): autofocus main nav search inputs 2026-06-08 15:34:43 +08:00
yyh a0eb2ba0ff feat(web): support search input autofocus 2026-06-08 15:34:33 +08:00
yyh 75f1094459 fix(web): use native button for explore app cards 2026-06-08 15:14:33 +08:00
yyh f61f9634f8 fix(web): use radio semantics for explore categories 2026-06-08 15:07:21 +08:00
yyh 11f78289d3 fix: highlight integration popover triggers 2026-06-08 14:50:18 +08:00
yyh 062341ab26 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite
# Conflicts:
#	eslint-suppressions.json
#	web/i18n/ar-TN/plugin.json
#	web/i18n/de-DE/plugin.json
#	web/i18n/es-ES/plugin.json
#	web/i18n/es-ES/workflow.json
#	web/i18n/fa-IR/plugin.json
#	web/i18n/fa-IR/workflow.json
#	web/i18n/fr-FR/plugin.json
#	web/i18n/fr-FR/workflow.json
#	web/i18n/hi-IN/plugin.json
#	web/i18n/hi-IN/workflow.json
#	web/i18n/id-ID/plugin.json
#	web/i18n/id-ID/workflow.json
#	web/i18n/it-IT/plugin.json
#	web/i18n/it-IT/workflow.json
#	web/i18n/ja-JP/plugin.json
#	web/i18n/ja-JP/workflow.json
#	web/i18n/ko-KR/plugin.json
#	web/i18n/ko-KR/workflow.json
#	web/i18n/nl-NL/app.json
#	web/i18n/nl-NL/plugin.json
#	web/i18n/nl-NL/workflow.json
#	web/i18n/pl-PL/plugin.json
#	web/i18n/pl-PL/workflow.json
#	web/i18n/pt-BR/plugin.json
#	web/i18n/ro-RO/plugin.json
#	web/i18n/ro-RO/workflow.json
#	web/i18n/ru-RU/plugin.json
#	web/i18n/ru-RU/workflow.json
#	web/i18n/sl-SI/plugin.json
#	web/i18n/sl-SI/workflow.json
#	web/i18n/th-TH/plugin.json
#	web/i18n/th-TH/workflow.json
#	web/i18n/tr-TR/plugin.json
#	web/i18n/tr-TR/workflow.json
#	web/i18n/uk-UA/plugin.json
#	web/i18n/uk-UA/workflow.json
#	web/i18n/vi-VN/plugin.json
#	web/i18n/vi-VN/workflow.json
#	web/i18n/zh-Hant/plugin.json
#	web/i18n/zh-Hant/workflow.json
2026-06-08 14:37:00 +08:00
yyh 6ea9ba5926 fix: question icon style 2026-06-08 14:20:29 +08:00
yyh 7a9054fdea fix: workspace filter style 2026-06-08 13:30:00 +08:00
yyh 508baa782c Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-08 13:14:55 +08:00
yyh f5ee121d2c fix: workspace card components 2026-06-08 13:02:26 +08:00
yyh 2bf60a67ad fix: style 2026-06-08 12:36:17 +08:00
hjlarry f6433097ad feat: app list add sort param 2026-06-08 11:26:18 +08:00
yyh 23b1038b99 fix: migrations 2026-06-08 11:21:56 +08:00
yyh ed10b82bb1 fix: export 2026-06-08 10:40:36 +08:00
yyh 41106ef6c9 fix: lint and type-check 2026-06-08 10:34:16 +08:00
yyh 5ee7bedb56 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite
# Conflicts:
#	.agents/skills/frontend-code-review/SKILL.md
#	.agents/skills/frontend-code-review/references/business-logic.md
#	.agents/skills/frontend-code-review/references/code-quality.md
#	.agents/skills/frontend-code-review/references/performance.md
#	api/controllers/console/explore/recommended_app.py
#	eslint-suppressions.json
#	packages/contracts/generated/api/console/workspaces/orpc.gen.ts
#	packages/iconify-collections/custom-vender/info.json
#	web/__tests__/apps/app-list-browsing-flow.test.tsx
#	web/app/(commonLayout)/role-route-guard.tsx
#	web/app/components/app-sidebar/nav-link/index.tsx
#	web/app/components/apps/__tests__/empty.spec.tsx
#	web/app/components/apps/__tests__/list.spec.tsx
#	web/app/components/apps/empty.tsx
#	web/app/components/apps/list.tsx
#	web/app/components/base/search-input/__tests__/index.spec.tsx
#	web/app/components/base/search-input/index.tsx
#	web/app/components/header/account-setting/index.tsx
#	web/app/components/header/header-wrapper.tsx
#	web/app/components/plugins/marketplace/search-box/index.tsx
#	web/app/components/tools/mcp/modal.tsx
#	web/features/tag-management/components/tag-filter.tsx
#	web/i18n/en-US/workflow.json
#	web/i18n/zh-Hans/workflow.json
2026-06-08 10:32:32 +08:00
Jingyi-Dify f0bcb77d55 fix(web): update preferences active icon 2026-06-07 00:12:29 -07:00
Jingyi-Dify 44a89bf870 fix(web): align main nav glass tokens 2026-06-07 00:11:58 -07:00
Jingyi-Dify 60ad023553 Revert "chore(web): remove explicit plugin type anys"
This reverts commit 9c6a7679ac.
2026-06-06 22:29:41 -07:00
Jingyi-Dify 9c6a7679ac chore(web): remove explicit plugin type anys 2026-06-06 22:06:10 -07:00
Jingyi-Dify 11b2ba29c1 fix(web): preserve marketplace install dialog during loading 2026-06-06 21:54:42 -07:00
Jingyi-Dify c52df73117 fix(web): align integration loading skeletons with loaded cards 2026-06-06 21:54:42 -07:00
Jingyi-Dify 9b8d81c852 fix(web): keep plugin install tasks visible while pending 2026-06-06 21:54:42 -07:00
Joel b28b2892f1 chroe: remove app create card 2026-06-06 10:42:13 +08:00
Joel 7f5349e707 chore: type selctc 2026-06-06 10:33:14 +08:00
Joel 0aaa1df1b8 chroe: tags and types copywriting 2026-06-06 10:19:55 +08:00
Joel bd41c5c3c0 chore: tags filter and pipeline conrner 2026-06-06 10:04:22 +08:00
Joel 95a2eea611 chore: knowledge align and remove create 2026-06-06 09:38:08 +08:00
yyh 19193891cf fix: remove redundant main nav link titles 2026-06-05 12:23:50 +08:00
Joel 4746dd39d3 chore: only cloud can show upgrade 2026-06-05 11:36:18 +08:00
Jingyi-Dify a5e6f58285 fix(web): resolve lint issues 2026-06-04 20:19:05 -07:00
Jingyi-Dify 4bf0398873 fix(web): use workspace last opened time 2026-06-04 20:19:05 -07:00
fatelei e6db98ef64 feat: add task detail 2026-06-05 11:13:54 +08:00
Jingyi-Dify 6b694ce829 fix(web): polish tool provider details 2026-06-04 19:58:45 -07:00
Jingyi-Dify 0ab3b5d677 feat(web): improve plugins panel filtering 2026-06-04 19:58:45 -07:00
Jingyi-Dify 346fabda27 fix(web): redirect legacy integration routes 2026-06-04 19:58:45 -07:00
Jingyi-Dify 3da0f12815 fix(web): refine plugin task popover 2026-06-04 19:58:45 -07:00
Jingyi-Dify 303cff1353 feat(web): improve data source plugin actions 2026-06-04 19:58:45 -07:00
Jingyi-Dify 10c3849887 fix(web): improve model selector empty state 2026-06-04 19:58:45 -07:00
Jingyi-Dify 29295950ea refactor(web): update main nav account settings 2026-06-04 19:58:44 -07:00
yyh c61a7ce442 fix: use next link for navigate button 2026-06-05 10:44:21 +08:00
yyh ab383969a8 style: dropdown menu pd 2026-06-05 10:16:07 +08:00
yyh 8a0aab4d81 style: remove p-1 in dropdown popup 2026-06-05 10:10:47 +08:00
hjlarry d389284813 feat: add last_opened_at for workspace API 2026-06-05 10:08:13 +08:00
yyh 530e366440 fix: use link for workspace card credits 2026-06-05 09:55:46 +08:00
yyh e4510a7d8f Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-05 09:35:05 +08:00
yyh 19d7a9b5d9 fix: learn dify item use dropdown menu checkbox item and no switch 2026-06-04 20:42:33 +08:00
yyh 1d063e3fd6 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-04 20:38:39 +08:00
yyh 850c7e311f fix(web): align focus rings in nav surfaces 2026-06-04 20:33:19 +08:00
Joel de0ccbb960 fix: question mark ui 2026-06-04 18:31:06 +08:00
Joel 407d3e28bb fix: focus template focus ring not show the full 2026-06-04 17:24:28 +08:00
Joel 8a51b3a296 chroe: remove mock logic 2026-06-04 17:02:47 +08:00
Joel 41894ad182 chore: uis 2026-06-04 16:46:41 +08:00
Joel 95ea709c91 chroe: knowledge info more info 2026-06-04 16:46:41 +08:00
Joel 3a01b91a45 chore: sidebar ui 2026-06-04 16:46:41 +08:00
Joel f50abac3f9 chore: knowledge info 2026-06-04 16:46:41 +08:00
Joel 7f44b2f601 chore: knowledge sidebar ui 2026-06-04 16:46:41 +08:00
yyh c71d2ac460 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-04 16:43:22 +08:00
yyh 1a7542052e style: create from template modal 2026-06-04 16:06:46 +08:00
Joel 6d26f6ea73 chore: handle tempalte and learn dify click to new 2026-06-04 14:45:02 +08:00
Joel b0d3347de9 chore: padding 2026-06-04 14:12:00 +08:00
Joel bf2a35bff8 chore: slide ui 2026-06-04 13:56:35 +08:00
yyh 63d50a61d3 style: add pointer cursor to app filter checkbox 2026-06-04 13:14:01 +08:00
yyh 101a6fcc53 fix(web): update account header home action 2026-06-04 12:56:10 +08:00
Jingyi-Dify d407c1fbf7 chore(integrations): update endpoint copy and header tests 2026-06-03 21:19:26 -07:00
Jingyi-Dify fe6ee8aa04 style(plugins): align empty states and remove actions 2026-06-03 21:19:25 -07:00
Jingyi-Dify 25228e3cde fix(integrations): align tool create actions 2026-06-03 21:19:25 -07:00
Jingyi-Dify 8049da9331 fix(integrations): align custom endpoint toolbar with design 2026-06-03 21:19:25 -07:00
Jingyi-Dify e404195c8c fix(integrations): align navigation and marketplace layout 2026-06-03 21:19:25 -07:00
Jingyi-Dify b3c7110768 fix(plugins): show correct badges and endpoint metadata 2026-06-03 21:19:25 -07:00
Jingyi-Dify 7b2b21c348 feat(integrations): use plugin category APIs for integration pages 2026-06-03 21:19:24 -07:00
Jingyi-Dify 3d3a1f4f90 style(integrations): refine provider cards and tag filter 2026-06-03 21:19:24 -07:00
yyh 274944f05e Merge branch 'feat/ui-onboarding-rewrite' of https://github.com/langgenius/dify into feat/ui-onboarding-rewrite 2026-06-04 11:57:05 +08:00
yyh a0d53b9f07 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite
# Conflicts:
#	eslint-suppressions.json
#	web/app/components/header/account-dropdown/index.tsx
2026-06-04 11:56:43 +08:00
hjlarry a24d1a0bbc feat: add PluginCategory openAPI 2026-06-04 11:52:35 +08:00
Joel fffa89a10e chore: handle tag icon and jump action 2026-06-03 18:12:38 +08:00
Joel 422461f360 chore: handle card ui 2026-06-03 17:56:47 +08:00
Joel 2ce913aaac fix: slide scroll show other slide promble 2026-06-03 17:40:23 +08:00
hjlarry 30f0a69fea feat: add builtin tools 2026-06-03 17:29:41 +08:00
hjlarry 04e8c6127f Revert "feat: PluginInstallTaskStartResponse add optional PluginInstallTask"
This reverts commit ccc9122980.
2026-06-03 17:21:07 +08:00
Joel 20e37a0457 chore: add become a partnter link 2026-06-03 17:08:00 +08:00
Joel 36c7209301 chroe: description vertical padding 2026-06-03 16:54:24 +08:00
hjlarry ccc9122980 feat: PluginInstallTaskStartResponse add optional PluginInstallTask 2026-06-03 16:51:22 +08:00
Joel 9143d44ec6 chore: new all plugin icon and request comp 2026-06-03 16:45:42 +08:00
hjlarry 1f856960f0 feat: PluginList by category 2026-06-03 16:35:33 +08:00
Joel a8218d5809 chore: market header align 2026-06-03 16:25:24 +08:00
Joel 70ec17bc34 feat: support scroll to show more plugins in group 2026-06-03 16:15:19 +08:00
Joel e4ea9d2e07 feat: marketplace page change; 2026-06-03 15:50:34 +08:00
yyh b72bfe060d fix: learn dify no skeleton 2026-06-03 14:33:31 +08:00
yyh dfc7f136ef Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-03 14:27:46 +08:00
yyh 15d66814ee fix: consolidate explore home initial loading 2026-06-03 14:02:12 +08:00
yyh 5f109213a5 fix: eslint 2026-06-03 13:05:19 +08:00
yyh a53828e826 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite
# Conflicts:
#	eslint-suppressions.json
#	web/app/components/app/create-app-dialog/app-list/index.tsx
#	web/app/components/app/create-app-modal/index.tsx
#	web/app/components/app/create-from-dsl-modal/index.tsx
#	web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx
#	web/hooks/use-import-dsl.ts
2026-06-03 13:04:38 +08:00
Jingyi-Dify 3a2ea826ff fix(plugins): refresh model defaults after plugin changes 2026-06-02 15:54:52 -07:00
Jingyi-Dify 4581ce2f45 style(model-provider): align model selectors 2026-06-02 15:54:15 -07:00
Jingyi-Dify 3925b2bf4f fix(model-provider): handle unavailable credentials 2026-06-02 15:53:45 -07:00
Jingyi-Dify 5744148e6d style(model-provider): align quota panel 2026-06-02 15:53:18 -07:00
Jingyi-Dify 6ba42e6d73 refactor(model-provider): split provider page body 2026-06-02 15:52:42 -07:00
Jingyi-Dify aff22cb5ed refactor integrations header layout 2026-06-02 11:19:28 -07:00
Jingyi-Dify 58c4a174ba align model provider toolbar copy 2026-06-02 11:19:27 -07:00
Jingyi-Dify c98f65cbcb refactor integrations tool provider cards 2026-06-02 11:19:27 -07:00
Joel add7c75f18 chore: stadio no app tiny 2026-06-02 17:53:12 +08:00
Joel 75e74ee8b9 chore: no knowledge some tiny 2026-06-02 17:51:10 +08:00
Joel 40a5236553 fix: learn dify no ssr to fix not same to server side if not on 2026-06-02 17:38:09 +08:00
Joel f820813e9f chore: knowledge empty page 2026-06-02 17:25:15 +08:00
Joel c4c3a2b265 chore: no apps align 2026-06-02 17:02:00 +08:00
yyh 99be8b34c8 fix: reverts agents.md to main 2026-06-02 16:56:19 +08:00
Joel 777265d898 chore: no apps page 2026-06-02 16:47:58 +08:00
Joel 7024913866 chore: siderbar tiny ui 2026-06-02 16:47:58 +08:00
yyh 3c862c3e98 fix: style 2026-06-02 16:46:04 +08:00
yyh fb497c60dd refactor(web): connect goto anything triggers to atom state 2026-06-02 16:41:41 +08:00
yyh 075af9cd44 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite
# Conflicts:
#	web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts
#	web/app/components/goto-anything/hooks/use-goto-anything-modal.ts
2026-06-02 16:35:10 +08:00
yyh 18e07cac9a fix(ui): use css selector 2026-06-02 16:01:58 +08:00
yyh ba06ed5f41 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-02 15:48:10 +08:00
Joel 3fd9d5eb14 chore: install border and nav top 2026-06-02 15:38:13 +08:00
yyh 15d2714e9d fix lint 2026-06-02 15:34:19 +08:00
yyh f36852646f Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-02 15:34:02 +08:00
Joel 72c3cd0d67 chore: installed app 2026-06-02 15:24:32 +08:00
Joel fd0cb47a81 chore: spacing in home page 2026-06-02 14:52:37 +08:00
Joel 87382673f3 chore: templates ui 2026-06-02 14:47:38 +08:00
Joel 3c716a6eee chore: learn dify sketlon 2026-06-02 14:32:41 +08:00
Joel 8da34fb60b chore: update banner and learn dify ui 2026-06-02 14:24:18 +08:00
Joel f0efb73fd0 chroe: recents ui and home padding x 2026-06-02 13:51:17 +08:00
Joel e2bd2355e9 chroe: app sidebar and continue work 2026-06-02 13:46:54 +08:00
Joel 73b50f5ede chore: add splits in nav 2026-06-02 13:36:46 +08:00
Joel 52cfe62d8d fix: home page auto jump to /apps 2026-06-02 11:24:10 +08:00
Joel f293253a7a feat: add logs and annotions title 2026-06-02 11:11:59 +08:00
Joel 1ac3ad4c81 chore: split logs and annotations page 2026-06-02 11:01:38 +08:00
Jingyi-Dify 2d6fd70733 feat: align integrations auto-update settings dialog 2026-06-01 16:00:52 -07:00
Jingyi-Dify 639c8d5967 chore: update frontend review component guidance 2026-06-01 16:00:15 -07:00
Jingyi-Dify 74c9b7fddd fix(web): align integrations sidebar actions 2026-06-01 13:07:31 -07:00
yyh be997384f3 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-01 19:40:06 +08:00
yyh acbea6701c Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-01 18:33:10 +08:00
yyh 86ac4dadd6 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-01 16:29:36 +08:00
yyh f3e11ec0ee Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-01 16:21:24 +08:00
yyh c9c4fca7a7 chore: merge feat/local-storage-react-hook 2026-06-01 16:05:14 +08:00
yyh d23eddc924 tweaks 2026-06-01 15:49:12 +08:00
yyh d65c7229f2 refactor: replace useEffect with useIsomorphicLayoutEffect for better SSR compatibility 2026-06-01 15:39:10 +08:00
yyh 66508326f9 test: update workflow store persistence expectations 2026-06-01 15:30:39 +08:00
yyh 310f49229e Merge remote-tracking branch 'origin/main' into feat/local-storage-react-hook 2026-06-01 15:28:05 +08:00
yyh f400be4280 fix knip 2026-06-01 15:28:02 +08:00
yyh 2855ab3a15 refactor(web): migrate local storage util to hook 2026-06-01 15:23:18 +08:00
yyh e2fd5421d2 prune 2026-06-01 15:21:45 +08:00
yyh ff37ba83b4 refactor(web): bridge workflow storage through react 2026-06-01 15:14:45 +08:00
yyh 7b97ec57ef feat(web): add use local storage hook 2026-06-01 14:45:39 +08:00
yyh 19d34b5a93 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-01 13:10:27 +08:00
yyh 5c315ea7fe Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite
# Conflicts:
#	web/app/components/tools/mcp/index.tsx
2026-06-01 12:29:40 +08:00
Jingyi-Dify e525880773 feat(integrations): reorganize sidebar navigation 2026-05-31 20:06:06 -07:00
Jingyi-Dify d82e30561f feat(plugin): refine install task status controls 2026-05-31 20:06:06 -07:00
yyh d2508db11f Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-31 22:17:12 +08:00
yyh 7f76fe68ea fix: style 2026-05-31 22:14:14 +08:00
yyh ac09702d08 Merge branch 'codex/server-prefetch-current-workspace' into feat/ui-onboarding-rewrite 2026-05-31 22:00:25 +08:00
yyh b14d1e60ec fix: save local storage 2026-05-31 21:38:54 +08:00
yyh b072136ca0 fix: add console.error for error bounadries 2026-05-31 21:38:23 +08:00
yyh c8b6ec5fb0 fix: prefetch workspace info in server 2026-05-31 21:38:10 +08:00
yyh 0b5b4271f0 fix: add console.error() in common layout error boundary 2026-05-30 23:22:33 +08:00
yyh fd60339625 fix: safe local storage 2026-05-30 23:16:48 +08:00
yyh f56f93f5c2 fix(web): defer try app preview loading 2026-05-30 23:14:26 +08:00
yyh 2921d27929 fix: use query for workspace info 2026-05-30 21:04:57 +08:00
yyh 98e3bff509 fix: skeleton 2026-05-30 20:52:23 +08:00
yyh 6c43fa459e fix(auth): reset profile query after login 2026-05-30 20:30:32 +08:00
yyh d2eae74b5e fix(web): make full screen loading fill viewport 2026-05-30 19:50:43 +08:00
yyh 27bdee85fe fix(auth): avoid leaking request origin in refresh redirects 2026-05-30 13:59:53 +08:00
Jingyi-Dify cca8295ad8 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-29 11:27:07 -07:00
yyh 4d1b3605b5 style: use kbd and align style 2026-05-29 22:16:51 +08:00
yyh d94be05f68 fix(web): use default profile query cache 2026-05-29 22:00:00 +08:00
yyh fea7590779 fix: remove cursor pointer 2026-05-29 21:15:52 +08:00
yyh 71ba903d4c fix: banner and template card skeleton style 2026-05-29 21:15:03 +08:00
yyh 44831839d1 fix(web): center common layout error in content area 2026-05-29 21:12:49 +08:00
yyh 88c3512471 fix: add webapp header skeleton 2026-05-29 21:06:02 +08:00
yyh 67dee6e07e fix: env badge position 2026-05-29 21:02:37 +08:00
yyh 95d7fa997b fix: hide webapp section when length===0 2026-05-29 20:59:59 +08:00
yyh 4a0b177eee Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite
# Conflicts:
#	web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx
#	web/app/(commonLayout)/layout.tsx
#	web/app/components/full-screen-loading.tsx
#	web/app/components/header/header-wrapper.tsx
2026-05-29 20:39:10 +08:00
yyh 15b2a8fdb7 Merge remote-tracking branch 'origin/refactor/suspense-boundary' into feat/ui-onboarding-rewrite 2026-05-29 20:23:54 +08:00
yyh 65098a6b4f fix: e2e 2026-05-29 20:18:16 +08:00
yyh 8055f8840c fix: remove global loading 2026-05-29 19:39:03 +08:00
yyh f033f91a68 Merge remote-tracking branch 'origin/main' into refactor/suspense-boundary 2026-05-29 18:54:09 +08:00
yyh 0b98319bd3 fix: redirect profile 401 to refresh route 2026-05-29 18:53:56 +08:00
yyh 386de25e26 add notes 2026-05-29 18:52:36 +08:00
yyh d30805353a update lock 2026-05-29 17:09:39 +08:00
yyh 21c5825508 fix(web): declare server-only dependency 2026-05-29 17:05:38 +08:00
yyh 2d324add39 fix(web): guard server profile prefetch URL 2026-05-29 16:50:29 +08:00
yyh 62beaf493e fix: knip 2026-05-29 16:46:23 +08:00
yyhandGitHub b34e5aa915 Merge branch 'main' into refactor/suspense-boundary 2026-05-29 16:41:06 +08:00
yyh 2ea19c2b1c test: fix oauth registration analytics spec 2026-05-29 16:29:47 +08:00
yyh 5712f29e8b Merge remote-tracking branch 'origin/main' into refactor/suspense-boundary 2026-05-29 16:19:58 +08:00
yyh 93f7404c6b refactor: hydrate common layout profile query 2026-05-29 16:19:26 +08:00
yyh 4f631d6f4c fix 2026-05-29 16:06:52 +08:00
yyh 2b3e15cc83 Remove app initializer and move auth boot logic to route boundaries 2026-05-29 15:12:02 +08:00
yyh 35956247ab Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-29 11:51:24 +08:00
yyh 343531b9dc refactor(web): migrate learn dify visibility state to jotai 2026-05-29 10:50:49 +08:00
yyh 236f389fce Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-29 10:24:10 +08:00
Jingyi-Dify 5a2604265c fix: align integrations search empty states 2026-05-28 12:43:58 -07:00
Jingyi-Dify 6a8aaa5a36 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-28 11:20:10 -07:00
yyh 0ad1e8c2d9 fix: improve Dify logo accessibility 2026-05-28 16:48:22 +08:00
yyh bf345136eb fix: style 2026-05-28 16:40:34 +08:00
yyh c9ed50c3ae fix 2026-05-28 16:36:32 +08:00
yyh c78c603a38 fix: remove workflow fullscreen 2026-05-28 16:02:18 +08:00
yyh 48b38446a3 fix: improve frontend accessibility selectors 2026-05-28 15:30:37 +08:00
yyh 8a8bec4bc6 refactor: align integrations layout 2026-05-28 15:11:27 +08:00
yyh 95936a8bac refactor(web): remove workspace context 2026-05-28 12:48:16 +08:00
yyh ac8a1107ca fix: stacking context 2026-05-28 11:20:26 +08:00
Jingyi-Dify 0c96426d91 fix: link app sidebar studio breadcrumb 2026-05-27 18:47:52 -07:00
Jingyi-Dify 67fee14770 feat: migrate installed app route 2026-05-27 17:38:33 -07:00
Jingyi-Dify d94006162d refactor: split large onboarding UI components 2026-05-27 17:04:42 -07:00
Jingyi-Dify 3d53cee8a9 fix: render home loading skeletons 2026-05-27 16:46:51 -07:00
Jingyi-Dify 1acd1b568a fix: add integration loading placeholders 2026-05-27 16:46:23 -07:00
Jingyi-Dify 68f939f3b3 fix(web): narrow home active state 2026-05-27 14:52:25 -07:00
Jingyi-Dify 1f4b76ba7e fix(web): scope common layout loading and marketplace title 2026-05-27 14:51:57 -07:00
Jingyi-Dify 4d974d8f72 fix(web): use scroll areas for integrations lists 2026-05-27 14:51:31 -07:00
Jingyi-Dify 1dc12d1661 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-27 12:08:53 -07:00
yyh 82345977cd fix: separate main nav web app groups 2026-05-27 20:59:59 +08:00
yyh 83c943bc21 fix: virtualize main nav web apps 2026-05-27 20:35:12 +08:00
yyh 94a376a5a7 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-27 20:11:47 +08:00
yyh 33d95ab23a Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-27 16:40:30 +08:00
yyh 7a8a92082b refactor: use segmented control 2026-05-27 16:40:20 +08:00
yyh ef00f850e4 fix: use chip for app type selector 2026-05-27 16:18:49 +08:00
hjlarry 7815228395 Merge branch 'feat/ui-onboarding-rewrite' of github.com:langgenius/dify into feat/ui-onboarding-rewrite 2026-05-27 14:45:28 +08:00
hjlarry dcd40b5004 fix install_app n+1 query 2026-05-27 14:44:00 +08:00
yyh c252006644 fix: align help menu support item 2026-05-27 14:20:34 +08:00
yyh 9e5668c233 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-27 13:31:01 +08:00
yyh 52ce49b3c6 fix(workspace-card): fix style and add skeleton 2026-05-27 13:18:03 +08:00
yyh e90aa76ba2 fix: use dropdown menu 2026-05-27 13:02:43 +08:00
yyh de9373e1b8 chore(contracts): type plugin auto-upgrade responses 2026-05-27 11:33:56 +08:00
yyh 58923f38e6 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-27 11:03:21 +08:00
yyh 8486a5b213 feat(api): add plugin auto-upgrade category contracts 2026-05-27 11:03:04 +08:00
yyh 28a8be0d5f feat(api): add learn dify app list contract 2026-05-27 11:02:45 +08:00
yyh f2d4d5b267 fix lint 2026-05-27 10:34:36 +08:00
yyh f62a59a18a fix: resolve app and dataset lint errors 2026-05-27 10:33:51 +08:00
yyh b488812714 lint 2026-05-27 10:28:42 +08:00
Jingyi-Dify 755760b97c refactor(web): migrate mcp modal controls to dify ui primitives 2026-05-26 19:06:52 -07:00
Jingyi-Dify 955c3fb797 refactor(web): migrate search inputs to dify ui primitive 2026-05-26 19:06:52 -07:00
yyh 0c9aa20047 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-27 10:03:36 +08:00
Jingyi-Dify 065246a9a7 Merge origin/main into feat/ui-onboarding-rewrite 2026-05-26 17:50:55 -07:00
Jingyi-Dify 0d12b5ab1c test(web): cover integrations permission-gated actions 2026-05-21 16:44:33 -07:00
Jingyi-Dify 514dcae189 style(web): align update setting time picker token 2026-05-21 15:49:39 -07:00
Jingyi-Dify 228dd84a91 refactor(web): tighten tools provider card and marketplace handling 2026-05-21 15:49:27 -07:00
Jingyi-Dify 336ddad096 fix(web): expose current nav links to assistive tech 2026-05-21 15:49:13 -07:00
Jingyi-Dify 92bb9a17b7 fix(web): scope main nav fullscreen canvas routes 2026-05-21 15:49:02 -07:00
Jingyi-Dify b8868dab90 fix(integrations): update install success action copy 2026-05-21 10:54:43 -07:00
Jingyi-Dify 94225682cd docs: update main nav and integrations follow-ups 2026-05-20 17:11:16 -07:00
Jingyi-Dify 18b6568c2a fix: refine integrations sidebar controls 2026-05-20 16:21:25 -07:00
Jingyi-Dify a3a9ded29b chore: localize main nav and integrations copy 2026-05-20 15:50:52 -07:00
Jingyi-Dify de78a26920 fix: scope dataset detail navigation routes 2026-05-20 15:50:20 -07:00
Jingyi-Dify c54d029e7c fix: restore dataset list markup 2026-05-20 15:49:49 -07:00
Jingyi-Dify ad4b9dc2c3 refactor: reuse toggle group in update settings 2026-05-20 12:42:53 -07:00
Joel cdec0c69a6 chore: learn dify try action same to template 2026-05-20 18:04:43 +08:00
Joel 53acc3726c merge 2026-05-20 17:43:06 +08:00
Joel b1d393f4d9 chore: hide select model provider in model provider page 2026-05-20 15:22:14 +08:00
Joel 62e9bdd70d chore: app permission show in app card 2026-05-20 15:00:18 +08:00
Joel d36c76c20e merge 2026-05-20 14:34:04 +08:00
Jingyi-Dify f525e1a5eb fix(web): align onboarding and integrations i18n copy 2026-05-19 16:03:01 -07:00
Joel e2f779b20d chore: load 8 contiue items 2026-05-19 14:54:23 +08:00
Joel e198d6305c merge 2026-05-19 14:14:51 +08:00
Joel 5e67514265 chore: support edcation action 2026-05-19 13:56:49 +08:00
Joel b63896de87 feat: learn dify use api 2026-05-19 13:44:14 +08:00
Jingyi-Dify e463389f2c feat: improve integration install flows 2026-05-18 20:55:05 -07:00
Jingyi-Dify cda348ca10 feat: split plugin settings by category 2026-05-18 20:54:32 -07:00
Jingyi-Dify ca48050666 feat: refine integrations page shell controls 2026-05-18 20:54:11 -07:00
Jingyi-Dify 9c0f592f34 feat: open integrations settings in account modal 2026-05-18 20:53:47 -07:00
JoelandJingyi-Dify b70241ad36 fix: app list not refresh 2026-05-18 12:11:08 -07:00
JoelandJingyi-Dify 4abe622b2e feat: continue with use the app list data 2026-05-18 12:10:58 -07:00
JoelandJingyi-Dify 16c32c82e3 feat: knowledge new sidebar 2026-05-18 12:10:48 -07:00
JoelandJingyi-Dify 46424513d1 chore: missing files 2026-05-18 12:10:37 -07:00
JoelandJingyi-Dify 2c4baa20d8 feat: app new nav 2026-05-18 12:10:25 -07:00
Jingyi-Dify b0ae553f2e fix(web): correct custom icon class names 2026-05-18 12:07:16 -07:00
Jingyi-Dify 0266a12ee5 fix(web): align rebased UI type contracts 2026-05-18 11:19:12 -07:00
Jingyi-Dify 9d7765d5fd docs: update main nav follow-up notes 2026-05-18 11:16:16 -07:00
Jingyi-Dify d4ef983f42 refactor(web): organize integrations page helpers 2026-05-18 11:16:16 -07:00
Jingyi-Dify 018f36711d fix(web): route document settings to integrations 2026-05-18 11:16:16 -07:00
Jingyi-Dify dacd333e4a chore(i18n): rename plugin-facing copy to integrations 2026-05-18 11:16:16 -07:00
Jingyi-Dify b079a26314 fix(web): gate integrations install actions 2026-05-18 11:16:15 -07:00
Jingyi-Dify 7e953ebe0b feat(web): complete update setting popover 2026-05-18 11:16:15 -07:00
Jingyi-Dify b4d28fca54 fix(web): polish integration page titles 2026-05-18 11:16:15 -07:00
JoelandJingyi-Dify 728c6b8201 chore: rename to marketplace path 2026-05-18 11:16:15 -07:00
JoelandJingyi-Dify f56e23b5fd chore: remove discover entrance 2026-05-18 11:16:15 -07:00
JoelandJingyi-Dify 5600cefa53 feat: add interation discover route 2026-05-18 11:16:15 -07:00
JoelandJingyi-Dify 561eb9cbd2 fix: trigger, agent-strategry, extension problem 2026-05-18 11:16:15 -07:00
JoelandJingyi-Dify 83766ca694 chore: new pages add to dataset route guard 2026-05-18 11:16:15 -07:00
JoelandJingyi-Dify 678be94d22 fix: custom tool copywriting 2026-05-18 11:16:15 -07:00
JoelandJingyi-Dify 9e852429be chore: split logic from accont setting and integrating setting 2026-05-18 11:16:15 -07:00
JoelandJingyi-Dify d93c5028f1 chore: rename to integration setting 2026-05-18 11:14:15 -07:00
JoelandJingyi-Dify 54f189305e chore: use new hook to handle setting 2026-05-18 11:13:33 -07:00
JoelandJingyi-Dify a610a24507 chore: filter apps and knowledges no data 2026-05-18 11:12:17 -07:00
JoelandJingyi-Dify 05e8a94bb5 fix: not configure default model tip not align 2026-05-18 11:12:17 -07:00
JoelandJingyi-Dify b2e2e7b60b chore: homepage coninue with to improve 2026-05-18 11:12:17 -07:00
JoelandJingyi-Dify e7d2e66ff5 chore: popup create hide some 2026-05-18 11:12:17 -07:00
JoelandJingyi-Dify c51069685c chore: some tiny style 2026-05-18 11:12:17 -07:00
JoelandJingyi-Dify 28c208f36a feat: knowledge items 2026-05-18 11:12:17 -07:00
JoelandJingyi-Dify 53a1386b87 feat: knowledge title 2026-05-18 11:12:17 -07:00
JoelandJingyi-Dify 0e366c7300 chore: show no empty logic 2026-05-18 11:12:17 -07:00
JoelandJingyi-Dify 939bdde373 feat: knowledge empty list 2026-05-18 11:12:17 -07:00
Jingyi-Dify 13dfa3aba4 feat(integrations): add unavailable page fallback 2026-05-18 11:12:16 -07:00
Jingyi-Dify 2705a7c1db feat(integrations): align tools and plugin category UI 2026-05-18 11:12:16 -07:00
Jingyi-Dify 258a751b8c feat(integrations): improve data source plugin management 2026-05-18 11:12:16 -07:00
Jingyi-Dify 5a35d3d9cd feat(plugin): add update settings popover 2026-05-18 11:12:16 -07:00
Jingyi-Dify c3fbafae83 chore(i18n): localize integrations updates 2026-05-18 11:12:16 -07:00
Jingyi-Dify f727c8f838 docs: update frontend agent guidance 2026-05-18 11:12:16 -07:00
JoelandJingyi-Dify 90af4c39b4 chore: some small ui 2026-05-18 11:12:16 -07:00
JoelandJingyi-Dify f7c3a4e4cb feat: empty page 2026-05-18 11:12:16 -07:00
JoelandJingyi-Dify be7d043edd chore: remove mock app data 2026-05-18 11:12:16 -07:00
JoelandJingyi-Dify cef8fe3a4b chore: remove shortcut 2026-05-18 11:12:16 -07:00
JoelandJingyi-Dify afe0e6c393 chore: missing files 2026-05-18 11:12:16 -07:00
JoelandJingyi-Dify 37309b931e feat: new head 2026-05-18 11:12:15 -07:00
JoelandJingyi-Dify 6a83c6705c temp: app hearder 2026-05-18 11:10:59 -07:00
JoelandJingyi-Dify 3e75d5e443 chore: create app card 2026-05-18 11:10:11 -07:00
JoelandJingyi-Dify 7be8a5b883 chore: app card ui 2026-05-18 11:10:11 -07:00
Jingyi-Dify 80dcb344f4 docs: record integrations install permission follow-up 2026-05-18 11:10:11 -07:00
Jingyi-Dify b029c9b1cd feat: add integrations plugin category views 2026-05-18 11:10:11 -07:00
Jingyi-Dify 6cb97e9201 fix: align tools and mcp provider behavior 2026-05-18 11:10:11 -07:00
Jingyi-Dify 4ef2e952bd feat: add integrations page shell refinements 2026-05-18 11:10:10 -07:00
Jingyi-Dify cc5545339c docs: update frontend review guidance
Document shared component reuse and component-writing checks for future frontend reviews, and refresh the MainNav follow-up notes.
2026-05-18 11:10:10 -07:00
Jingyi-Dify 0a8c46a3a7 refactor: polish integrations and main nav UI
Reuse shared base controls in MainNav and Integrations, add active integration icons, and keep compact integration content framing covered by targeted tests.
2026-05-18 11:10:10 -07:00
Jingyi-Dify 65770903d1 feat: refine integrations layout and controls
- add integrations headers, install action, permission quick settings, and update setting entry points

- centralize default vs compact content insets for integrations child pages

- cover provider, plugin, marketplace, MCP, and model provider behaviors with focused tests
2026-05-18 11:10:10 -07:00
Jingyi-Dify 5a6ba2ffb5 fix: localize integrations i18n copy 2026-05-18 11:09:15 -07:00
Jingyi-Dify aa53afe07d fix: update custom tool integration route 2026-05-18 11:09:14 -07:00
Jingyi-Dify 4740a89f4a feat: add canonical integrations routes 2026-05-18 11:09:14 -07:00
Jingyi-Dify 328db3d67a fix: align main nav interactions
Update active main nav icon positioning from the refreshed Figma assets, remove the transparent active border that caused nav item jitter, and route mobile common layout through the new MainNav instead of the legacy Header.

Also align workspace plan actions with the new UI contract by showing Upgrade for sandbox workspaces and View Plan for paid workspaces, both opening the pricing modal.
2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 88062fb247 feat: explore page to home page 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 045da59220 chore: app card icon and palce of learn dify 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 948b0f6bc7 chore: templates item ui and learn dify 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 14a59f6e44 chore: tag ui 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify f9f361113e feat: add description and tag filter 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify eea6f59307 chore: remove more learning templates and templates copywrite 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 718f69dc43 feat: hide learn dify anim effect 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 82a2ba9264 feat: learn dify 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 6c8e032fbb chore: fix small css 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 28c2c3bfd3 chore: split icon to new file and enchance data struct 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 9d463e1024 feat: continue work 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 7f87616625 chore: no show slide logic 2026-05-18 11:09:14 -07:00
JoelandJingyi-Dify 43a04ed0c2 feat: finish slide 2026-05-18 11:09:13 -07:00
Jingyi-Dify 5083edd0ce fix: align main nav gating and account popup behavior 2026-05-18 11:09:13 -07:00
Jingyi-Dify 8306fa41b9 fix(web): align main nav defaults
Default integrations to the model provider section and route the main nav entry there.

Hide cloud-only workspace credits and upgrade actions outside cloud edition.

Add the repo-local karpathy-guidelines skill.
2026-05-18 11:09:13 -07:00
Jingyi-Dify 8f33305e90 docs: update iconify review guidance
- generalize generated icon diff review guidance for intrinsic width and height changes
2026-05-18 11:09:13 -07:00
Jingyi-Dify 7077a43c1c feat: add integrations tools page with prebuilt icons
- add the integrations page sidebar with collapsible icon-only navigation and Figma-aligned marketplace card
- move custom integration SVGs into the iconify collection and document the Tailwind i-custom workflow
- preserve source SVG collection dimensions when flattening generated icon data so existing main nav icons keep their 20x20 viewBox
- add an icon dimension guard for layout-sensitive generated icons
- update model provider routing, i18n, and focused frontend tests
2026-05-18 11:09:13 -07:00
Jingyi-Dify 884a43ae0a fix(web): preserve settings fallbacks during main nav update
- hide migrated settings tabs from the account settings sidebar

- add disabled integrations destination mapping for future migration

- keep legacy settings modal fallback until integrations sections are ready

- restore main nav active styling and add titles for truncated labels
2026-05-18 11:09:13 -07:00
Jingyi-Dify 914f89f478 refactor(web): align main nav review feedback
- move main nav active edge styling into Tailwind classes

- split account dropdown menu content into focused components

- align frontend review skill rules with i18n and styling guidance

- add missing common i18n keys across supported locales
2026-05-18 11:09:13 -07:00
Jingyi-Dify 163153db18 refactor(web): split main nav components
- Move MainNav sections into focused components under main-nav/components

- Reuse Explore AppNavItem for MainNav web app rows via a mainNav variant

- Keep WorkspaceCard expanded panel behavior and styling aligned with the pre-refactor UI
2026-05-18 11:09:13 -07:00
Jingyi-Dify 49d890d514 feat(web): refine main nav onboarding UI
- Add a reusable dimm Badge variant for workspace plan labels

- Update MainNav workspace, web apps, account, and help menu styling to match Figma

- Add MainNav-specific account dropdown with appearance, language, timezone, and logout entries

- Keep account trigger compact without plan badge while preserving the badge in the popup header

- Prevent the common layout shell from creating a page-level scrollbar
2026-05-18 11:09:13 -07:00
Jingyi-Dify 0292bc2728 feat: refine desktop main nav visuals 2026-05-18 11:09:13 -07:00
Jingyi-Dify 5c21120977 feat: add desktop main navigation 2026-05-18 11:09:13 -07:00
5498 changed files with 148397 additions and 376876 deletions
@@ -0,0 +1,440 @@
---
name: component-refactoring
description: Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component --json` shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction, or when `pnpm analyze-component` warns to refactor before testing; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring.
---
# Dify Component Refactoring Skill
Refactor high-complexity React components in the Dify frontend codebase with the patterns and workflow below.
> **Complexity Threshold**: Components with complexity > 50 (measured by `pnpm analyze-component`) should be refactored before testing.
## Quick Reference
### Commands (run from `web/`)
Use paths relative to `web/` (e.g., `app/components/...`).
Use `refactor-component` for refactoring prompts and `analyze-component` for testing prompts and metrics.
```bash
cd web
# Generate refactoring prompt
pnpm refactor-component <path>
# Output refactoring analysis as JSON
pnpm refactor-component <path> --json
# Generate testing prompt (after refactoring)
pnpm analyze-component <path>
# Output testing analysis as JSON
pnpm analyze-component <path> --json
```
### Complexity Analysis
```bash
# Analyze component complexity
pnpm analyze-component <path> --json
# Key metrics to check:
# - complexity: normalized score 0-100 (target < 50)
# - maxComplexity: highest single function complexity
# - lineCount: total lines (target < 300)
```
### Complexity Score Interpretation
| Score | Level | Action |
|-------|-------|--------|
| 0-25 | 🟢 Simple | Ready for testing |
| 26-50 | 🟡 Medium | Consider minor refactoring |
| 51-75 | 🟠 Complex | **Refactor before testing** |
| 76-100 | 🔴 Very Complex | **Must refactor** |
## Core Refactoring Patterns
### Pattern 1: Extract Custom Hooks
**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI.
**Dify Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-<feature>.ts`.
```typescript
// ❌ Before: Complex state logic in component
function Configuration() {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
// 50+ lines of state management logic...
return <div>...</div>
}
// ✅ After: Extract to custom hook
// hooks/use-model-config.ts
export const useModelConfig = (appId: string) => {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
// Related state management logic here
return { modelConfig, setModelConfig, completionParams, setCompletionParams }
}
// Component becomes cleaner
function Configuration() {
const { modelConfig, setModelConfig } = useModelConfig(appId)
return <div>...</div>
}
```
**Dify Examples**:
- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts`
- `web/app/components/app/configuration/debug/hooks.tsx`
- `web/app/components/workflow/hooks/use-workflow.ts`
### Pattern 2: Extract Sub-Components
**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns.
**Dify Convention**: Place sub-components in subdirectories or as separate files in the same directory.
```typescript
// ❌ Before: Monolithic JSX with multiple sections
const AppInfo = () => {
return (
<div>
{/* 100 lines of header UI */}
{/* 100 lines of operations UI */}
{/* 100 lines of modals */}
</div>
)
}
// ✅ After: Split into focused components
// app-info/
// ├── index.tsx (orchestration only)
// ├── app-header.tsx (header UI)
// ├── app-operations.tsx (operations UI)
// └── app-modals.tsx (modal management)
const AppInfo = () => {
const { showModal, setShowModal } = useAppInfoModals()
return (
<div>
<AppHeader appDetail={appDetail} />
<AppOperations onAction={handleAction} />
<AppModals show={showModal} onClose={() => setShowModal(null)} />
</div>
)
}
```
**Dify Examples**:
- `web/app/components/app/configuration/` directory structure
- `web/app/components/workflow/nodes/` per-node organization
### Pattern 3: Simplify Conditional Logic
**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains.
```typescript
// ❌ Before: Deeply nested conditionals
const Template = useMemo(() => {
if (appDetail?.mode === AppModeEnum.CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh />
case LanguagesSupported[7]:
return <TemplateChatJa />
default:
return <TemplateChatEn />
}
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
// Another 15 lines...
}
// More conditions...
}, [appDetail, locale])
// ✅ After: Use lookup tables + early returns
const TEMPLATE_MAP = {
[AppModeEnum.CHAT]: {
[LanguagesSupported[1]]: TemplateChatZh,
[LanguagesSupported[7]]: TemplateChatJa,
default: TemplateChatEn,
},
[AppModeEnum.ADVANCED_CHAT]: {
[LanguagesSupported[1]]: TemplateAdvancedChatZh,
// ...
},
}
const Template = useMemo(() => {
const modeTemplates = TEMPLATE_MAP[appDetail?.mode]
if (!modeTemplates) return null
const TemplateComponent = modeTemplates[locale] || modeTemplates.default
return <TemplateComponent appDetail={appDetail} />
}, [appDetail, locale])
```
### Pattern 4: Extract API/Data Logic
**When**: Component directly handles API calls, data transformation, or complex async operations.
**Dify Convention**:
- This skill is for component decomposition, not query/mutation design.
- Do not introduce deprecated `useInvalid` / `useReset`.
- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.
**Dify Examples**:
- `web/service/use-workflow.ts`
- `web/service/use-common.ts`
- `web/service/knowledge/use-dataset.ts`
- `web/service/knowledge/use-document.ts`
### Pattern 5: Extract Modal/Dialog Management
**When**: Component manages multiple modals with complex open/close states.
**Dify Convention**: Modals should be extracted with their state management.
```typescript
// ❌ Before: Multiple modal states in component
const AppInfo = () => {
const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showSwitchModal, setShowSwitchModal] = useState(false)
const [showImportDSLModal, setShowImportDSLModal] = useState(false)
// 5+ more modal states...
}
// ✅ After: Extract to modal management hook
type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null
const useAppInfoModals = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const openModal = useCallback((type: ModalType) => setActiveModal(type), [])
const closeModal = useCallback(() => setActiveModal(null), [])
return {
activeModal,
openModal,
closeModal,
isOpen: (type: ModalType) => activeModal === type,
}
}
```
### Pattern 6: Extract Form Logic
**When**: Complex form validation, submission handling, or field transformation.
**Dify Convention**: Use `@tanstack/react-form` patterns from `web/app/components/base/form/`.
```typescript
// ✅ Use existing form infrastructure
import { useAppForm } from '@/app/components/base/form'
const ConfigForm = () => {
const form = useAppForm({
defaultValues: { name: '', description: '' },
onSubmit: handleSubmit,
})
return <form.Provider>...</form.Provider>
}
```
## Dify-Specific Refactoring Guidelines
### 1. Context Provider Extraction
**When**: Component provides complex context values with multiple states.
```typescript
// ❌ Before: Large context value object
const value = {
appId, isAPIKeySet, isTrailFinished, mode, modelModeType,
promptMode, isAdvancedMode, isAgent, isOpenAI, isFunctionCall,
// 50+ more properties...
}
return <ConfigContext.Provider value={value}>...</ConfigContext.Provider>
// ✅ After: Split into domain-specific contexts
<ModelConfigProvider value={modelConfigValue}>
<DatasetConfigProvider value={datasetConfigValue}>
<UIConfigProvider value={uiConfigValue}>
{children}
</UIConfigProvider>
</DatasetConfigProvider>
</ModelConfigProvider>
```
**Dify Reference**: `web/context/` directory structure
### 2. Workflow Node Components
**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`).
**Conventions**:
- Keep node logic in `use-interactions.ts`
- Extract panel UI to separate files
- Use `_base` components for common patterns
```
nodes/<node-type>/
├── index.tsx # Node registration
├── node.tsx # Node visual component
├── panel.tsx # Configuration panel
├── use-interactions.ts # Node-specific hooks
└── types.ts # Type definitions
```
### 3. Configuration Components
**When**: Refactoring app configuration components.
**Conventions**:
- Separate config sections into subdirectories
- Use existing patterns from `web/app/components/app/configuration/`
- Keep feature toggles in dedicated components
### 4. Tool/Plugin Components
**When**: Refactoring tool-related components (`web/app/components/tools/`).
**Conventions**:
- Follow existing modal patterns
- Use service hooks from `web/service/use-tools.ts`
- Keep provider-specific logic isolated
## Refactoring Workflow
### Step 1: Generate Refactoring Prompt
```bash
pnpm refactor-component <path>
```
This command will:
- Analyze component complexity and features
- Identify specific refactoring actions needed
- Generate a prompt for AI assistant (auto-copied to clipboard on macOS)
- Provide detailed requirements based on detected patterns
### Step 2: Analyze Details
```bash
pnpm analyze-component <path> --json
```
Identify:
- Total complexity score
- Max function complexity
- Line count
- Features detected (state, effects, API, etc.)
### Step 3: Plan
Create a refactoring plan based on detected features:
| Detected Feature | Refactoring Action |
|------------------|-------------------|
| `hasState: true` + `hasEffects: true` | Extract custom hook |
| `hasAPI: true` | Extract data/service hook |
| `hasEvents: true` (many) | Extract event handlers |
| `lineCount > 300` | Split into sub-components |
| `maxComplexity > 50` | Simplify conditional logic |
### Step 4: Execute Incrementally
1. **Extract one piece at a time**
2. **Run lint, type-check, and tests after each extraction**
3. **Verify functionality before next step**
```
For each extraction:
┌────────────────────────────────────────┐
│ 1. Extract code │
│ 2. Run: pnpm lint:fix │
│ 3. Run: pnpm type-check │
│ 4. Run: pnpm test │
│ 5. Test functionality manually │
│ 6. PASS? → Next extraction │
│ FAIL? → Fix before continuing │
└────────────────────────────────────────┘
```
### Step 5: Verify
After refactoring:
```bash
# Re-run refactor command to verify improvements
pnpm refactor-component <path>
# If complexity < 25 and lines < 200, you'll see:
# ✅ COMPONENT IS WELL-STRUCTURED
# For detailed metrics:
pnpm analyze-component <path> --json
# Target metrics:
# - complexity < 50
# - lineCount < 300
# - maxComplexity < 30
```
## Common Mistakes to Avoid
### ❌ Over-Engineering
```typescript
// ❌ Too many tiny hooks
const useButtonText = () => useState('Click')
const useButtonDisabled = () => useState(false)
const useButtonLoading = () => useState(false)
// ✅ Cohesive hook with related state
const useButtonState = () => {
const [text, setText] = useState('Click')
const [disabled, setDisabled] = useState(false)
const [loading, setLoading] = useState(false)
return { text, setText, disabled, setDisabled, loading, setLoading }
}
```
### ❌ Breaking Existing Patterns
- Follow existing directory structures
- Maintain naming conventions
- Preserve export patterns for compatibility
### ❌ Premature Abstraction
- Only extract when there's clear complexity benefit
- Don't create abstractions for single-use code
- Keep refactored code in the same domain area
## References
### Dify Codebase Examples
- **Hook extraction**: `web/app/components/app/configuration/hooks/`
- **Component splitting**: `web/app/components/app/configuration/`
- **Service hooks**: `web/service/use-*.ts`
- **Workflow patterns**: `web/app/components/workflow/hooks/`
- **Form patterns**: `web/app/components/base/form/`
### Related Skills
- `frontend-testing` - For testing refactored components
- `web/docs/test.md` - Testing specification
@@ -0,0 +1,495 @@
# Complexity Reduction Patterns
This document provides patterns for reducing cognitive complexity in Dify React components.
## Understanding Complexity
### SonarJS Cognitive Complexity
The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics:
- **Total Complexity**: Sum of all functions' complexity in the file
- **Max Complexity**: Highest single function complexity
### What Increases Complexity
| Pattern | Complexity Impact |
|---------|-------------------|
| `if/else` | +1 per branch |
| Nested conditions | +1 per nesting level |
| `switch/case` | +1 per case |
| `for/while/do` | +1 per loop |
| `&&`/`||` chains | +1 per operator |
| Nested callbacks | +1 per nesting level |
| `try/catch` | +1 per catch |
| Ternary expressions | +1 per nesting |
## Pattern 1: Replace Conditionals with Lookup Tables
**Before** (complexity: ~15):
```typescript
const Template = useMemo(() => {
if (appDetail?.mode === AppModeEnum.CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh appDetail={appDetail} />
case LanguagesSupported[7]:
return <TemplateChatJa appDetail={appDetail} />
default:
return <TemplateChatEn appDetail={appDetail} />
}
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateAdvancedChatZh appDetail={appDetail} />
case LanguagesSupported[7]:
return <TemplateAdvancedChatJa appDetail={appDetail} />
default:
return <TemplateAdvancedChatEn appDetail={appDetail} />
}
}
if (appDetail?.mode === AppModeEnum.WORKFLOW) {
// Similar pattern...
}
return null
}, [appDetail, locale])
```
**After** (complexity: ~3):
```typescript
import type { ComponentType } from 'react'
// Define lookup table outside component
const TEMPLATE_MAP: Record<AppModeEnum, Record<string, ComponentType<TemplateProps>>> = {
[AppModeEnum.CHAT]: {
[LanguagesSupported[1]]: TemplateChatZh,
[LanguagesSupported[7]]: TemplateChatJa,
default: TemplateChatEn,
},
[AppModeEnum.ADVANCED_CHAT]: {
[LanguagesSupported[1]]: TemplateAdvancedChatZh,
[LanguagesSupported[7]]: TemplateAdvancedChatJa,
default: TemplateAdvancedChatEn,
},
[AppModeEnum.WORKFLOW]: {
[LanguagesSupported[1]]: TemplateWorkflowZh,
[LanguagesSupported[7]]: TemplateWorkflowJa,
default: TemplateWorkflowEn,
},
// ...
}
// Clean component logic
const Template = useMemo(() => {
if (!appDetail?.mode) return null
const templates = TEMPLATE_MAP[appDetail.mode]
if (!templates) return null
const TemplateComponent = templates[locale] ?? templates.default
return <TemplateComponent appDetail={appDetail} />
}, [appDetail, locale])
```
## Pattern 2: Use Early Returns
**Before** (complexity: ~10):
```typescript
const handleSubmit = () => {
if (isValid) {
if (hasChanges) {
if (isConnected) {
submitData()
} else {
showConnectionError()
}
} else {
showNoChangesMessage()
}
} else {
showValidationError()
}
}
```
**After** (complexity: ~4):
```typescript
const handleSubmit = () => {
if (!isValid) {
showValidationError()
return
}
if (!hasChanges) {
showNoChangesMessage()
return
}
if (!isConnected) {
showConnectionError()
return
}
submitData()
}
```
## Pattern 3: Extract Complex Conditions
**Before** (complexity: high):
```typescript
const canPublish = (() => {
if (mode !== AppModeEnum.COMPLETION) {
if (!isAdvancedMode)
return true
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history || !hasSetBlockStatus.query)
return false
return true
}
return true
}
return !promptEmpty
})()
```
**After** (complexity: lower):
```typescript
// Extract to named functions
const canPublishInCompletionMode = () => !promptEmpty
const canPublishInChatMode = () => {
if (!isAdvancedMode) return true
if (modelModeType !== ModelModeType.completion) return true
return hasSetBlockStatus.history && hasSetBlockStatus.query
}
// Clean main logic
const canPublish = mode === AppModeEnum.COMPLETION
? canPublishInCompletionMode()
: canPublishInChatMode()
```
## Pattern 4: Replace Chained Ternaries
**Before** (complexity: ~5):
```typescript
const statusText = serverActivated
? t('status.running')
: serverPublished
? t('status.inactive')
: appUnpublished
? t('status.unpublished')
: t('status.notConfigured')
```
**After** (complexity: ~2):
```typescript
const getStatusText = () => {
if (serverActivated) return t('status.running')
if (serverPublished) return t('status.inactive')
if (appUnpublished) return t('status.unpublished')
return t('status.notConfigured')
}
const statusText = getStatusText()
```
Or use lookup:
```typescript
const STATUS_TEXT_MAP = {
running: 'status.running',
inactive: 'status.inactive',
unpublished: 'status.unpublished',
notConfigured: 'status.notConfigured',
} as const
const getStatusKey = (): keyof typeof STATUS_TEXT_MAP => {
if (serverActivated) return 'running'
if (serverPublished) return 'inactive'
if (appUnpublished) return 'unpublished'
return 'notConfigured'
}
const statusText = t(STATUS_TEXT_MAP[getStatusKey()])
```
## Pattern 5: Flatten Nested Loops
**Before** (complexity: high):
```typescript
const processData = (items: Item[]) => {
const results: ProcessedItem[] = []
for (const item of items) {
if (item.isValid) {
for (const child of item.children) {
if (child.isActive) {
for (const prop of child.properties) {
if (prop.value !== null) {
results.push({
itemId: item.id,
childId: child.id,
propValue: prop.value,
})
}
}
}
}
}
}
return results
}
```
**After** (complexity: lower):
```typescript
// Use functional approach
const processData = (items: Item[]) => {
return items
.filter(item => item.isValid)
.flatMap(item =>
item.children
.filter(child => child.isActive)
.flatMap(child =>
child.properties
.filter(prop => prop.value !== null)
.map(prop => ({
itemId: item.id,
childId: child.id,
propValue: prop.value,
}))
)
)
}
```
## Pattern 6: Extract Event Handler Logic
**Before** (complexity: high in component):
```typescript
const Component = () => {
const handleSelect = (data: DataSet[]) => {
if (isEqual(data.map(item => item.id), dataSets.map(item => item.id))) {
hideSelectDataSet()
return
}
formattingChangedDispatcher()
let newDatasets = data
if (data.find(item => !item.name)) {
const newSelected = produce(data, (draft) => {
data.forEach((item, index) => {
if (!item.name) {
const newItem = dataSets.find(i => i.id === item.id)
if (newItem)
draft[index] = newItem
}
})
})
setDataSets(newSelected)
newDatasets = newSelected
}
else {
setDataSets(data)
}
hideSelectDataSet()
// 40 more lines of logic...
}
return <div>...</div>
}
```
**After** (complexity: lower):
```typescript
// Extract to hook or utility
const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState<DataSet[]>) => {
const normalizeSelection = (data: DataSet[]) => {
const hasUnloadedItem = data.some(item => !item.name)
if (!hasUnloadedItem) return data
return produce(data, (draft) => {
data.forEach((item, index) => {
if (!item.name) {
const existing = dataSets.find(i => i.id === item.id)
if (existing) draft[index] = existing
}
})
})
}
const hasSelectionChanged = (newData: DataSet[]) => {
return !isEqual(
newData.map(item => item.id),
dataSets.map(item => item.id)
)
}
return { normalizeSelection, hasSelectionChanged }
}
// Component becomes cleaner
const Component = () => {
const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets)
const handleSelect = (data: DataSet[]) => {
if (!hasSelectionChanged(data)) {
hideSelectDataSet()
return
}
formattingChangedDispatcher()
const normalized = normalizeSelection(data)
setDataSets(normalized)
hideSelectDataSet()
}
return <div>...</div>
}
```
## Pattern 7: Reduce Boolean Logic Complexity
**Before** (complexity: ~8):
```typescript
const toggleDisabled = hasInsufficientPermissions
|| appUnpublished
|| missingStartNode
|| triggerModeDisabled
|| (isAdvancedApp && !currentWorkflow?.graph)
|| (isBasicApp && !basicAppConfig.updated_at)
```
**After** (complexity: ~3):
```typescript
// Extract meaningful boolean functions
const isAppReady = () => {
if (isAdvancedApp) return !!currentWorkflow?.graph
return !!basicAppConfig.updated_at
}
const hasRequiredPermissions = () => {
return isCurrentWorkspaceEditor && !hasInsufficientPermissions
}
const canToggle = () => {
if (!hasRequiredPermissions()) return false
if (!isAppReady()) return false
if (missingStartNode) return false
if (triggerModeDisabled) return false
return true
}
const toggleDisabled = !canToggle()
```
## Pattern 8: Simplify useMemo/useCallback Dependencies
**Before** (complexity: multiple recalculations):
```typescript
const payload = useMemo(() => {
let parameters: Parameter[] = []
let outputParameters: OutputParameter[] = []
if (!published) {
parameters = (inputs || []).map((item) => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
outputParameters = (outputs || []).map((item) => ({
name: item.variable,
description: '',
type: item.value_type,
}))
}
else if (detail && detail.tool) {
parameters = (inputs || []).map((item) => ({
// Complex transformation...
}))
outputParameters = (outputs || []).map((item) => ({
// Complex transformation...
}))
}
return {
icon: detail?.icon || icon,
label: detail?.label || name,
// ...more fields
}
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
```
**After** (complexity: separated concerns):
```typescript
// Separate transformations
const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => {
return useMemo(() => {
if (!published) {
return inputs.map(item => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
}
if (!detail?.tool) return []
return inputs.map(item => ({
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '',
form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm',
}))
}, [inputs, detail, published])
}
// Component uses hook
const parameters = useParameterTransform(inputs, detail, published)
const outputParameters = useOutputTransform(outputs, detail, published)
const payload = useMemo(() => ({
icon: detail?.icon || icon,
label: detail?.label || name,
parameters,
outputParameters,
// ...
}), [detail, icon, name, parameters, outputParameters])
```
## Target Metrics After Refactoring
| Metric | Target |
|--------|--------|
| Total Complexity | < 50 |
| Max Function Complexity | < 30 |
| Function Length | < 30 lines |
| Nesting Depth | ≤ 3 levels |
| Conditional Chains | ≤ 3 conditions |
@@ -0,0 +1,477 @@
# Component Splitting Patterns
This document provides detailed guidance on splitting large components into smaller, focused components in Dify.
## When to Split Components
Split a component when you identify:
1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently
1. **Conditional rendering blocks** - Large `{condition && <JSX />}` blocks
1. **Repeated patterns** - Similar UI structures used multiple times
1. **300+ lines** - Component exceeds manageable size
1. **Modal clusters** - Multiple modals rendered in one component
## Splitting Strategies
### Strategy 1: Section-Based Splitting
Identify visual sections and extract each as a component.
```typescript
// ❌ Before: Monolithic component (500+ lines)
const ConfigurationPage = () => {
return (
<div>
{/* Header Section - 50 lines */}
<div className="header">
<h1>{t('configuration.title')}</h1>
<div className="actions">
{isAdvancedMode && <Badge>Advanced</Badge>}
<ModelParameterModal ... />
<AppPublisher ... />
</div>
</div>
{/* Config Section - 200 lines */}
<div className="config">
<Config />
</div>
{/* Debug Section - 150 lines */}
<div className="debug">
<Debug ... />
</div>
{/* Modals Section - 100 lines */}
{showSelectDataSet && <SelectDataSet ... />}
{showHistoryModal && <EditHistoryModal ... />}
{showUseGPT4Confirm && <Confirm ... />}
</div>
)
}
// ✅ After: Split into focused components
// configuration/
// ├── index.tsx (orchestration)
// ├── configuration-header.tsx
// ├── configuration-content.tsx
// ├── configuration-debug.tsx
// └── configuration-modals.tsx
// configuration-header.tsx
interface ConfigurationHeaderProps {
isAdvancedMode: boolean
onPublish: () => void
}
function ConfigurationHeader({
isAdvancedMode,
onPublish,
}: ConfigurationHeaderProps) {
const { t } = useTranslation()
return (
<div className="header">
<h1>{t('configuration.title')}</h1>
<div className="actions">
{isAdvancedMode && <Badge>Advanced</Badge>}
<ModelParameterModal ... />
<AppPublisher onPublish={onPublish} />
</div>
</div>
)
}
// index.tsx (orchestration only)
const ConfigurationPage = () => {
const { modelConfig, setModelConfig } = useModelConfig()
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
<ConfigurationHeader
isAdvancedMode={isAdvancedMode}
onPublish={handlePublish}
/>
<ConfigurationContent
modelConfig={modelConfig}
onConfigChange={setModelConfig}
/>
{!isMobile && (
<ConfigurationDebug
inputs={inputs}
onSetting={handleSetting}
/>
)}
<ConfigurationModals
activeModal={activeModal}
onClose={closeModal}
/>
</div>
)
}
```
### Strategy 2: Conditional Block Extraction
Extract large conditional rendering blocks.
```typescript
// ❌ Before: Large conditional blocks
const AppInfo = () => {
return (
<div>
{expand ? (
<div className="expanded">
{/* 100 lines of expanded view */}
</div>
) : (
<div className="collapsed">
{/* 50 lines of collapsed view */}
</div>
)}
</div>
)
}
// ✅ After: Separate view components
function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) {
return (
<div className="expanded">
{/* Clean, focused expanded view */}
</div>
)
}
function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) {
return (
<div className="collapsed">
{/* Clean, focused collapsed view */}
</div>
)
}
const AppInfo = () => {
return (
<div>
{expand
? <AppInfoExpanded appDetail={appDetail} onAction={handleAction} />
: <AppInfoCollapsed appDetail={appDetail} onAction={handleAction} />
}
</div>
)
}
```
### Strategy 3: Modal Extraction
Extract modals with their trigger logic.
```typescript
// ❌ Before: Multiple modals in one component
const AppInfo = () => {
const [showEdit, setShowEdit] = useState(false)
const [showDuplicate, setShowDuplicate] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const [showSwitch, setShowSwitch] = useState(false)
const onEdit = async (data) => { /* 20 lines */ }
const onDuplicate = async (data) => { /* 20 lines */ }
const onDelete = async () => { /* 15 lines */ }
return (
<div>
{/* Main content */}
{showEdit && <EditModal onConfirm={onEdit} onClose={() => setShowEdit(false)} />}
{showDuplicate && <DuplicateModal onConfirm={onDuplicate} onClose={() => setShowDuplicate(false)} />}
{showDelete && <DeleteConfirm onConfirm={onDelete} onClose={() => setShowDelete(false)} />}
{showSwitch && <SwitchModal ... />}
</div>
)
}
// ✅ After: Modal manager component
// app-info-modals.tsx
type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | null
interface AppInfoModalsProps {
appDetail: AppDetail
activeModal: ModalType
onClose: () => void
onSuccess: () => void
}
function AppInfoModals({
appDetail,
activeModal,
onClose,
onSuccess,
}: AppInfoModalsProps) {
const handleEdit = async (data) => { /* logic */ }
const handleDuplicate = async (data) => { /* logic */ }
const handleDelete = async () => { /* logic */ }
return (
<>
{activeModal === 'edit' && (
<EditModal
appDetail={appDetail}
onConfirm={handleEdit}
onClose={onClose}
/>
)}
{activeModal === 'duplicate' && (
<DuplicateModal
appDetail={appDetail}
onConfirm={handleDuplicate}
onClose={onClose}
/>
)}
{activeModal === 'delete' && (
<DeleteConfirm
onConfirm={handleDelete}
onClose={onClose}
/>
)}
{activeModal === 'switch' && (
<SwitchModal
appDetail={appDetail}
onClose={onClose}
/>
)}
</>
)
}
// Parent component
const AppInfo = () => {
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
{/* Main content with openModal triggers */}
<Button onClick={() => openModal('edit')}>Edit</Button>
<AppInfoModals
appDetail={appDetail}
activeModal={activeModal}
onClose={closeModal}
onSuccess={handleSuccess}
/>
</div>
)
}
```
### Strategy 4: List Item Extraction
Extract repeated item rendering.
```typescript
// ❌ Before: Inline item rendering
const OperationsList = () => {
return (
<div>
{operations.map(op => (
<div key={op.id} className="operation-item">
<span className="icon">{op.icon}</span>
<span className="title">{op.title}</span>
<span className="description">{op.description}</span>
<button onClick={() => op.onClick()}>
{op.actionLabel}
</button>
{op.badge && <Badge>{op.badge}</Badge>}
{/* More complex rendering... */}
</div>
))}
</div>
)
}
// ✅ After: Extracted item component
interface OperationItemProps {
operation: Operation
onAction: (id: string) => void
}
function OperationItem({ operation, onAction }: OperationItemProps) {
return (
<div className="operation-item">
<span className="icon">{operation.icon}</span>
<span className="title">{operation.title}</span>
<span className="description">{operation.description}</span>
<button onClick={() => onAction(operation.id)}>
{operation.actionLabel}
</button>
{operation.badge && <Badge>{operation.badge}</Badge>}
</div>
)
}
const OperationsList = () => {
const handleAction = useCallback((id: string) => {
const op = operations.find(o => o.id === id)
op?.onClick()
}, [operations])
return (
<div>
{operations.map(op => (
<OperationItem
key={op.id}
operation={op}
onAction={handleAction}
/>
))}
</div>
)
}
```
## Directory Structure Patterns
### Pattern A: Flat Structure (Simple Components)
For components with 2-3 sub-components:
```
component-name/
├── index.tsx # Main component
├── sub-component-a.tsx
├── sub-component-b.tsx
└── types.ts # Shared types
```
### Pattern B: Nested Structure (Complex Components)
For components with many sub-components:
```
component-name/
├── index.tsx # Main orchestration
├── types.ts # Shared types
├── hooks/
│ ├── use-feature-a.ts
│ └── use-feature-b.ts
├── components/
│ ├── header/
│ │ └── index.tsx
│ ├── content/
│ │ └── index.tsx
│ └── modals/
│ └── index.tsx
└── utils/
└── helpers.ts
```
### Pattern C: Feature-Based Structure (Dify Standard)
Following Dify's existing patterns:
```
configuration/
├── index.tsx # Main page component
├── base/ # Base/shared components
│ ├── feature-panel/
│ ├── group-name/
│ └── operation-btn/
├── config/ # Config section
│ ├── index.tsx
│ ├── agent/
│ └── automatic/
├── dataset-config/ # Dataset section
│ ├── index.tsx
│ ├── card-item/
│ └── params-config/
├── debug/ # Debug section
│ ├── index.tsx
│ └── hooks.tsx
└── hooks/ # Shared hooks
└── use-advanced-prompt-config.ts
```
## Props Design
### Minimal Props Principle
Pass only what's needed:
```typescript
// ❌ Bad: Passing entire objects when only some fields needed
<ConfigHeader appDetail={appDetail} modelConfig={modelConfig} />
// ✅ Good: Destructure to minimum required
<ConfigHeader
appName={appDetail.name}
isAdvancedMode={modelConfig.isAdvanced}
onPublish={handlePublish}
/>
```
### Callback Props Pattern
Use callbacks for child-to-parent communication:
```typescript
// Parent
const Parent = () => {
const [value, setValue] = useState('')
return (
<Child
value={value}
onChange={setValue}
onSubmit={handleSubmit}
/>
)
}
// Child
interface ChildProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
}
function Child({ value, onChange, onSubmit }: ChildProps) {
return (
<div>
<input value={value} onChange={e => onChange(e.target.value)} />
<button onClick={onSubmit}>Submit</button>
</div>
)
}
```
### Render Props for Flexibility
When sub-components need parent context:
```typescript
interface ListProps<T> {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
renderEmpty?: () => React.ReactNode
}
function List<T>({ items, renderItem, renderEmpty }: ListProps<T>) {
if (items.length === 0 && renderEmpty) {
return <>{renderEmpty()}</>
}
return (
<div>
{items.map((item, index) => renderItem(item, index))}
</div>
)
}
// Usage
<List
items={operations}
renderItem={(op, i) => <OperationItem key={i} operation={op} />}
renderEmpty={() => <EmptyState message="No operations" />}
/>
```
@@ -0,0 +1,281 @@
# Hook Extraction Patterns
This document provides detailed guidance on extracting custom hooks from complex components in Dify.
## When to Extract Hooks
Extract a custom hook when you identify:
1. **Coupled state groups** - Multiple `useState` hooks that are always used together
1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic
1. **Business logic** - Data transformations, validations, or calculations
1. **Reusable patterns** - Logic that appears in multiple components
## Extraction Process
### Step 1: Identify State Groups
Look for state variables that are logically related:
```typescript
// ❌ These belong together - extract to hook
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
const [modelModeType, setModelModeType] = useState<ModelModeType>(...)
// These are model-related state that should be in useModelConfig()
```
### Step 2: Identify Related Effects
Find effects that modify the grouped state:
```typescript
// ❌ These effects belong with the state above
useEffect(() => {
if (hasFetchedDetail && !modelModeType) {
const mode = currModel?.model_properties.mode
if (mode) {
const newModelConfig = produce(modelConfig, (draft) => {
draft.mode = mode
})
setModelConfig(newModelConfig)
}
}
}, [textGenerationModelList, hasFetchedDetail, modelModeType, currModel])
```
### Step 3: Create the Hook
```typescript
// hooks/use-model-config.ts
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ModelConfig } from '@/models/debug'
import { produce } from 'immer'
import { useEffect, useState } from 'react'
import { ModelModeType } from '@/types/app'
interface UseModelConfigParams {
initialConfig?: Partial<ModelConfig>
currModel?: { model_properties?: { mode?: ModelModeType } }
hasFetchedDetail: boolean
}
interface UseModelConfigReturn {
modelConfig: ModelConfig
setModelConfig: (config: ModelConfig) => void
completionParams: FormValue
setCompletionParams: (params: FormValue) => void
modelModeType: ModelModeType
}
export const useModelConfig = ({
initialConfig,
currModel,
hasFetchedDetail,
}: UseModelConfigParams): UseModelConfigReturn => {
const [modelConfig, setModelConfig] = useState<ModelConfig>({
provider: 'langgenius/openai/openai',
model_id: 'gpt-3.5-turbo',
mode: ModelModeType.unset,
// ... default values
...initialConfig,
})
const [completionParams, setCompletionParams] = useState<FormValue>({})
const modelModeType = modelConfig.mode
// Fill old app data missing model mode
useEffect(() => {
if (hasFetchedDetail && !modelModeType) {
const mode = currModel?.model_properties?.mode
if (mode) {
setModelConfig(produce(modelConfig, (draft) => {
draft.mode = mode
}))
}
}
}, [hasFetchedDetail, modelModeType, currModel])
return {
modelConfig,
setModelConfig,
completionParams,
setCompletionParams,
modelModeType,
}
}
```
### Step 4: Update Component
```typescript
// Before: 50+ lines of state management
function Configuration() {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
// ... lots of related state and effects
}
// After: Clean component
function Configuration() {
const {
modelConfig,
setModelConfig,
completionParams,
setCompletionParams,
modelModeType,
} = useModelConfig({
currModel,
hasFetchedDetail,
})
// Component now focuses on UI
}
```
## Naming Conventions
### Hook Names
- Use `use` prefix: `useModelConfig`, `useDatasetConfig`
- Be specific: `useAdvancedPromptConfig` not `usePrompt`
- Include domain: `useWorkflowVariables`, `useMCPServer`
### File Names
- Kebab-case: `use-model-config.ts`
- Place in `hooks/` subdirectory when multiple hooks exist
- Place alongside component for single-use hooks
### Return Type Names
- Suffix with `Return`: `UseModelConfigReturn`
- Suffix params with `Params`: `UseModelConfigParams`
## Common Hook Patterns in Dify
### 1. Data Fetching / Mutation Hooks
When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns.
- Do not introduce deprecated `useInvalid` / `useReset`.
- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks.
### 2. Form State Hook
```typescript
// Pattern: Form state + validation + submission
export const useConfigForm = (initialValues: ConfigFormValues) => {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const validate = useCallback(() => {
const newErrors: Record<string, string> = {}
if (!values.name) newErrors.name = 'Name is required'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [values])
const handleChange = useCallback((field: string, value: any) => {
setValues(prev => ({ ...prev, [field]: value }))
}, [])
const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise<void>) => {
if (!validate()) return
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
}, [values, validate])
return { values, errors, isSubmitting, handleChange, handleSubmit }
}
```
### 3. Modal State Hook
```typescript
// Pattern: Multiple modal management
type ModalType = 'edit' | 'delete' | 'duplicate' | null
export const useModalState = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const [modalData, setModalData] = useState<any>(null)
const openModal = useCallback((type: ModalType, data?: any) => {
setActiveModal(type)
setModalData(data)
}, [])
const closeModal = useCallback(() => {
setActiveModal(null)
setModalData(null)
}, [])
return {
activeModal,
modalData,
openModal,
closeModal,
isOpen: useCallback((type: ModalType) => activeModal === type, [activeModal]),
}
}
```
### 4. Toggle/Boolean Hook
```typescript
// Pattern: Boolean state with convenience methods
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(v => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return [value, { toggle, setTrue, setFalse, set: setValue }] as const
}
// Usage
const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle()
```
## Testing Extracted Hooks
After extraction, test hooks in isolation:
```typescript
// use-model-config.spec.ts
import { renderHook, act } from '@testing-library/react'
import { useModelConfig } from './use-model-config'
describe('useModelConfig', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: false,
}))
expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai')
expect(result.current.modelModeType).toBe(ModelModeType.unset)
})
it('should update model config', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: true,
}))
act(() => {
result.current.setModelConfig({
...result.current.modelConfig,
model_id: 'gpt-4',
})
})
expect(result.current.modelConfig.model_id).toBe('gpt-4')
})
})
```
@@ -5,7 +5,7 @@ description: Write, update, or review Dify end-to-end tests under `e2e/` that us
# Dify E2E Cucumber + Playwright
Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) as the canonical package guide for local architecture and conventions, then read any feature-scoped `AGENTS.md` that owns the target area. Apply Playwright/Cucumber best practices only where they fit the current suite.
Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) as the canonical guide for local architecture and conventions, then apply Playwright/Cucumber best practices only where they fit the current suite.
## Scope
@@ -25,8 +25,6 @@ Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS
4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved.
5. Re-check official Playwright or Cucumber docs with the available documentation tools before introducing a new framework pattern.
Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. Put feature-specific conventions in the owning feature's `AGENTS.md` instead of adding them here.
## Local Rules
- `e2e/` uses Cucumber for scenarios and Playwright as the browser layer.
@@ -45,7 +43,6 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance.
- Inspect the target feature area.
- Reuse an existing step when wording and behavior already match.
- Add a new step only for a genuinely new user action or assertion.
- Before adding several similar steps, scan the target capability for an existing domain noun that can be parameterized without hiding behavior.
- Keep edits close to the current capability folder unless the step is broadly reusable.
2. Write behavior-first scenarios.
- Describe user-observable behavior, not DOM mechanics.
@@ -54,19 +51,15 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance.
3. Write step definitions in the local style.
- Keep one step to one user-visible action or one assertion.
- Prefer Cucumber Expressions such as `{string}` and `{int}`.
- Use a bounded regex only when the accepted values are a small explicit domain set and Cucumber Expressions would make the Gherkin less natural.
- Do not create one-off steps for each case variant when the same domain action or outcome applies to named surfaces, modes, or resources.
- Scope locators to stable containers when the page has repeated elements.
- Avoid page-object layers or extra helper abstractions unless repeated complexity clearly justifies them.
4. Use Playwright in the local style.
- Prefer user-facing locators: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then `getByTestId` for explicit contracts.
- Use web-first `expect(...)` assertions.
- Do not use `waitForTimeout`, manual polling, or raw visibility checks when a locator action or retrying assertion already expresses the behavior.
- Use `expect.poll` for API persistence, backend eventual consistency, captured browser events, or other non-DOM state; prefer locator assertions for DOM readiness and visible UI state.
- If a product element has real user-facing semantics but no accessible name, prefer fixing that accessible contract over adding a test id.
5. Validate narrowly.
- Run the narrowest tagged scenario or flow that exercises the change.
- Run `vpr lint --fix --quiet` from the repository root and `pnpm -C e2e type-check`.
- Run `pnpm -C e2e check`.
- Broaden verification only when the change affects hooks, tags, setup, or shared step semantics.
## Review Checklist
@@ -39,7 +39,6 @@ Prefer reuse when:
- the user action is genuinely the same
- the expected outcome is genuinely the same
- the wording stays natural across features
- the parameter is a real product domain value such as a named surface, mode, resource, or status
Write a new step when:
@@ -47,8 +46,6 @@ Write a new step when:
- reusing the old wording would make the scenario misleading
- a supposedly generic step would become an implementation-detail wrapper
Do not optimize for a low step count by making vague steps. Optimize for a small set of truthful, domain-owned steps.
### 4. Prefer Cucumber Expressions
Use Cucumber Expressions for parameters unless regex is clearly necessary.
@@ -62,8 +59,6 @@ Common examples:
Keep expressions readable. If a step needs complicated parsing logic, first ask whether the scenario wording should be simpler.
Use regex for a bounded natural-language alternative only when it keeps Gherkin readable, for example `/(Web app|Backend service API)/`. Avoid broad regexes that accept unowned language.
### 5. Keep step definitions thin and meaningful
Step definitions are glue between Gherkin and automation, not a second abstraction language.
@@ -41,7 +41,6 @@ Also remember:
- repeated content usually needs scoping to a stable container
- exact text matching is often too brittle when role/name or label already exists
- `getByTestId` is acceptable when semantics are weak but the contract is intentional
- when a real UI region, card, status, or icon lacks an accessible name, prefer adding that semantic contract in product code before falling back to `getByTestId`
### 3. Use web-first assertions
@@ -63,8 +62,6 @@ Avoid:
If a condition genuinely needs custom retry logic, use Playwright's polling/assertion tools deliberately and keep that choice local and explicit.
Use `expect.poll` for non-DOM truth such as API state, backend eventual consistency, generated resources, or captured browser events. For DOM state, use locator assertions so Playwright can apply actionability and web-first retry semantics.
### 4. Let actions wait for actionability
Locator actions already wait for the element to be actionable. Do not preface every click/fill with extra timing logic unless the action needs a specific visible/ready assertion for clarity.
@@ -25,7 +25,6 @@ Before reviewing, read the relevant local contracts:
- `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md` when code uses or changes `@langgenius/dify-ui/*`.
- `web/docs/overlay.md` when reviewing dialogs, drawers, popovers, tooltips, menus, selects, comboboxes, or other floating UI.
- `web/docs/test.md` and the `frontend-testing` skill when reviewing tests or testability.
- `karpathy-guidelines` for scope control and focused, verifiable changes.
- `how-to-write-component` when reviewing React component structure, ownership, effects, query/mutation contracts, or memoization.
For any UI, UX, or accessibility review, fetch the latest Web Interface Guidelines before finalizing findings. Treat them as a required baseline, not the complete source of accessibility truth:
@@ -29,11 +29,10 @@ Prefer:
Flag:
- New CSS modules or ad hoc CSS when Tailwind utilities and Dify tokens cover the need.
- Component-level plain `.css` files or component CSS imported through `globals.css`; use scoped `*.module.css` only when Tailwind and component variants cannot express the style.
- Generic color utilities where Dify semantic tokens exist.
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or documented radius mappings exist.
- `!` important modifiers or important CSS overrides without a narrow, documented reason.
- Manual string concatenation, template strings, array `.join(' ')`, or custom ternaries for conditional or multi-line classes.
- Manual string concatenation for conditional classes.
- JS conditional class branches for primitive visual states already exposed by Dify UI/Base UI `data-*` selectors.
- Incoming `className` placed before default classes in `cn(...)`, preventing call-site overrides.
- Arbitrary z-index or one-off layering fixes on overlays.
@@ -60,9 +59,8 @@ Flag:
Flag:
- User-facing hardcoded strings in `web/`.
- Added or renamed i18n keys that are not present in every supported locale file for the touched namespace.
- Translation namespace drift, especially using unrelated module namespaces for local feature copy.
- Generic button labels like `Continue` where the action is specific.
- Error messages that state only the failure and not the next step.
Use feature-local translation keys by default. Alias only when crossing namespaces. `pnpm i18n:check --file <name>` should pass for any touched translation namespace.
Use feature-local translation keys by default. Alias only when crossing namespaces.
@@ -18,7 +18,6 @@ Accept repeated TanStack Query calls in siblings when each component independent
Flag:
- React component files over 300 lines when the file mixes multiple responsibilities that can be split into focused colocated components, hooks, or utilities.
- Shallow wrappers that only rename props or hide the real primitive.
- Extra DOM wrappers that do not provide layout, semantics, accessibility, state ownership, or library integration.
- Dialog/dropdown/popover hidden surfaces that obscure the parent flow when they should be extracted into a small local component.
@@ -30,7 +29,6 @@ Prefer colocated components split by actual data and state needs.
Flag:
- Refactors of existing navigation, sidebar, dropdown, webapp list, or app-switching UI that do not preserve behavior-sensitive interactions such as expand/collapse arrows, hover persistence, pin/delete controls, routing, keyboard/focus handling, or open-state ownership.
- Components that mix data fetching, mutation side effects, popup state, form validation, layout, and row rendering without a clear owner.
- Generic components with many boolean props that encode one feature's workflow.
- A shared component that imports feature-specific copy, routes, or API contracts.
@@ -40,8 +38,6 @@ Flag:
- A component that exposes controlled props but still keeps a competing private state for the same value.
- A component that cannot render empty, loading, or missing optional API fields without caller-side preprocessing.
When existing components already own interaction logic, prefer reusing or extending them. If a refactor is necessary, preserve the old interaction contract and add or update focused tests for changed behavior.
## Props And Types
Flag:
@@ -40,13 +40,9 @@ Flag:
- JavaScript conditional class logic for visual states that the Dify UI/Base UI primitive already exposes through `data-*` attributes or CSS variables.
- Controlled props added when uncontrolled DOM state or CSS variables would be enough.
- Thin wrappers that rename Base UI parts without adding semantics.
- Generic Base UI selection primitives wrapped without preserving their value generics, such as `Select.Root<Value, Multiple>`, `RadioGroup<Value>`, or `Radio.Root<Value>`.
- Shared select/radio option components that type selected values as `string` while callers pass enums, unions, booleans, numbers, objects, or nullable placeholder values.
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
For non-string `Select` and `RadioGroup` values, prefer explicit domain generics at the root and at child value carriers. JSX children do not inherit the parent generic, so `RadioGroup<PromptMode>` should compose with `Radio<PromptMode>`, `RadioRoot<PromptMode>`, or option values from a typed collection. For `Select`, prefer the Base UI `items` collection pattern for typed value-to-label rendering, and flag string coercion helpers used only to recover display labels.
## Forms
Flag:
@@ -95,7 +91,6 @@ Use:
Flag:
- Manually recreating UI behavior or chrome already owned by `@langgenius/dify-ui/*` or `web/app/components/base/*`, such as buttons, inputs, toggle groups, popovers, dropdown menus, alert dialogs, switches, avatars, scroll areas, toasts, borders, focus states, disabled states, segmented controls, or existing feature components.
- Styling a raw Base UI primitive directly in `web/` when a Dify UI primitive exists.
- Wrapping a Dify UI primitive in a feature component that hides its label, error, disabled, or focus contract.
- Replacing a semantic primitive with a generic `div` plus classes to match a screenshot.
@@ -126,13 +121,3 @@ Use `!` only for a tightly scoped compatibility override after confirming the pr
## Focus Details
Flag focus rings attached to the wrong element. For example, Base UI `Slider.Thumb` focuses an internal `input[type=range]`, so the visible thumb wrapper needs `has-[:focus-visible]` rather than direct wrapper `focus-visible`.
## Custom SVG Icons
Flag:
- New generated React icon components or JSON files under `web/app/components/base/icons/src/...` for custom SVG icons.
- Custom SVG icons consumed outside the Tailwind `i-custom-*` icon class pipeline.
- Generated `packages/iconify-collections/custom-*/icons.json` diffs where unrelated existing icons lost or changed intrinsic `width` or `height`.
New custom SVG icons belong in `packages/iconify-collections/assets/...`. Regenerate with `pnpm --filter @dify/iconify-collections generate`, validate with `pnpm --filter @dify/iconify-collections check:dimensions`, and consume the generated icon with Tailwind `i-custom-*` classes.
+5 -6
View File
@@ -102,11 +102,11 @@ describe('ComponentName', () => {
})
})
// Props tests (REQUIRED when props change observable behavior)
// Props tests (REQUIRED)
describe('Props', () => {
it('should disable the action when disabled', () => {
render(<Component disabled />)
expect(screen.getByRole('button')).toBeDisabled()
it('should apply custom className', () => {
render(<Component className="custom" />)
expect(screen.getByRole('button')).toHaveClass('custom')
})
})
@@ -220,7 +220,6 @@ Every test should clearly separate:
### 2. Black-Box Testing
- Test observable behavior, not implementation details
- Test product contracts, not cosmetic implementation. Do not add or expand unit tests only to lock pure style classes, spacing, colors, backgrounds, or layout micro-adjustments. Cover visual-only fixes with browser/manual verification, screenshots, or E2E/visual checks when risk justifies it. Add unit tests only when the change affects user-observable behavior, accessibility semantics, state, data flow, routing, or a stable component API contract.
- Use semantic queries (`getByRole` with accessible `name`, `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)`)
- Treat `getByTestId` as a last resort. If a control cannot be found by role/name, label, landmark, or dialog scope, fix the component accessibility first instead of adding or relying on `data-testid`.
- Remove production `data-testid` attributes when semantic selectors can cover the behavior. Keep them only for non-visual mocked boundaries, editor/browser shims such as Monaco, canvas/chart output, or third-party widgets with no accessible DOM in the test environment.
@@ -274,7 +273,7 @@ it('should disable input when isReadOnly is true')
### Always Required (All Components)
1. **Rendering**: Component renders without crashing
1. **Props**: Required props, optional props, default values that change observable behavior. Do not test pass-through styling props such as `className` unless they are an explicit, stable component API whose absence would break a real integration contract.
1. **Props**: Required props, optional props, default values
1. **Edge Cases**: null, undefined, empty values, boundary conditions
### Conditional (When Present)
+41 -98
View File
@@ -1,128 +1,71 @@
---
name: how-to-write-component
description: Use when writing, refactoring, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
---
# How To Write A Component
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the same feature branch.
## First Decisions
| Question | Default | Promote or extract only when |
| --- | --- | --- |
| Where should code live? | Keep it local to the feature workflow, route, or owner. | Multiple verticals need the same stable primitive. |
| How should route/tab folders be named? | Match the current route segment, tab name, or user-visible surface. | Keep a historical or broader parent only when it still owns multiple surfaces. |
| Who owns state, data, and handlers? | The lowest component that uses them. | A parent coordinates shared loading, errors, empty UI, selection, submission, navigation, or one consistent snapshot. |
| Should this become Jotai state? | Keep synchronous UI/form state in component or DOM state. | Siblings need one source of truth, the value drives atoms, or scoped workflow state must survive hidden/unmounted steps. |
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. |
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
Use this as the decision guide for React/TypeScript component structure. Existing code is reference material, not automatic precedent; when it conflicts with these rules, adapt the approach instead of reproducing the violation.
## Core Defaults
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
- Module README whitelist: `@/service/client`, `@/next/*`.
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
## Layout And Ownership
## Ownership
- State-heavy wizards, drawers, modals, and secondary workflows can be a small feature surface: an entry file, one feature-local state file when Jotai is actually needed, and shallow `ui/` owners that match real visual regions.
- The entry file handles route integration, provider wiring, close behavior, and surface mounting. The composition owner handles high-level workflow branching. The closest visual owner handles section branching.
- When a page or tab maps to a route segment, name its feature folder after that route/tab surface instead of a stale parent grouping. Remove misleading intermediate folders when only one surface remains.
- When a tab folder grows into several independent sections or action areas, split the first level by product/visual owners. Keep the root for the entry component and cross-owner state, colocate tests with the owner folder, and put truly shared local UI under a specifically named `components/` file.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data; TanStack Query deduplicates and shares cache.
- Pass stable domain identity across boundaries. Do not forward derived presentation state when the receiver can derive it from its own data source.
- A component that owns a visual surface should also own data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in Query and API flow.
- Do not replace prop drilling with one large view-model hook threaded through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child, menu, or row own the action.
## Feature-Scoped Jotai
- A Jotai-backed feature has one feature-local state file for shared primitive atoms, query atoms, derived atoms, write-only actions, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Keep component-owned synchronous UI state local even inside Jotai features: dialog open flags, menus/popovers, confirmations, field drafts, and selected local options usually belong in component state.
- Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
- Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit.
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
- For scoped primitives that are always hydrated by `ScopeProvider`, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type.
- Order state files by dependency graph: types/constants, primitives, query atoms, query-data derived atoms, business/readiness derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Name derived atoms as business facts and write atoms as user or workflow commands. Components should read or write the exact atom they need with `useAtomValue` or `useSetAtom`.
- Menu/dialog `open` state usually stays local, but a scoped atom is acceptable when a composed menu plus secondary surface would otherwise pass confusing `open`/`onClose` props through unrelated layers. Scope that primitive with the surface instance so reset behavior stays local.
- Keep independent dialog lifecycles separate. Avoid one discriminated "current action dialog" atom when dialogs have separate open state, loading guards, or reset behavior.
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
## Components, Props, And Types
- Type component signatures directly; do not use `FC` or `React.FC`.
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Preserve domain value types for selection components. Do not widen enum, union, boolean, numeric, object, or nullable select/radio values to `string`; keep wrappers and option value carriers typed from their feature option collection.
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
## Generated API And Nullable Data
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
- Do not hand-write DTO mirrors, widen generated fields/enums, or add parallel frontend enum/status layers unless they model product state not represented by the API.
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Presentation-only tone maps should be keyed by generated enums.
- Normalize or coerce only at real boundaries: user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters.
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `null` or `undefined` until the final boundary requiring a string.
- Do not use `value || undefined` for mutation fields where `''` means "clear this value". Trim or normalize at the form boundary, then preserve intentional empty strings.
- Prefer nullable-tolerant render props for API-returned rows. Narrow only where a real value is required, such as mutation params, route hrefs, select values, query input, or required React keys.
- Build required values in the same branch that proves them, using `flatMap`, a local loop, or an early return. Avoid truthiness guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after filters.
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by narrowing filters.
- Empty collection fallbacks are for not-yet-loaded query data or genuinely nullable collections at the owning render boundary, not for hiding required API fields.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
## Queries And Mutations
- Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape.
- Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- If a generated query input comes from an atom, including a route-identity bridge atom, keep the query in `atomWithQuery`; do not unwrap the atom in a component just to call `useQuery`.
- Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms.
- In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object.
- For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`.
- When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options.
- For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`.
- Do not extract generated `queryOptions(...)` into a helper solely to share input construction; extract only when prefetch/render must share exact options or the helper owns real domain behavior.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally.
- For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache.
- Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape.
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
- Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid.
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
- Do not use deprecated `useInvalid` or `useReset`.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
## Boundaries And Overlays
## Component Boundaries
- Use the first level below a page or tab to organize independent page sections when it adds structure or the root folder becomes noisy. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component when hidden content would obscure the parent.
- Preserve composability by separating behavior ownership from placement ownership: an action can own trigger/open/menu content while the caller owns slots, offsets, and alignment.
- When a dialog, dropdown, or popover accepts controlled `open`, mount it unconditionally unless unmounting is required for performance or reset semantics. Use keyed scope or local state reset instead of `{open && <Surface />}` wrappers.
- When opening a dialog from a menu item, keep the menu and dialog as sibling surfaces. Let the menu command open the dialog, and mount the dialog outside menu popup content.
- For dialogs and alert dialogs, keep the root responsible for `open` wiring and put query/mutation hooks inside the content component when work should mount only after the overlay opens.
- Prefer uncontrolled overlay roots when the library can own open state. Use `onOpenChange` for side effects and CSS/data selectors for open-state styling.
- Avoid wrapper DOM unless it provides layout, semantics, accessibility, state ownership, or library integration. Avoid shallow wrappers, hook-to-props adapters, layout-only render props, children pass-through wrappers, and prop renaming unless they add real behavior or a real boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
## Effects, Navigation, And Performance
## You Might Not Need An Effect
- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration.
- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive.
- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known.
- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render.
- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary.
- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components.
- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow.
## Navigation And Performance
- Use Effects only to synchronize with external systems. Do not use Effects to transform props/state for rendering, handle user actions, copy state, reset state from props, or fetch data.
- For forms initialized from query data, prefer keyed remounts or surface-entry atom hydration over Effects that copy query data into form state.
- Prefer framework data APIs or TanStack Query for data fetching.
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
- Before using `memo`, move changing state down to the smallest component that uses it. If state must wrap stable content, lift the stable content up and pass it as `children`.
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/karpathy-guidelines
+2 -5
View File
@@ -23,11 +23,8 @@
/docs/ @crazywoola
# CLI
/cli/ @GareArc
/.github/workflows/cli-tests.yml @GareArc
# E2E
/e2e/ @lyzno1
/cli/ @langgenius/maintainers
/.github/workflows/cli-tests.yml @langgenius/maintainers
# Backend (default owner, more specific rules below will override)
/api/ @QuantumGhost
-6
View File
@@ -7,9 +7,3 @@ web:
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.nvmrc'
e2e:
- changed-files:
- any-glob-to-any-file:
- 'e2e/**'
- '.github/workflows/web-e2e.yml'
+7 -7
View File
@@ -29,13 +29,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -91,13 +91,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -142,13 +142,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
@@ -195,7 +195,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
files: ./coverage.xml
disable_search: true
+4 -16
View File
@@ -20,7 +20,7 @@ jobs:
run: echo "autofix.ci updates pull request branches, not merge group refs."
- if: github.event_name != 'merge_group'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check Docker Compose inputs
if: github.event_name != 'merge_group'
@@ -51,18 +51,6 @@ jobs:
with:
files: |
api/**
- name: Check frontend contract inputs
if: github.event_name != 'merge_group'
id: frontend-contract-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
api/**
packages/contracts/openapi-ts.api.config.ts
packages/contracts/package.json
packages/contracts/openapi/**
pnpm-lock.yaml
pnpm-workspace.yaml
- name: Check dify-agent inputs
if: github.event_name != 'merge_group'
id: dify-agent-changes
@@ -78,7 +66,7 @@ jobs:
python-version: "3.11"
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
@@ -155,8 +143,8 @@ jobs:
uv run dev/generate_swagger_markdown_docs.py --swagger-dir ../packages/contracts/openapi --markdown-dir openapi/markdown --keep-swagger-json
- name: Generate frontend contracts
if: github.event_name != 'merge_group' && steps.frontend-contract-changes.outputs.any_changed == 'true'
run: pnpm --dir packages/contracts gen-api-contract
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: pnpm --dir packages/contracts gen-api-contract-from-openapi
- name: ESLint autofix
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
+8 -49
View File
@@ -8,6 +8,7 @@ on:
- "build/**"
- "release/e-*"
- "hotfix/**"
- "feat/hitl-backend"
tags:
- "*"
@@ -20,8 +21,6 @@ env:
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DIFY_WEB_IMAGE_NAME: ${{ vars.DIFY_WEB_IMAGE_NAME || 'langgenius/dify-web' }}
DIFY_API_IMAGE_NAME: ${{ vars.DIFY_API_IMAGE_NAME || 'langgenius/dify-api' }}
DIFY_AGENT_IMAGE_NAME: ${{ vars.DIFY_AGENT_IMAGE_NAME || 'langgenius/dify-agent-backend' }}
DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME: ${{ vars.DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME || 'langgenius/dify-agent-local-sandbox' }}
jobs:
build:
@@ -61,34 +60,6 @@ jobs:
file: "web/Dockerfile"
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-agent-amd64"
image_name_env: "DIFY_AGENT_IMAGE_NAME"
artifact_context: "agent"
build_context: "{{defaultContext}}"
file: "dify-agent/Dockerfile"
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-agent-arm64"
image_name_env: "DIFY_AGENT_IMAGE_NAME"
artifact_context: "agent"
build_context: "{{defaultContext}}"
file: "dify-agent/Dockerfile"
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-agent-local-sandbox-amd64"
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
artifact_context: "local-sandbox"
build_context: "{{defaultContext}}:dify-agent"
file: "docker/local-sandbox/Dockerfile"
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-agent-local-sandbox-arm64"
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
artifact_context: "local-sandbox"
build_context: "{{defaultContext}}:dify-agent"
file: "docker/local-sandbox/Dockerfile"
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
steps:
- name: Prepare
@@ -97,7 +68,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
@@ -107,13 +78,13 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env[matrix.image_name_env] }}
- name: Build Docker image
id: build
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ${{ matrix.build_context }}
@@ -151,18 +122,12 @@ jobs:
- service_name: "validate-web-amd64"
build_context: "{{defaultContext}}"
file: "web/Dockerfile"
- service_name: "validate-agent-amd64"
build_context: "{{defaultContext}}"
file: "dify-agent/Dockerfile"
- service_name: "validate-agent-local-sandbox-amd64"
build_context: "{{defaultContext}}:dify-agent"
file: "docker/local-sandbox/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Validate Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.build_context }}
@@ -182,12 +147,6 @@ jobs:
- service_name: "merge-web-images"
image_name_env: "DIFY_WEB_IMAGE_NAME"
context: "web"
- service_name: "merge-agent-images"
image_name_env: "DIFY_AGENT_IMAGE_NAME"
context: "agent"
- service_name: "merge-agent-local-sandbox-images"
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
context: "local-sandbox"
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -197,14 +156,14 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env[matrix.image_name_env] }}
tags: |
-415
View File
@@ -1,415 +0,0 @@
name: CLI E2E Tests
on:
workflow_dispatch:
inputs:
cli_ref:
description: "Git ref (default: current branch)"
type: string
required: false
edition:
description: "Dify edition"
type: choice
required: false
default: ee
options: [ee, ce]
test_scope:
description: "smoke = [P0] only / full = all cases"
type: choice
required: false
default: full
options: [smoke, full]
# ── Suite on/off ────────────────────────────────────────────────────────
suite_framework_output_error:
description: "framework + output + error-handling suites"
type: boolean
default: true
suite_discovery:
description: "discovery suite (get app / describe app)"
type: boolean
default: true
suite_run:
description: "run suite (basic / streaming / conversation / file / hitl)"
type: boolean
default: true
suite_auth:
description: "auth suite (login / status / whoami / use / devices / logout)"
type: boolean
default: true
suite_agent:
description: "agent suite"
type: boolean
default: true
permissions:
contents: read
# ── Shared env injected into every E2E job ───────────────────────────────────
# Each job reads DIFY_E2E_TOKEN + app IDs from the provision job outputs,
# so global-setup skips minting and finds existing apps in < 10 s.
env:
DIFY_E2E_NO_KEYRING: "1" # Linux CI has no keychain; skip probe
VITEST_RETRY: "2" # Retry flaky staging responses
jobs:
# ════════════════════════════════════════════════════════════════════════════
# 0. PROVISION — mint token + import DSL fixtures (runs once, outputs IDs)
# ════════════════════════════════════════════════════════════════════════════
provision:
name: "Provision: mint token + DSL apps"
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
token: ${{ steps.out.outputs.DIFY_E2E_TOKEN }}
workspace_id: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_ID }}
workspace_name: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_NAME }}
ws2_id: ${{ steps.out.outputs.DIFY_E2E_WS2_ID }}
chat_app_id: ${{ steps.out.outputs.DIFY_E2E_CHAT_APP_ID }}
workflow_app_id: ${{ steps.out.outputs.DIFY_E2E_WORKFLOW_APP_ID }}
file_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_APP_ID }}
file_chat_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_CHAT_APP_ID }}
hitl_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_APP_ID }}
hitl_external_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_EXTERNAL_APP_ID }}
hitl_single_action_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID }}
hitl_multi_node_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_MULTI_NODE_APP_ID }}
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
package_json_field: packageManager
run_install: false
- name: Install CLI dependencies
working-directory: cli
run: pnpm install --frozen-lockfile
- name: Mint token & provision apps
id: out
working-directory: cli
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
run: bun scripts/e2e-provision.ts
# ════════════════════════════════════════════════════════════════════════════
# 1-B. framework + output + error-handling (parallel with run/discovery)
# ════════════════════════════════════════════════════════════════════════════
suite-framework-output-error:
name: "Suite: framework + output + error-handling"
if: ${{ inputs.suite_framework_output_error != 'false' }}
needs: provision
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: cli
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
- uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: { package_json_field: packageManager, run_install: false }
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
- name: Run framework + output + error-handling
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
DIFY_E2E_INCLUDE: "test/e2e/suites/framework/**/*.e2e.ts,test/e2e/suites/output/**/*.e2e.ts,test/e2e/suites/error-handling/**/*.e2e.ts"
run: |
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
pnpm test:e2e -- -t "\[P0\]"
else
pnpm test:e2e
fi
# ════════════════════════════════════════════════════════════════════════════
# 1-C. Discovery (parallel)
# ════════════════════════════════════════════════════════════════════════════
suite-discovery:
name: "Suite: discovery"
if: ${{ inputs.suite_discovery != 'false' }}
needs: provision
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: cli
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
- uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: { package_json_field: packageManager, run_install: false }
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
- name: Run discovery suite
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
DIFY_E2E_INCLUDE: "test/e2e/suites/discovery/**/*.e2e.ts"
run: |
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
pnpm test:e2e -- -t "\[P0\]"
else
pnpm test:e2e
fi
# ════════════════════════════════════════════════════════════════════════════
# 1-D. Run suite — 5 files in matrix (parallel)
# ════════════════════════════════════════════════════════════════════════════
suite-run:
name: "Suite: run / ${{ matrix.name }}"
if: ${{ inputs.suite_run != 'false' }}
needs: provision
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- name: basic
file: run-app-basic.e2e.ts
- name: streaming
file: run-app-streaming.e2e.ts
- name: conversation
file: run-app-conversation.e2e.ts
- name: file
file: run-app-file.e2e.ts
- name: hitl
file: run-app-hitl.e2e.ts
defaults:
run:
working-directory: cli
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
- uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: { package_json_field: packageManager, run_install: false }
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
- name: "Run run/${{ matrix.name }}"
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_SSO_TOKEN: ${{ secrets.DIFY_E2E_SSO_TOKEN }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
DIFY_E2E_FILE_APP_ID: ${{ needs.provision.outputs.file_app_id }}
DIFY_E2E_FILE_CHAT_APP_ID: ${{ needs.provision.outputs.file_chat_app_id }}
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID: ${{ needs.provision.outputs.hitl_single_action_app_id }}
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
DIFY_E2E_INCLUDE: "test/e2e/suites/run/${{ matrix.file }}"
run: |
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
pnpm test:e2e -- -t "\[P0\]"
else
pnpm test:e2e
fi
- name: Upload results on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
path: cli/test-results/
retention-days: 3
# ════════════════════════════════════════════════════════════════════════════
# 1-E. auth/login + status + whoami (parallel, read-only, safe)
# ════════════════════════════════════════════════════════════════════════════
suite-auth-safe:
name: "Suite: auth (login / status / whoami)"
if: ${{ inputs.suite_auth != 'false' }}
needs: provision
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: cli
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
- uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: { package_json_field: packageManager, run_install: false }
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
- name: Run auth/login + status + whoami
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
DIFY_E2E_INCLUDE: "test/e2e/suites/auth/login.e2e.ts,test/e2e/suites/auth/status.e2e.ts,test/e2e/suites/auth/whoami.e2e.ts"
run: |
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
pnpm test:e2e -- -t "\[P0\]"
else
pnpm test:e2e
fi
# ════════════════════════════════════════════════════════════════════════════
# 2. DESTRUCTIVE — auth/use + devices + logout + agent (serial, runs LAST)
# Must wait for ALL parallel suites to finish to avoid token revocation
# invalidating other in-flight requests.
# ════════════════════════════════════════════════════════════════════════════
suite-last:
name: "Suite: auth-use + devices + logout + agent (last, serial)"
# Runs when auth is selected; also runs after all parallel jobs finish
if: ${{ inputs.suite_auth != 'false' || inputs.suite_agent != 'false' }}
needs:
- provision
- suite-framework-output-error
- suite-discovery
- suite-run
- suite-auth-safe
# `needs` on a skipped job is treated as success — safe to proceed even if
# some suites were disabled via toggle.
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: cli
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
- uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: { package_json_field: packageManager, run_install: false }
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
- name: Run use / devices / logout / agent (serial)
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID: ${{ needs.provision.outputs.hitl_single_action_app_id }}
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
run: |
# Collect files in safe order: use → devices → logout (revokes last) → agent
FILES=()
if [ "${{ inputs.suite_auth }}" = "true" ]; then
FILES+=(
test/e2e/suites/auth/use.e2e.ts
test/e2e/suites/auth/devices.e2e.ts
test/e2e/suites/auth/logout.e2e.ts
)
fi
if [ "${{ inputs.suite_agent }}" = "true" ]; then
while IFS= read -r f; do FILES+=("$f"); done \
< <(find test/e2e/suites/agent -name '*.e2e.ts' | sort)
fi
[ ${#FILES[@]} -eq 0 ] && { echo "Nothing to run."; exit 0; }
# Pass files via DIFY_E2E_INCLUDE (comma-separated) so vitest
# config's include list is overridden instead of ANDed.
INCLUDE=$(IFS=,; echo "${FILES[*]}")
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
DIFY_E2E_INCLUDE="$INCLUDE" pnpm test:e2e -- -t "\[P0\]"
else
DIFY_E2E_INCLUDE="$INCLUDE" pnpm test:e2e
fi
- name: Upload results on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: e2e-last-${{ github.run_id }}
path: cli/test-results/
retention-days: 3
-74
View File
@@ -1,74 +0,0 @@
name: CLI Edge Publish
on:
push:
branches: [main]
paths:
- 'cli/**'
- 'packages/contracts/generated/api/openapi/**'
workflow_dispatch:
concurrency:
group: difyctl-edge-publish
cancel-in-progress: false
jobs:
publish:
name: build + publish edge to R2
runs-on: ${{ github.repository == 'langgenius/dify' && 'depot-ubuntu-24.04' || 'ubuntu-24.04' }}
if: vars.DIFYCTL_R2_BUCKET != ''
defaults:
run:
shell: bash
working-directory: ./cli
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0
- name: Enable cross-arch native prebuilds
working-directory: ./
run: cat cli/scripts/cross-arch.pnpm.yaml >> pnpm-workspace.yaml
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Setup Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
with:
bun-version-file: cli/.bun-version
- name: Compute edge version
id: ver
run: echo "version=$(node scripts/release-naming.mjs edge-version "$(git rev-parse --short HEAD)")" >> "$GITHUB_OUTPUT"
- name: Compile standalone binaries (all targets, all-or-nothing)
run: |
CLI_VERSION="${{ steps.ver.outputs.version }}" \
DIFYCTL_CHANNEL=edge \
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
pnpm build:bin
- name: Generate sha256 checksums
run: CLI_VERSION="${{ steps.ver.outputs.version }}" scripts/release-write-checksums.sh
- name: Smoke the runner-arch binary
run: ./dist/bin/difyctl-v${{ steps.ver.outputs.version }}-linux-x64 version
- name: Publish to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DIFYCTL_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFYCTL_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED
AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED
DIFYCTL_R2_S3_ENDPOINT: ${{ vars.DIFYCTL_R2_S3_ENDPOINT }}
DIFYCTL_R2_BUCKET: ${{ vars.DIFYCTL_R2_BUCKET }}
DIFYCTL_R2_PUBLIC_BASE: ${{ vars.DIFYCTL_R2_PUBLIC_BASE }}
DIFYCTL_COMMIT: ${{ github.sha }}
run: |
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
scripts/release-r2-publish.sh edge "${{ steps.ver.outputs.version }}"
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -98,7 +98,7 @@ jobs:
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 1
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
shell: bash
steps:
- name: Checkout cli ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
+3 -8
View File
@@ -30,28 +30,23 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Validate release manifest
if: matrix.os == 'depot-ubuntu-24.04'
run: scripts/release-validate-manifest.sh
- name: CI pipeline (typecheck, lint, coverage, build)
run: pnpm run ci
run: pnpm ci
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
directory: cli/coverage
flags: cli
+6 -6
View File
@@ -13,13 +13,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
@@ -40,7 +40,7 @@ jobs:
cp envs/middleware.env.example middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.middleware.yaml
@@ -63,13 +63,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
@@ -94,7 +94,7 @@ jobs:
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.middleware.yaml
@@ -1,13 +1,10 @@
name: Deploy Agent
permissions:
contents: read
name: Deploy HITL
on:
workflow_run:
workflows: ["Build and Push API & Web"]
branches:
- "deploy/agent"
- "build/feat/hitl"
types:
- completed
@@ -16,13 +13,13 @@ jobs:
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/agent'
github.event.workflow_run.head_branch == 'build/feat/hitl'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.AGENT_SSH_HOST }}
host: ${{ secrets.HITL_SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
${{ vars.SSH_SCRIPT_AGENT || secrets.SSH_SCRIPT_AGENT }}
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
+3 -3
View File
@@ -53,7 +53,7 @@ jobs:
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Build Docker Image
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
push: false
@@ -77,10 +77,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build Docker Image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.context }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
+3 -2
View File
@@ -6,6 +6,8 @@ on:
merge_group:
branches: ["main"]
types: [checks_requested]
push:
branches: ["main"]
permissions:
actions: write
@@ -46,7 +48,7 @@ jobs:
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
@@ -322,7 +324,6 @@ jobs:
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
uses: ./.github/workflows/web-e2e.yml
secrets: inherit
web-e2e-skip:
name: Skip Web Full-Stack E2E
-54
View File
@@ -1,54 +0,0 @@
name: Post-Merge Checks
on:
push:
branches: ["main"]
permissions:
contents: read
concurrency:
group: post-merge-${{ github.sha }}
cancel-in-progress: false
jobs:
check-changes:
name: Check Changed Files
runs-on: depot-ubuntu-24.04
outputs:
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
filters: |
external_e2e:
- 'e2e/features/agent-v2/**'
- 'e2e/scripts/**'
- 'e2e/support/**'
- '.github/workflows/post-merge.yml'
- '.github/workflows/web-e2e.yml'
- '.github/actions/setup-web/**'
- 'dify-agent/**'
- 'api/clients/agent_backend/**'
- 'api/core/app/apps/agent_app/**'
- 'api/core/workflow/nodes/agent_v2/**'
- 'api/controllers/console/agent/**'
- 'api/services/agent/**'
- 'api/core/plugin/**'
- 'api/services/plugin/**'
- 'api/core/tools/**'
- 'api/services/tools/**'
- 'web/features/agent-v2/**'
- 'web/app/(commonLayout)/roster/**'
- 'web/app/components/workflow/nodes/agent-v2/**'
external-e2e:
name: External Runtime E2E
needs: check-changes
if: needs.check-changes.outputs.external-e2e-changed == 'true'
uses: ./.github/workflows/web-e2e.yml
with:
run-external-runtime: true
secrets: inherit
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
@@ -21,10 +21,10 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
steps:
- name: Checkout default branch (trusted code)
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
days-before-issue-stale: 15
days-before-issue-close: 3
+5 -30
View File
@@ -19,10 +19,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Check changed files
id: changed-files
@@ -30,13 +29,11 @@ jobs:
with:
files: |
api/**
scripts/check_no_new_getattr.py
scripts/ast_grep_rules/no_new_getattr.yml
.github/workflows/style.yml
- name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: false
python-version: "3.12"
@@ -54,22 +51,8 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: uv run --project api --dev python api/dev/lint_response_contracts.py --fail-on-mismatch
- name: Fetch merge target ref for getattr guard
if: steps.changed-files.outputs.any_changed == 'true'
run: git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main
- name: Bind merge target branch for getattr guard
if: steps.changed-files.outputs.any_changed == 'true'
run: git show-ref --verify --quiet refs/heads/main || git branch main origin/main
- name: Run No New Getattr Guard
if: steps.changed-files.outputs.any_changed == 'true'
run: uv run --project api python scripts/check_no_new_getattr.py --mode ci --merge-target main
- name: Run Type Checks
if: steps.changed-files.outputs.any_changed == 'true'
env:
PYREFLY_OUTPUT_FORMAT: github
run: make type-check-core
- name: Dotenv check
@@ -88,7 +71,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -122,14 +105,6 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip
- name: Web dead code check production
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip:production
- name: Web production unused declarations check
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip:production-unused-check
ts-common-style:
name: TS Common
runs-on: depot-ubuntu-24.04
@@ -139,7 +114,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -196,7 +171,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
working-directory: sdks/nodejs-client
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+4 -51
View File
@@ -2,11 +2,6 @@ name: Web Full-Stack E2E
on:
workflow_call:
inputs:
run-external-runtime:
required: false
type: boolean
default: false
permissions:
contents: read
@@ -25,7 +20,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -33,13 +28,11 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
cache-dependency-glob: |
api/uv.lock
dify-agent/uv.lock
cache-dependency-glob: api/uv.lock
- name: Install API dependencies
run: uv sync --project api --dev
@@ -58,52 +51,12 @@ jobs:
E2E_INIT_PASSWORD: E2eInit12345
run: vp run e2e:full
- name: Run external runtime E2E tests
if: ${{ inputs.run-external-runtime }}
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_AGENT_DECISION_MODEL_NAME: ${{ vars.E2E_AGENT_DECISION_MODEL_NAME || 'gpt-5.5' }}
E2E_AGENT_DECISION_MODEL_PROVIDER: ${{ vars.E2E_AGENT_DECISION_MODEL_PROVIDER || 'openai' }}
E2E_AGENT_DECISION_MODEL_TYPE: ${{ vars.E2E_AGENT_DECISION_MODEL_TYPE || 'llm' }}
E2E_EXTERNAL_RUNTIME_SEED_SPECS: ${{ vars.E2E_EXTERNAL_RUNTIME_SEED_SPECS }}
E2E_EXTERNAL_RUNTIME_TAGS: ${{ vars.E2E_EXTERNAL_RUNTIME_TAGS }}
E2E_FORCE_WEB_BUILD: "1"
E2E_INIT_PASSWORD: E2eInit12345
E2E_MARKETPLACE_API_URL: ${{ vars.E2E_MARKETPLACE_API_URL }}
E2E_MARKETPLACE_PLUGIN_IDS: ${{ vars.E2E_MARKETPLACE_PLUGIN_IDS }}
E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: ${{ vars.E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS }}
E2E_MODEL_PROVIDER_CREDENTIALS_JSON: ${{ secrets.E2E_MODEL_PROVIDER_CREDENTIALS_JSON }}
E2E_START_AGENT_BACKEND: ${{ vars.E2E_START_AGENT_BACKEND || '1' }}
E2E_STABLE_MODEL_NAME: ${{ vars.E2E_STABLE_MODEL_NAME || 'gpt-5-nano' }}
E2E_STABLE_MODEL_PROVIDER: ${{ vars.E2E_STABLE_MODEL_PROVIDER || 'openai' }}
E2E_STABLE_MODEL_TYPE: ${{ vars.E2E_STABLE_MODEL_TYPE || 'llm' }}
run: |
if [[ -z "${E2E_MODEL_PROVIDER_CREDENTIALS_JSON}" ]]; then
echo "E2E_MODEL_PROVIDER_CREDENTIALS_JSON is required for external runtime E2E." >&2
exit 1
fi
if [[ -d cucumber-report ]]; then
rm -rf cucumber-report-non-external
mv cucumber-report cucumber-report-non-external
fi
trap 'vp run e2e:middleware:down' EXIT
vp run e2e:middleware:up
vp run e2e:external:prepare
vp run e2e:external
- name: Upload Cucumber report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report
path: |
e2e/cucumber-report
e2e/cucumber-report-non-external
path: e2e/cucumber-report
retention-days: 7
- name: Upload E2E logs
+6 -29
View File
@@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -83,7 +83,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
directory: web/coverage
flags: web
@@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -113,36 +113,13 @@ jobs:
run: vp exec playwright install --with-deps chromium
- name: Run dify-ui tests
run: vp test run --project unit --coverage --silent=passed-only
run: vp test run --coverage --silent=passed-only
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
directory: packages/dify-ui/coverage
flags: dify-ui
env:
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
dify-ui-storybook-test:
name: dify-ui Storybook Tests
runs-on: depot-ubuntu-24.04-4
defaults:
run:
shell: bash
working-directory: ./packages/dify-ui
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Install Chromium for Browser Mode
run: vp exec playwright install --with-deps chromium
- name: Run dify-ui Storybook tests
run: vp run test:storybook
@@ -1,30 +0,0 @@
# HITL timeout semantics implementation report
## What changed
- Updated `api/core/workflow/nodes/human_input/callback.py` so `DifyHITLCallback` now preserves Dify's timeout split at the boundary:
- `HumanInputFormStatus.TIMEOUT` returns the graphon timeout branch via `Expired(selected_handle="__timeout__", ...)`.
- `HumanInputFormStatus.EXPIRED` is treated as an invalid resume state and raises `AssertionError`.
- `HumanInputFormStatus.WAITING` with a past global deadline is treated as an invalid resume state and raises `AssertionError`.
- `HumanInputFormStatus.WAITING` with only the node-level deadline expired still returns the timeout branch.
- Added `created_at` to `HumanInputFormEntity` and `_HumanInputFormEntityImpl` so the callback can compute the global deadline using Dify's shared `HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS` invariant.
- Kept the submitted and pause flows unchanged.
- Added focused unit coverage in `api/tests/unit_tests/core/workflow/test_human_input_callback.py` for:
- node timeout branch
- global expiration rejection
- waiting-form past node deadline timeout
- waiting-form past global deadline rejection
## Verification
- `uv run --project api pytest -o addopts='' api/tests/unit_tests/core/workflow/test_human_input_callback.py api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py -q`
- `git diff --check`
## Result
- The focused test set is expected to pass with the new `created_at` boundary in place.
- No unrelated files were modified.
## Concerns
- The callback now fails fast on invalid resume states by design. That is intentional, but any caller that previously relied on `EXPIRED` being mapped to the timeout branch will now see an assertion failure instead.
+1 -1
View File
@@ -157,7 +157,7 @@ build-web:
build-api:
@echo "Building API Docker image: $(API_IMAGE):$(VERSION)..."
docker build -t $(API_IMAGE):$(VERSION) -f api/Dockerfile .
docker build -t $(API_IMAGE):$(VERSION) ./api
@echo "API Docker image built successfully: $(API_IMAGE):$(VERSION)"
# Push Docker images
+1 -1
View File
@@ -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 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.
- **Self-hosting Dify Community Edition<br/>**
Quickly get Dify running in your environment with this [starter guide](#quick-start).
+1 -11
View File
@@ -36,9 +36,6 @@ FILES_ACCESS_TIMEOUT=300
# Collaboration mode toggle
ENABLE_COLLABORATION_MODE=true
# Learn app feature toggle
ENABLE_LEARN_APP=true
# Access token expiration time in minutes
ACCESS_TOKEN_EXPIRE_MINUTES=60
@@ -771,16 +768,9 @@ EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while use redis as event bus.
# It's highly recommended to enable this for large deployments.
EVENT_BUS_REDIS_USE_CLUSTERS=false
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
# Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
# Human input timeout check interval in minutes
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
# Nacos remote settings source HTTP timeouts (seconds).
# Bound how long requests to the Nacos endpoint wait before failing, so a slow or
# unresponsive Nacos server cannot stall API startup or token refresh.
# Read timeout for Nacos requests (default: 10.0)
DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0
# Connect timeout for Nacos requests (default: 3.0)
DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0
+13 -25
View File
@@ -8,30 +8,18 @@
!dify-agent/src/
!dify-agent/src/**
# Environment configuration and example
.env
*.env.*
# Python related files
api/.venv
api/.venv/**
api/.env
api/*.env.*
api/.idea
api/.mypy_cache
api/.ruff_cache
api/storage/generate_files/*
api/storage/privkeys/*
api/storage/tools/*
api/storage/upload_files/*
api/logs
api/*.log*
**/__pycache__
**/*.pyc
**/.venv/
**/.mypy_cache/
**/.ruff_cache/
**/.import_linter_cache/
**/.pytest_cache/
**/.hypothesis/
# Upload files and logs
api/storage/**
api/logs/
api/*.log*
# Tests
api/tests
# Editor configuration
**/.vscode/
**/.idea/
+1 -1
View File
@@ -55,7 +55,7 @@ else:
if __name__ == "__main__":
from gevent import pywsgi
from geventwebsocket.handler import WebSocketHandler
from geventwebsocket.handler import WebSocketHandler # type: ignore[reportMissingTypeStubs]
log_startup_banner(HOST, PORT)
server = pywsgi.WSGIServer((HOST, PORT), socketio_app, handler_class=WebSocketHandler)
+1 -1
View File
@@ -1,7 +1,7 @@
import logging
import time
import socketio
import socketio # type: ignore[reportMissingTypeStubs]
from flask import request
from opentelemetry.trace import get_current_span
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
+2 -2
View File
@@ -1,5 +1,5 @@
import psycogreen.gevent as pscycogreen_gevent
from grpc.experimental import gevent as grpc_gevent
import psycogreen.gevent as pscycogreen_gevent # type: ignore
from grpc.experimental import gevent as grpc_gevent # type: ignore
# grpc gevent
grpc_gevent.init_gevent()
+6 -12
View File
@@ -5,8 +5,6 @@ API adapters: request building from Dify product concepts, a thin client wrapper
event adaptation for future workflow integration, and deterministic fakes.
"""
from dify_agent.protocol import RuntimeLayerSpec, extract_runtime_layer_specs
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
from clients.agent_backend.errors import (
AgentBackendError,
@@ -18,12 +16,12 @@ from clients.agent_backend.errors import (
AgentBackendValidationError,
)
from clients.agent_backend.event_adapter import (
AgentBackendDeferredToolCallInternalEvent,
AgentBackendInternalEvent,
AgentBackendInternalEventType,
AgentBackendRunCancelledInternalEvent,
AgentBackendRunEventAdapter,
AgentBackendRunFailedInternalEvent,
AgentBackendRunPausedInternalEvent,
AgentBackendRunStartedInternalEvent,
AgentBackendRunSucceededInternalEvent,
AgentBackendStreamInternalEvent,
@@ -32,10 +30,7 @@ from clients.agent_backend.factory import create_agent_backend_run_client
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
from clients.agent_backend.request_builder import (
AGENT_SOUL_PROMPT_LAYER_ID,
DIFY_CONFIG_LAYER_ID,
DIFY_CORE_TOOLS_LAYER_ID,
DIFY_EXECUTION_CONTEXT_LAYER_ID,
DIFY_KNOWLEDGE_BASE_LAYER_ID,
DIFY_PLUGIN_TOOLS_LAYER_ID,
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
WORKFLOW_USER_PROMPT_LAYER_ID,
@@ -44,20 +39,18 @@ from clients.agent_backend.request_builder import (
AgentBackendOutputConfig,
AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput,
CleanupLayerSpec,
extract_cleanup_layer_specs,
redact_for_agent_backend_log,
)
__all__ = [
"AGENT_SOUL_PROMPT_LAYER_ID",
"DIFY_CONFIG_LAYER_ID",
"DIFY_CORE_TOOLS_LAYER_ID",
"DIFY_EXECUTION_CONTEXT_LAYER_ID",
"DIFY_KNOWLEDGE_BASE_LAYER_ID",
"DIFY_PLUGIN_TOOLS_LAYER_ID",
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
"WORKFLOW_USER_PROMPT_LAYER_ID",
"AgentBackendAgentAppRunInput",
"AgentBackendDeferredToolCallInternalEvent",
"AgentBackendError",
"AgentBackendHTTPError",
"AgentBackendInternalEvent",
@@ -70,6 +63,7 @@ __all__ = [
"AgentBackendRunEventAdapter",
"AgentBackendRunFailedError",
"AgentBackendRunFailedInternalEvent",
"AgentBackendRunPausedInternalEvent",
"AgentBackendRunRequestBuilder",
"AgentBackendRunStartedInternalEvent",
"AgentBackendRunSucceededInternalEvent",
@@ -78,11 +72,11 @@ __all__ = [
"AgentBackendTransportError",
"AgentBackendValidationError",
"AgentBackendWorkflowNodeRunInput",
"CleanupLayerSpec",
"DifyAgentBackendRunClient",
"FakeAgentBackendRunClient",
"FakeAgentBackendScenario",
"RuntimeLayerSpec",
"create_agent_backend_run_client",
"extract_runtime_layer_specs",
"extract_cleanup_layer_specs",
"redact_for_agent_backend_log",
]
+19 -52
View File
@@ -2,9 +2,7 @@
The adapter does not define a new cross-service event contract. It consumes
``dify_agent.protocol.RunEvent`` and produces small API-internal models that the
workflow Agent Node maps to Graphon/AppQueue events. Deferred external tool calls
remain Dify Agent ``run_succeeded`` payloads on the wire; API code turns them
into an internal event so workflow pause/session handling stays local to API.
future workflow Agent Node can map to Graphon/AppQueue events in phase 3.
"""
from __future__ import annotations
@@ -14,11 +12,11 @@ from typing import Annotated, Literal, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import (
DeferredToolCallPayload,
PydanticAIStreamRunEvent,
RunCancelledEvent,
RunEvent,
RunFailedEvent,
RunPausedEvent,
RunStartedEvent,
RunSucceededEvent,
)
@@ -32,7 +30,7 @@ class AgentBackendInternalEventType(StrEnum):
RUN_STARTED = "run_started"
STREAM_EVENT = "stream_event"
DEFERRED_TOOL_CALL = "deferred_tool_call"
RUN_PAUSED = "run_paused"
RUN_SUCCEEDED = "run_succeeded"
RUN_FAILED = "run_failed"
RUN_CANCELLED = "run_cancelled"
@@ -67,17 +65,15 @@ class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
type: Literal[AgentBackendInternalEventType.RUN_SUCCEEDED] = AgentBackendInternalEventType.RUN_SUCCEEDED
output: JsonValue
session_snapshot: CompositorSessionSnapshot
usage: dict[str, JsonValue] | None = None
class AgentBackendDeferredToolCallInternalEvent(AgentBackendInternalEventBase):
"""API-internal representation of a Dify Agent deferred external tool call."""
class AgentBackendRunPausedInternalEvent(AgentBackendInternalEventBase):
"""API-internal resumable pause event for human handoff and Babysit flows."""
type: Literal[AgentBackendInternalEventType.DEFERRED_TOOL_CALL] = AgentBackendInternalEventType.DEFERRED_TOOL_CALL
deferred_tool_call: DeferredToolCallPayload
type: Literal[AgentBackendInternalEventType.RUN_PAUSED] = AgentBackendInternalEventType.RUN_PAUSED
reason: str
message: str | None = None
session_snapshot: CompositorSessionSnapshot
usage: dict[str, JsonValue] | None = None
session_snapshot: CompositorSessionSnapshot | None = None
class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
@@ -99,7 +95,7 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
type AgentBackendInternalEvent = Annotated[
AgentBackendRunStartedInternalEvent
| AgentBackendStreamInternalEvent
| AgentBackendDeferredToolCallInternalEvent
| AgentBackendRunPausedInternalEvent
| AgentBackendRunSucceededInternalEvent
| AgentBackendRunFailedInternalEvent
| AgentBackendRunCancelledInternalEvent,
@@ -132,26 +128,22 @@ class AgentBackendRunEventAdapter:
)
]
case RunSucceededEvent():
if "deferred_tool_call" in event.data.model_fields_set:
if event.data.deferred_tool_call is None:
raise TypeError("run_succeeded deferred_tool_call branch is missing payload")
return [
AgentBackendDeferredToolCallInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
deferred_tool_call=event.data.deferred_tool_call,
message=_deferred_tool_call_message(event.data.deferred_tool_call),
session_snapshot=event.data.session_snapshot,
usage=_agent_run_usage(event.data.usage),
)
]
return [
AgentBackendRunSucceededInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
output=event.data.output,
session_snapshot=event.data.session_snapshot,
usage=_agent_run_usage(event.data.usage),
)
]
case RunPausedEvent():
return [
AgentBackendRunPausedInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
reason=event.data.reason,
message=event.data.message,
session_snapshot=event.data.session_snapshot,
)
]
case RunFailedEvent():
@@ -173,28 +165,3 @@ class AgentBackendRunEventAdapter:
)
]
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")
def _deferred_tool_call_message(payload: DeferredToolCallPayload) -> str:
"""Return a concise workflow pause message from deferred-tool arguments."""
args = payload.args
if isinstance(args, dict):
question = args.get("question")
if isinstance(question, str) and question.strip():
return question
title = args.get("title")
if isinstance(title, str) and title.strip():
return title
return f"Agent backend requested external input via deferred tool '{payload.tool_name}'."
def _agent_run_usage(usage: object | None) -> dict[str, JsonValue] | None:
"""Return JSON-safe usage metadata from optional Agent backend usage."""
if usage is None:
return None
dumped = _EVENT_DATA_ADAPTER.dump_python(usage, mode="json")
if not isinstance(dumped, dict):
return None
return cast(dict[str, JsonValue], dumped)
+8 -15
View File
@@ -17,10 +17,11 @@ from dify_agent.protocol import (
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
DeferredToolCallPayload,
RunEvent,
RunFailedEvent,
RunFailedEventData,
RunPausedEvent,
RunPausedEventData,
RunStartedEvent,
RunStatusResponse,
RunSucceededEvent,
@@ -31,11 +32,7 @@ _FIXED_TIME = datetime(2026, 1, 1, tzinfo=UTC)
class FakeAgentBackendScenario(StrEnum):
"""Deterministic fake scenarios for API-side integration tests.
``PAUSED`` represents the API workflow effect. On the Dify Agent wire
protocol it is a succeeded run carrying a deferred external tool call.
"""
"""Deterministic fake scenarios for API-side integration tests."""
SUCCESS = "success"
FAILED = "failed"
@@ -98,7 +95,7 @@ class FakeAgentBackendRunClient:
case FakeAgentBackendScenario.PAUSED:
return RunStatusResponse(
run_id=run_id,
status="succeeded",
status="paused",
created_at=_FIXED_TIME,
updated_at=_FIXED_TIME,
)
@@ -131,17 +128,13 @@ class FakeAgentBackendRunClient:
case FakeAgentBackendScenario.PAUSED:
return (
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
RunSucceededEvent(
RunPausedEvent(
id="2-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunSucceededEventData(
deferred_tool_call=DeferredToolCallPayload(
tool_call_id="fake-ask-human-1",
tool_name="ask_human",
args={"question": "Agent requested human input."},
metadata={"layer_type": "dify.ask_human", "schema_version": 1},
),
data=RunPausedEventData(
reason="human_input_required",
message="Agent requested human input.",
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
),
+90 -258
View File
@@ -11,17 +11,13 @@ composition-driven.
from __future__ import annotations
from collections.abc import Mapping
from typing import ClassVar
from typing import ClassVar, cast
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers import ExitIntent
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig
from dify_agent.layers.config import DIFY_CONFIG_LAYER_TYPE_ID, DifyConfigLayerConfig
from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
@@ -29,12 +25,10 @@ from dify_agent.layers.dify_plugin import (
DifyPluginLLMLayerConfig,
DifyPluginToolsLayerConfig,
)
from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
from dify_agent.layers.execution_context import (
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
DifyExecutionContextLayerConfig,
)
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.protocol import (
@@ -42,12 +36,10 @@ from dify_agent.protocol import (
DIFY_AGENT_MODEL_LAYER_ID,
DIFY_AGENT_OUTPUT_LAYER_ID,
CreateRunRequest,
DeferredToolResultsPayload,
LayerExitSignals,
RunComposition,
RunLayerSpec,
RunPurpose,
RuntimeLayerSpec,
)
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
@@ -56,18 +48,74 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_CONFIG_LAYER_ID = "config"
DIFY_DRIVE_LAYER_ID = "drive"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
DIFY_CORE_TOOLS_LAYER_ID = "core_tools"
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
DIFY_ASK_HUMAN_LAYER_ID = "ask_human"
DIFY_SHELL_LAYER_ID = "shell"
# Layer types that hold credentials in their per-run config. These are excluded
# from the cleanup-replay composition (and from the snapshot that is sent with
# the cleanup request) because we deliberately do not persist plaintext
# credentials between runs.
_CLEANUP_EXCLUDED_LAYER_TYPES: tuple[str, ...] = (
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
)
class CleanupLayerSpec(BaseModel):
"""One layer node replayed by an Agent backend cleanup-only run.
Cleanup composition cannot include credential-bearing plugin layers, so we
persist only the non-plugin layer specs together with the original config.
Storing the config (rather than just ``name``/``type``) means cleanup does
not depend on the original build-time inputs being re-derivable.
"""
name: str
type: str
deps: dict[str, str] = Field(default_factory=dict)
metadata: dict[str, JsonValue] = Field(default_factory=dict)
config: JsonValue = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
def extract_cleanup_layer_specs(composition: RunComposition) -> list[CleanupLayerSpec]:
"""Project the in-flight composition into the persistable cleanup spec list.
Plugin layers are intentionally dropped (their configs hold credentials and
the lifecycle contract says "do not include an LLM layer" during cleanup).
The filtered names must later drive snapshot filtering so the agenton
compositor's name-order check still passes for the cleanup run.
"""
excluded = set(_CLEANUP_EXCLUDED_LAYER_TYPES)
specs: list[CleanupLayerSpec] = []
for layer in composition.layers:
if layer.type in excluded:
continue
config_value: JsonValue = None
if isinstance(layer.config, BaseModel):
config_value = layer.config.model_dump(mode="json", warnings=False)
else:
# ``RunLayerSpec.config`` is typed as ``LayerConfigInput`` which
# includes ``Mapping[str, object] | bytes``. In the cleanup-replay
# pipeline our builder only emits BaseModel-derived configs or
# ``None``, so the wider input alias narrows safely here.
config_value = cast(JsonValue, layer.config)
specs.append(
CleanupLayerSpec(
name=layer.name,
type=layer.type,
deps=dict(layer.deps),
metadata=dict(layer.metadata),
config=config_value,
)
)
return specs
def _filter_snapshot_to_specs(
snapshot: CompositorSessionSnapshot,
specs: list[RuntimeLayerSpec],
specs: list[CleanupLayerSpec],
) -> CompositorSessionSnapshot:
"""Keep only snapshot layers whose names appear in the cleanup spec list.
@@ -82,28 +130,6 @@ def _filter_snapshot_to_specs(
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
def _shell_layer_deps() -> dict[str, str]:
return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
def _drive_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _config_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _shell_config_with_drive_ref(
shell_config: DifyShellLayerConfig | None,
drive_config: DifyDriveLayerConfig | None,
) -> DifyShellLayerConfig:
config = shell_config or DifyShellLayerConfig()
if drive_config is None:
return config
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
class AgentBackendModelConfig(BaseModel):
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
@@ -116,30 +142,6 @@ class AgentBackendModelConfig(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
# ``DifyPluginLLMLayerConfig.model_settings`` is pydantic_ai's ``ModelSettings``
# TypedDict (closed: unknown keys are rejected, explicit ``None`` values fail the
# per-field type checks). Agent Soul model settings carry a wider, nullable shape
# (``stop`` / ``response_format`` plus null-padded fields), so the layer config
# only receives the keys the runtime contract accepts.
_AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS = (
"temperature",
"top_p",
"presence_penalty",
"frequency_penalty",
"max_tokens",
)
def _agent_model_settings(settings: Mapping[str, JsonValue]) -> dict[str, JsonValue] | None:
sanitized: dict[str, JsonValue] = {
key: settings[key] for key in _AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS if settings.get(key) is not None
}
stop = settings.get("stop")
if isinstance(stop, list) and stop:
sanitized["stop_sequences"] = stop
return sanitized or None
class AgentBackendOutputConfig(BaseModel):
"""API-side structured output declaration for the conventional output layer.
@@ -167,24 +169,11 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
tools: DifyPluginToolsLayerConfig | None = None
core_tools: DifyCoreToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement; a deferred call ends the run and
# the workflow pauses via the existing HITL form mechanism (ENG-635).
ask_human_config: DifyAskHumanLayerConfig | None = None
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
include_shell: bool = False
shell_config: DifyShellLayerConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None
# Human tool results fed back into a continuation run after a HITL submission
# (ENG-638). Keyed by the original deferred tool_call_id.
deferred_tool_results: DeferredToolResultsPayload | None = None
include_history: bool = True
suspend_on_exit: bool = True
metadata: dict[str, JsonValue] = Field(default_factory=dict)
@@ -216,23 +205,11 @@ class AgentBackendAgentAppRunInput(BaseModel):
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
tools: DifyPluginToolsLayerConfig | None = None
core_tools: DifyCoreToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement (ENG-635).
ask_human_config: DifyAskHumanLayerConfig | None = None
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
include_shell: bool = False
shell_config: DifyShellLayerConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None
# Human tool results fed back into a continuation run after a HITL submission
# (ENG-638). Keyed by the original deferred tool_call_id.
deferred_tool_results: DeferredToolResultsPayload | None = None
include_history: bool = True
suspend_on_exit: bool = True
metadata: dict[str, JsonValue] = Field(default_factory=dict)
@@ -254,11 +231,9 @@ class AgentBackendRunRequestBuilder:
"""Build an Agent App conversation-turn run request.
Layer graph: optional Agent Soul system prompt → user prompt →
execution context → optional shell / config / drive / history
(multi-turn) → LLM → optional plugin-direct tools / core-routed tools /
knowledge search / ask_human / structured output. Mirrors the
workflow-node layer ordering minus the workflow-job / previous-node
prompt.
execution context → optional history (multi-turn) → LLM → optional
plugin tools → optional structured output. Mirrors the workflow-node
layer ordering minus the workflow-job / previous-node prompt.
"""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
@@ -288,47 +263,6 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = (
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
)
if include_shell:
# Sandboxed bash workspace (dify.shell). It enters before config/drive
# so eager pulls materialize content in the same filesystem used by
# model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
)
)
if run_input.config_layer_config is not None:
layers.append(
RunLayerSpec(
name=DIFY_CONFIG_LAYER_ID,
type=DIFY_CONFIG_LAYER_TYPE_ID,
deps=_config_layer_deps(),
metadata=run_input.metadata,
config=run_input.config_layer_config,
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps=_drive_layer_deps(),
metadata=run_input.metadata,
config=run_input.drive_config,
)
)
if run_input.include_history:
layers.append(
RunLayerSpec(
@@ -349,57 +283,31 @@ class AgentBackendRunRequestBuilder:
model_provider=run_input.model.model_provider,
model=run_input.model.model,
credentials=run_input.model.credentials,
model_settings=_agent_model_settings(run_input.model.model_settings),
model_settings=run_input.model.model_settings or None,
),
)
)
if run_input.tools is not None and run_input.tools.tools:
plugin_tool_deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
if include_shell:
plugin_tool_deps["shell"] = DIFY_SHELL_LAYER_ID
layers.append(
RunLayerSpec(
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps=plugin_tool_deps,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.tools,
)
)
if run_input.core_tools is not None and run_input.core_tools.tools:
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). The layer declares NoLayerDeps,
# so the spec carries no deps; shellctl connection is server-injected.
layers.append(
RunLayerSpec(
name=DIFY_CORE_TOOLS_LAYER_ID,
type=DIFY_CORE_TOOLS_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.core_tools,
)
)
if run_input.knowledge is not None and run_input.knowledge.sets:
layers.append(
RunLayerSpec(
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.knowledge,
)
)
if run_input.ask_human_config is not None:
# Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends
# the run with a deferred_tool_call; the caller pauses (workflow HITL) and
# later resumes with deferred_tool_results. Needs the history layer above.
layers.append(
RunLayerSpec(
name=DIFY_ASK_HUMAN_LAYER_ID,
type=DIFY_ASK_HUMAN_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.ask_human_config,
config=run_input.shell_config or DifyShellLayerConfig(),
)
)
@@ -423,7 +331,6 @@ class AgentBackendRunRequestBuilder:
idempotency_key=run_input.idempotency_key,
metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot,
deferred_tool_results=run_input.deferred_tool_results,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),
@@ -433,7 +340,7 @@ class AgentBackendRunRequestBuilder:
self,
*,
session_snapshot: CompositorSessionSnapshot,
runtime_layer_specs: list[RuntimeLayerSpec],
composition_layer_specs: list[CleanupLayerSpec],
idempotency_key: str | None = None,
metadata: dict[str, JsonValue] | None = None,
) -> CreateRunRequest:
@@ -444,12 +351,11 @@ class AgentBackendRunRequestBuilder:
non-plugin layer graph that produced the snapshot. Plugin layers
(``dify.plugin.llm``, ``dify.plugin.tools``) are excluded from both the
composition and the snapshot before submission because their configs
may carry credentials or runtime-only declarations that are not
persisted between runs.
require credentials that are not persisted between runs.
"""
if not runtime_layer_specs:
if not composition_layer_specs:
raise ValueError(
"build_cleanup_request requires runtime_layer_specs; an empty "
"build_cleanup_request requires composition_layer_specs; an empty "
"composition would fail the agent backend's snapshot validation."
)
request_metadata = dict(metadata or {})
@@ -462,9 +368,9 @@ class AgentBackendRunRequestBuilder:
metadata=dict(spec.metadata),
config=spec.config,
)
for spec in runtime_layer_specs
for spec in composition_layer_specs
]
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, runtime_layer_specs)
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, composition_layer_specs)
return CreateRunRequest(
composition=RunComposition(layers=layers),
purpose="workflow_node",
@@ -475,13 +381,7 @@ class AgentBackendRunRequestBuilder:
)
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
"""Build a workflow Agent Node run request without defining another wire schema.
Layer graph mirrors the workflow surface: prompts → execution context →
optional shell / config / drive / history → LLM → optional
plugin-direct tools / core-routed tools / knowledge search /
ask_human / structured output.
"""
"""Build a workflow Agent Node run request without defining another wire schema."""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
layers.append(
@@ -499,7 +399,7 @@ class AgentBackendRunRequestBuilder:
name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "workflow_node_job"},
config=PromptLayerConfig(user=run_input.workflow_node_job_prompt),
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
),
RunLayerSpec(
name=WORKFLOW_USER_PROMPT_LAYER_ID,
@@ -516,47 +416,6 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = (
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
)
if include_shell:
# Sandboxed bash workspace (dify.shell). It enters before drive so
# drive can materialize mentioned targets with `dify-agent drive pull`
# in the same shell-visible filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
)
)
if run_input.config_layer_config is not None:
layers.append(
RunLayerSpec(
name=DIFY_CONFIG_LAYER_ID,
type=DIFY_CONFIG_LAYER_TYPE_ID,
deps=_config_layer_deps(),
metadata=run_input.metadata,
config=run_input.config_layer_config,
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps=_drive_layer_deps(),
metadata=run_input.metadata,
config=run_input.drive_config,
)
)
if run_input.include_history:
layers.append(
RunLayerSpec(
@@ -578,58 +437,32 @@ class AgentBackendRunRequestBuilder:
model_provider=run_input.model.model_provider,
model=run_input.model.model,
credentials=run_input.model.credentials,
model_settings=_agent_model_settings(run_input.model.model_settings),
model_settings=run_input.model.model_settings or None,
),
),
]
)
if run_input.tools is not None and run_input.tools.tools:
plugin_tool_deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
if include_shell:
plugin_tool_deps["shell"] = DIFY_SHELL_LAYER_ID
layers.append(
RunLayerSpec(
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps=plugin_tool_deps,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.tools,
)
)
if run_input.core_tools is not None and run_input.core_tools.tools:
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). The layer declares NoLayerDeps,
# so the spec carries no deps; shellctl connection is server-injected.
layers.append(
RunLayerSpec(
name=DIFY_CORE_TOOLS_LAYER_ID,
type=DIFY_CORE_TOOLS_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.core_tools,
)
)
if run_input.knowledge is not None and run_input.knowledge.sets:
layers.append(
RunLayerSpec(
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.knowledge,
)
)
if run_input.ask_human_config is not None:
# Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends
# the run with a deferred_tool_call; the caller pauses (workflow HITL) and
# later resumes with deferred_tool_results. Needs the history layer above.
layers.append(
RunLayerSpec(
name=DIFY_ASK_HUMAN_LAYER_ID,
type=DIFY_ASK_HUMAN_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.ask_human_config,
config=run_input.shell_config or DifyShellLayerConfig(),
)
)
@@ -653,7 +486,6 @@ class AgentBackendRunRequestBuilder:
idempotency_key=run_input.idempotency_key,
metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot,
deferred_tool_results=run_input.deferred_tool_results,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),
@@ -0,0 +1,135 @@
"""API-side client for the agent backend's read-only workspace file endpoints.
The agent backend exposes ``/workspaces/{session_id}/files{,/preview,/download}``
to inspect a shell-layer sandbox workspace. This thin synchronous client proxies
those reads for the console FS inspector and normalizes transport/HTTP failures
into the API backend's ``AgentBackendError`` boundary, preserving the backend's
status code and ``{code, message}`` detail so the controller can relay them.
"""
from __future__ import annotations
import base64
import binascii
from dataclasses import dataclass
from typing import Literal
import httpx
from pydantic import BaseModel
from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError
_DEFAULT_TIMEOUT_SECONDS = 30.0
class WorkspaceFileEntry(BaseModel):
"""One entry in a workspace directory listing."""
name: str
type: Literal["file", "dir", "symlink"]
size: int
mtime: int
class WorkspaceListResult(BaseModel):
"""Directory listing of a workspace path."""
path: str
entries: list[WorkspaceFileEntry]
truncated: bool
class WorkspacePreviewResult(BaseModel):
"""Inline preview of a workspace file."""
path: str
size: int
truncated: bool
binary: bool
text: str | None = None
@dataclass(frozen=True, slots=True)
class WorkspaceDownloadResult:
"""Decoded bytes of a workspace file for download."""
path: str
size: int
truncated: bool
content: bytes
class WorkspaceFilesBackendClient:
"""Synchronous proxy to the agent backend workspace file endpoints."""
def __init__(
self,
base_url: str,
*,
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
transport: httpx.BaseTransport | None = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._transport = transport
def list_files(self, session_id: str, path: str) -> WorkspaceListResult:
data = self._get(f"/workspaces/{session_id}/files", params={"path": path})
return WorkspaceListResult.model_validate(data)
def preview(self, session_id: str, path: str) -> WorkspacePreviewResult:
data = self._get(f"/workspaces/{session_id}/files/preview", params={"path": path})
return WorkspacePreviewResult.model_validate(data)
def download(self, session_id: str, path: str) -> WorkspaceDownloadResult:
data = self._get(f"/workspaces/{session_id}/files/download", params={"path": path})
encoded = data.get("content_base64")
if not isinstance(encoded, str):
raise AgentBackendHTTPError("agent backend download response missing content", status_code=502, detail=data)
try:
content = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise AgentBackendHTTPError(
"agent backend returned undecodable download content", status_code=502, detail=str(exc)
) from exc
size = data.get("size")
return WorkspaceDownloadResult(
path=str(data.get("path", path)),
size=int(size) if isinstance(size, (int, float)) else len(content),
truncated=bool(data.get("truncated")),
content=content,
)
def _get(self, route: str, *, params: dict[str, str]) -> dict[str, object]:
url = f"{self._base_url}{route}"
try:
with httpx.Client(timeout=self._timeout, transport=self._transport, trust_env=False) as client:
response = client.get(url, params=params)
except httpx.HTTPError as exc:
raise AgentBackendTransportError(f"failed to reach agent backend workspace endpoint: {exc}") from exc
if response.status_code >= 400:
detail: object
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
raise AgentBackendHTTPError(
f"agent backend workspace request failed ({response.status_code})",
status_code=response.status_code,
detail=detail,
)
body = response.json()
if not isinstance(body, dict):
raise AgentBackendHTTPError(
"agent backend workspace response was not an object", status_code=502, detail=body
)
return body
__all__ = [
"WorkspaceDownloadResult",
"WorkspaceFileEntry",
"WorkspaceFilesBackendClient",
"WorkspaceListResult",
"WorkspacePreviewResult",
]
-5
View File
@@ -22,10 +22,8 @@ from .plugin import (
setup_system_trigger_oauth_client,
transform_datasource_credentials,
)
from .rbac import migrate_dataset_permissions_to_rbac, migrate_member_roles_to_rbac
from .retention import (
archive_workflow_runs,
archive_workflow_runs_plan,
clean_expired_messages,
clean_workflow_runs,
cleanup_orphaned_draft_variables,
@@ -52,7 +50,6 @@ from .vector import (
__all__ = [
"add_qdrant_index",
"archive_workflow_runs",
"archive_workflow_runs_plan",
"backfill_plugin_auto_upgrade",
"clean_expired_messages",
"clean_workflow_runs",
@@ -76,9 +73,7 @@ __all__ = [
"legacy_model_types",
"migrate_annotation_vector_database",
"migrate_data_for_plugin",
"migrate_dataset_permissions_to_rbac",
"migrate_knowledge_vector_database",
"migrate_member_roles_to_rbac",
"migrate_oss",
"migration_data_wizard",
"old_metadata_migration",
+3 -4
View File
@@ -25,7 +25,7 @@ def reset_password(email, new_password, password_confirm):
return
normalized_email = email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(db.session, email.strip())
account = AccountService.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"))
@@ -67,7 +67,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(db.session, email.strip())
account = AccountService.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"))
@@ -133,9 +133,8 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
password=new_password,
language=language,
create_workspace_required=False,
session=db.session,
)
TenantService.create_owner_tenant_if_not_exist(account, name, session=db.session)
TenantService.create_owner_tenant_if_not_exist(account, name)
click.echo(
click.style(
+1 -3
View File
@@ -7,7 +7,6 @@ from typing import cast
import click
from commands.rbac import migrate_dataset_permissions_to_rbac
from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from services.legacy_model_type_migration import (
@@ -146,7 +145,7 @@ def legacy_model_types(
option_name="--model-types",
)
selected_model_types = (
tuple(ModelType(model_type) for model_type in normalized_model_types)
tuple(ModelType.value_of(model_type) for model_type in normalized_model_types)
if normalized_model_types
else (
ModelType.LLM,
@@ -178,4 +177,3 @@ def legacy_model_types(
data_migrate.add_command(legacy_model_types)
data_migrate.add_command(migrate_dataset_permissions_to_rbac)
+13 -18
View File
@@ -10,7 +10,6 @@ import click
import sqlalchemy as sa
import yaml
from core.db.session_factory import session_factory
from extensions.ext_database import db
from models import Tenant
from models.model import App
@@ -107,8 +106,7 @@ def export_migration_data(input_file: str | None, output_file: str | None, overw
assert output_file is not None
raw_config = _load_json_object(input_file, "Export config")
selection = ExportConfigParser().parse(raw_config)
with session_factory.create_session() as session:
result = MigrationExportService().export(session, selection)
result = MigrationExportService().export(selection)
MigrationPackageService().save_package(result.package, output_file, overwrite=overwrite)
click.echo(click.style(f"Output written to {output_file}", fg="green"))
_render_report(result.report_items, context=_with_output_path(result.report_context, output_file))
@@ -155,21 +153,19 @@ def import_migration_data(
_require_options(("--input", input_file))
assert input_file is not None
package = MigrationPackageService().load_package(input_file)
with session_factory.create_session() as session:
result = MigrationImportService().import_package(
session,
ImportRequest(
package=package,
cli_target_tenant=target_tenant,
operator_email=operator_email,
options_override=_build_options_override(
package.metadata.import_options,
id_strategy=id_strategy,
conflict_strategy=conflict_strategy,
create_app_api_token_on_import=create_app_api_token_on_import,
),
result = MigrationImportService().import_package(
ImportRequest(
package=package,
cli_target_tenant=target_tenant,
operator_email=operator_email,
options_override=_build_options_override(
package.metadata.import_options,
id_strategy=id_strategy,
conflict_strategy=conflict_strategy,
create_app_api_token_on_import=create_app_api_token_on_import,
),
)
)
_render_report(result.report_items, context=result.report_context)
except MigrationDataError as exc:
raise click.ClickException(str(exc)) from exc
@@ -252,8 +248,7 @@ def migration_data_wizard() -> None:
conflict_strategy=conflict_strategy,
output_file=output_file,
)
with session_factory.create_session() as session:
result = MigrationExportService().export(session, selection)
result = MigrationExportService().export(selection)
MigrationPackageService().save_package(result.package, output_file, overwrite=overwrite)
click.echo(click.style(f"Output written to {output_file}", fg="green"))
_print_wizard_step("Report")
-484
View File
@@ -1,484 +0,0 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed
import click
from sqlalchemy import select
from configs import dify_config
from core.db.session_factory import session_factory
from core.rbac import RBACResourceWhitelistScope
from models import Dataset, DatasetPermission, DatasetPermissionEnum, TenantAccountJoin, TenantAccountRole
from services.enterprise.rbac_service import ListOption, RBACService, ReplaceMemberBindings, ReplaceUserAccessPolicies
_RBAC_DEFAULT_ACCESS_POLICY_ID = "default"
_LEGACY_ROLE_TO_BUILTIN_TAG = {
TenantAccountRole.OWNER.value: "owner",
TenantAccountRole.ADMIN.value: "admin",
TenantAccountRole.EDITOR.value: "editor",
TenantAccountRole.NORMAL.value: "normal",
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
}
def _resolve_builtin_role_ids(tenant_id: str, operator_account_id: str) -> dict[str, str]:
"""Resolve every legacy workspace role to the current tenant's builtin RBAC role id.
The migration replays the old `TenantAccountJoin.role` values onto the
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
identified by runtime ids, so the command must look them up per tenant.
"""
roles = RBACService.Roles.list(
tenant_id=tenant_id,
account_id=operator_account_id,
options=ListOption(page_number=1, results_per_page=100),
).data
role_id_by_tag = {
role.role_tag: role.id
for role in roles
if role.is_builtin and role.category == "global_system_default" and role.role_tag
}
resolved: dict[str, str] = {}
for legacy_role, expected_builtin_tag in _LEGACY_ROLE_TO_BUILTIN_TAG.items():
role_id = role_id_by_tag.get(expected_builtin_tag)
if expected_builtin_tag == "dataset_operator" and not dify_config.DATASET_OPERATOR_ENABLED:
continue
if not role_id:
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
resolved[legacy_role] = role_id
return resolved
def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str:
"""Resolve a legacy workspace role to the current tenant's builtin RBAC role id.
The migration replays the old `TenantAccountJoin.role` values onto the
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
identified by runtime ids, so the command must look them up per tenant.
"""
if legacy_role not in _LEGACY_ROLE_TO_BUILTIN_TAG:
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
return _resolve_builtin_role_ids(tenant_id, operator_account_id)[legacy_role]
def _iter_tenant_member_batches(
tenant_id: str | None,
*,
db_batch_size: int,
api_batch_size: int,
) -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
"""Yield legacy member roles in tenant-scoped API-sized batches.
Rows are projected to primitive values and streamed from the database, so
the command never materializes every TenantAccountJoin ORM object. The
iterator only keeps one tenant's API-sized batches in memory while it
finds that tenant's owner account.
"""
with session_factory.create_session() as session:
stmt = (
select(TenantAccountJoin.tenant_id, TenantAccountJoin.account_id, TenantAccountJoin.role)
.order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
.execution_options(yield_per=db_batch_size)
)
if tenant_id:
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
current_tenant_id: str | None = None
owner_account_id: str | None = None
batches: list[list[tuple[str, str]]] = []
batch: list[tuple[str, str]] = []
def flush_current_tenant() -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
if current_tenant_id is None:
return
if batch:
batches.append(batch.copy())
if not owner_account_id:
raise ValueError(f"Workspace owner not found for tenant={current_tenant_id}")
for item in batches:
yield current_tenant_id, owner_account_id, item
for row in session.execute(stmt):
workspace_id = str(row.tenant_id)
if current_tenant_id is not None and workspace_id != current_tenant_id:
yield from flush_current_tenant()
owner_account_id = None
batches = []
batch = []
current_tenant_id = workspace_id
account_id = str(row.account_id)
role = str(row.role)
if role == TenantAccountRole.OWNER.value:
owner_account_id = account_id
batch.append((account_id, role))
if len(batch) >= api_batch_size:
batches.append(batch)
batch = []
yield from flush_current_tenant()
def _member_already_has_role(current_roles_by_account_id: dict[str, set[str]], account_id: str, role_id: str) -> bool:
return current_roles_by_account_id.get(account_id) == {role_id}
def _replace_member_role(
tenant_id: str,
operator_account_id: str,
member_account_id: str,
role_id: str,
) -> str:
RBACService.MemberRoles.replace(
tenant_id=tenant_id,
account_id=operator_account_id,
member_account_id=member_account_id,
role_ids=[role_id],
)
return member_account_id
@click.command(
"rbac-migrate-member-roles", help="Migrate legacy workspace member roles into RBAC member-role bindings."
)
@click.option("--tenant-id", help="Only migrate a single workspace.")
@click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.")
@click.option("--db-batch-size", default=5000, show_default=True, help="Rows fetched per database batch.")
@click.option("--api-batch-size", default=200, show_default=True, help="Members checked per RBAC batch_get call.")
@click.option("--workers", default=1, show_default=True, help="Concurrent member role replace calls per tenant batch.")
def migrate_member_roles_to_rbac(
tenant_id: str | None,
dry_run: bool,
db_batch_size: int,
api_batch_size: int,
workers: int,
) -> None:
"""Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data.
This is an offline migration command for workspaces that already have
members in the legacy role model but need matching records in the RBAC
member-role binding store.
"""
click.echo(click.style("Starting RBAC member-role migration.", fg="green"))
if workers < 1:
raise click.BadParameter("workers must be >= 1", param_hint="--workers")
tenant_count = 0
scanned_count = 0
skipped_count = 0
migrated_count = 0
current_tenant_id: str | None = None
role_ids_by_legacy_role: dict[str, str] = {}
for workspace_id, owner_account_id, batch in _iter_tenant_member_batches(
tenant_id,
db_batch_size=db_batch_size,
api_batch_size=api_batch_size,
):
scanned_count += len(batch)
if workspace_id != current_tenant_id:
tenant_count += 1
current_tenant_id = workspace_id
role_ids_by_legacy_role = _resolve_builtin_role_ids(workspace_id, owner_account_id)
click.echo(f"tenant={workspace_id}")
current_roles_by_account_id: dict[str, set[str]] = {}
if not dry_run:
current_roles = RBACService.MemberRoles.batch_get(
tenant_id=workspace_id,
account_id=owner_account_id,
member_account_ids=[account_id for account_id, _ in batch],
)
current_roles_by_account_id = {
item.account_id: {str(role.id) for role in item.roles} for item in current_roles
}
replace_jobs: list[tuple[str, str]] = []
for member_account_id, legacy_role in batch:
resolved_role_id = role_ids_by_legacy_role.get(legacy_role)
if not resolved_role_id:
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
if dry_run:
click.echo(
f"tenant={workspace_id} member={member_account_id} "
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
)
continue
if _member_already_has_role(current_roles_by_account_id, member_account_id, resolved_role_id):
skipped_count += 1
continue
replace_jobs.append((member_account_id, resolved_role_id))
if replace_jobs:
if workers == 1:
for member_account_id, resolved_role_id in replace_jobs:
_replace_member_role(workspace_id, owner_account_id, member_account_id, resolved_role_id)
migrated_count += 1
else:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(
_replace_member_role,
workspace_id,
owner_account_id,
member_account_id,
resolved_role_id,
)
for member_account_id, resolved_role_id in replace_jobs
]
for future in as_completed(futures):
future.result()
migrated_count += 1
if scanned_count % 10000 == 0:
click.echo(
f"progress scanned={scanned_count} migrated={migrated_count} skipped={skipped_count}",
err=True,
)
if scanned_count == 0:
click.echo(click.style("No workspace members found for migration.", fg="yellow"))
return
if dry_run:
click.echo(
click.style(
f"Dry run completed. Scanned {scanned_count} members across {tenant_count} tenants. "
"No RBAC bindings were written.",
fg="yellow",
)
)
else:
click.echo(
click.style(
f"RBAC member-role migration completed. Scanned {scanned_count} members across {tenant_count} tenants, "
f"migrated {migrated_count}, skipped {skipped_count} already up-to-date.",
fg="green",
)
)
def _dataset_permission_enum(permission: DatasetPermissionEnum | str | None) -> DatasetPermissionEnum:
if permission is None:
return DatasetPermissionEnum.ONLY_ME
try:
return DatasetPermissionEnum(permission)
except ValueError as exc:
raise ValueError(f"Unsupported legacy dataset permission: {permission}") from exc
def _rbac_dataset_scope_for_legacy_permission(permission: DatasetPermissionEnum) -> RBACResourceWhitelistScope:
if permission is DatasetPermissionEnum.ALL_TEAM:
return RBACResourceWhitelistScope.ALL
if permission in {DatasetPermissionEnum.ONLY_ME, DatasetPermissionEnum.PARTIAL_TEAM}:
return RBACResourceWhitelistScope.SPECIFIC
raise ValueError(f"Unsupported legacy dataset permission: {permission}")
def _emit_dataset_permission_migration_event(payload: dict[str, object]) -> None:
click.echo(json.dumps(payload, sort_keys=True))
@click.command(
"rbac-migrate-dataset-permissions",
help=(
"Migrate legacy dataset permission scopes and partial members into RBAC dataset access bindings. "
"Side effect: replacing each dataset whitelist clears existing per-user policy bindings; "
"the command then recreates legacy partial-member default bindings."
),
)
@click.option("--tenant-id", help="Only migrate datasets in a single workspace.")
@click.option("--dataset-id", help="Only migrate a single dataset.")
@click.option("--batch-size", default=500, show_default=True, type=click.IntRange(min=1))
@click.option(
"--dry-run/--apply",
default=True,
show_default=True,
help="Preview the migration without writing RBAC bindings. Use --apply to write changes.",
)
def migrate_dataset_permissions_to_rbac(
tenant_id: str | None,
dataset_id: str | None,
batch_size: int,
dry_run: bool,
) -> None:
"""Backfill RBAC dataset access config from legacy `Dataset.permission`.
Legacy mapping:
- all_team_members -> RBAC dataset whitelist scope "all"
- partial_members -> RBAC dataset whitelist scope "specific" plus each partial member gets the
virtual default policy
- only_me -> RBAC dataset whitelist scope "specific" with no member policy bindings
The command replaces each dataset's RBAC whitelist scope first. RBAC clears
existing per-user policy bindings during that replace, then this command
recreates the legacy partial-member default bindings. Re-running it is
therefore idempotent for a dataset's current legacy configuration.
"""
click.echo(click.style("Starting RBAC dataset permission migration.", fg="green"))
scanned_count = 0
scope_migrated_count = 0
user_policy_migrated_count = 0
partial_dataset_count = 0
last_dataset_id: str | None = None
while True:
with session_factory.create_session() as session:
stmt = (
select(Dataset.id, Dataset.tenant_id, Dataset.permission, Dataset.created_by)
.order_by(Dataset.id.asc())
.limit(batch_size)
)
if tenant_id:
stmt = stmt.where(Dataset.tenant_id == tenant_id)
if dataset_id:
stmt = stmt.where(Dataset.id == dataset_id)
if last_dataset_id:
stmt = stmt.where(Dataset.id > last_dataset_id)
dataset_rows = list(session.execute(stmt).all())
if not dataset_rows:
break
dataset_ids = [str(row.id) for row in dataset_rows]
partial_members_by_dataset_id: dict[str, list[str]] = {item: [] for item in dataset_ids}
permission_rows = session.execute(
select(DatasetPermission.dataset_id, DatasetPermission.account_id).where(
DatasetPermission.dataset_id.in_(dataset_ids)
)
).all()
for row in permission_rows:
partial_members_by_dataset_id[str(row.dataset_id)].append(str(row.account_id))
for dataset in dataset_rows:
workspace_id = str(dataset.tenant_id)
current_dataset_id = str(dataset.id)
operator_account_id = str(dataset.created_by)
permission_value = _dataset_permission_enum(dataset.permission)
scope = _rbac_dataset_scope_for_legacy_permission(permission_value)
partial_member_ids = sorted(set(partial_members_by_dataset_id[current_dataset_id]))
should_bind_partial_members = permission_value is DatasetPermissionEnum.PARTIAL_TEAM
click.echo(
f"tenant={workspace_id} dataset={current_dataset_id} "
f"operator={operator_account_id} "
f"legacy_permission={permission_value} -> rbac_scope={scope} "
f"partial_members={len(partial_member_ids) if should_bind_partial_members else 0}"
)
scanned_count += 1
replace_whitelist_payload = ReplaceMemberBindings(scope=scope)
if dry_run:
_emit_dataset_permission_migration_event(
{
"event": "dataset_permission_migration_proposed_change",
"action": "replace_whitelist",
"dry_run": True,
"tenant_id": workspace_id,
"dataset_id": current_dataset_id,
"operator_account_id": operator_account_id,
"before": {
"legacy_dataset_permission": permission_value.value,
"legacy_partial_member_ids": partial_member_ids if should_bind_partial_members else [],
},
"after": {
"rbac_whitelist_scope": scope.value,
},
"call": {
"method": "RBACService.DatasetAccess.replace_whitelist",
"kwargs": {
"tenant_id": workspace_id,
"account_id": operator_account_id,
"dataset_id": current_dataset_id,
"payload": replace_whitelist_payload.model_dump(mode="json"),
},
},
}
)
if not dry_run:
RBACService.DatasetAccess.replace_whitelist(
tenant_id=workspace_id,
account_id=operator_account_id,
dataset_id=current_dataset_id,
payload=replace_whitelist_payload,
)
scope_migrated_count += 1
if should_bind_partial_members:
partial_dataset_count += 1
for member_account_id in partial_member_ids:
replace_user_access_policies_payload = ReplaceUserAccessPolicies(
access_policy_ids=[_RBAC_DEFAULT_ACCESS_POLICY_ID],
)
if dry_run:
_emit_dataset_permission_migration_event(
{
"event": "dataset_permission_migration_proposed_change",
"action": "replace_user_access_policies",
"dry_run": True,
"tenant_id": workspace_id,
"dataset_id": current_dataset_id,
"operator_account_id": operator_account_id,
"target_account_id": member_account_id,
"before": {
"legacy_dataset_permission": permission_value.value,
"legacy_partial_member_id": member_account_id,
},
"after": {
"rbac_user_access_policy_ids": [_RBAC_DEFAULT_ACCESS_POLICY_ID],
},
"call": {
"method": "RBACService.DatasetAccess.replace_user_access_policies",
"kwargs": {
"tenant_id": workspace_id,
"account_id": operator_account_id,
"dataset_id": current_dataset_id,
"target_account_id": member_account_id,
"payload": replace_user_access_policies_payload.model_dump(mode="json"),
},
},
}
)
continue
RBACService.DatasetAccess.replace_user_access_policies(
tenant_id=workspace_id,
account_id=operator_account_id,
dataset_id=current_dataset_id,
target_account_id=member_account_id,
payload=replace_user_access_policies_payload,
)
user_policy_migrated_count += 1
last_dataset_id = dataset_ids[-1]
if dataset_id:
break
if scanned_count == 0:
click.echo(click.style("No datasets found for migration.", fg="yellow"))
return
if dry_run:
click.echo(
click.style(
f"Dry run completed. Scanned {scanned_count} datasets; "
f"{partial_dataset_count} partial-member datasets would be migrated.",
fg="yellow",
)
)
else:
click.echo(
click.style(
"RBAC dataset permission migration completed. "
f"Scanned {scanned_count} datasets, migrated {scope_migrated_count} scopes, "
f"wrote {user_policy_migrated_count} user default-policy bindings.",
fg="green",
)
)
+95 -571
View File
@@ -12,171 +12,10 @@ from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpi
from services.retention.conversation.messages_clean_policy import create_message_clean_policy
from services.retention.conversation.messages_clean_service import MessagesCleanService
from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup
from services.retention.workflow_run.tenant_prefix import tenant_prefix_condition
from tasks.remove_app_and_related_data_task import delete_draft_variables_batch
logger = logging.getLogger(__name__)
_HEX_PREFIXES = tuple("0123456789abcdef")
class WorkflowRunArchivePlanRow(TypedDict):
tenant_prefix: str
total_tenants: int
workflow_runs: int
workflow_node_executions: int
paid_tenants: int
unpaid_tenants: int
class WorkflowRunArchiveTenantPlan(TypedDict):
archive_tenant_ids: list[str] | None
paid_tenant_ids: list[str]
unpaid_tenant_ids: list[str]
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
if value.tzinfo is None:
return value.replace(tzinfo=datetime.UTC)
return value.astimezone(datetime.UTC)
def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
if not prefixes:
return []
parsed = []
for raw_prefix in prefixes.split(","):
prefix = raw_prefix.strip().lower()
if not prefix:
continue
if len(prefix) != 1 or prefix not in _HEX_PREFIXES:
raise click.UsageError("--tenant-prefixes must be a comma-separated list of hex digits, e.g. 0,1,a,f.")
parsed.append(prefix)
return sorted(set(parsed))
def _get_archive_candidate_tenant_ids_by_prefix(
prefix: str,
*,
start_from: datetime.datetime | None,
end_before: datetime.datetime,
) -> list[str]:
from graphon.enums import WorkflowExecutionStatus
from models.workflow import WorkflowRun
from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver
conditions = [
WorkflowRun.created_at < end_before,
WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()),
WorkflowRun.type.in_(WorkflowRunArchiver.ARCHIVED_TYPE),
tenant_prefix_condition(WorkflowRun.tenant_id, prefix),
]
if start_from is not None:
conditions.append(WorkflowRun.created_at >= start_from)
tenant_ids = db.session.scalars(
sa.select(WorkflowRun.tenant_id).where(*conditions).distinct().order_by(WorkflowRun.tenant_id)
).all()
return list(tenant_ids)
def _filter_paid_workflow_archive_tenant_ids(tenant_ids: list[str]) -> tuple[list[str], list[str]]:
from configs import dify_config
from enums.cloud_plan import CloudPlan
from services.billing_service import BillingService
tenant_ids = sorted(set(tenant_ids))
if not tenant_ids:
return [], []
if not dify_config.BILLING_ENABLED:
return tenant_ids, []
plans = BillingService.get_plan_bulk_with_cache(tenant_ids)
paid_tenant_ids = [
tenant_id
for tenant_id in tenant_ids
if plans.get(tenant_id) and plans[tenant_id].get("plan") in (CloudPlan.PROFESSIONAL, CloudPlan.TEAM)
]
unpaid_tenant_ids = sorted(set(tenant_ids) - set(paid_tenant_ids))
return paid_tenant_ids, unpaid_tenant_ids
def _resolve_archive_tenant_ids_from_plan(
*,
tenant_ids: str | None,
tenant_prefixes: list[str],
start_from: datetime.datetime | None,
end_before: datetime.datetime,
) -> WorkflowRunArchiveTenantPlan:
"""
Resolve the archive tenant scope once before scanning workflow_runs.
Prefix rollout should use the tenant list collected by the same planning path, then archive by
tenant_id IN (...). Scanning workflow_runs with a tenant prefix range in every archive run is too expensive on
the large production table this command is meant to shrink.
"""
if tenant_ids:
requested_tenant_ids = [tid.strip() for tid in tenant_ids.split(",") if tid.strip()]
elif tenant_prefixes:
requested_tenant_ids = []
for prefix in tenant_prefixes:
requested_tenant_ids.extend(
_get_archive_candidate_tenant_ids_by_prefix(
prefix,
start_from=start_from,
end_before=end_before,
)
)
else:
return WorkflowRunArchiveTenantPlan(
archive_tenant_ids=None,
paid_tenant_ids=[],
unpaid_tenant_ids=[],
)
paid_tenant_ids, unpaid_tenant_ids = _filter_paid_workflow_archive_tenant_ids(requested_tenant_ids)
return WorkflowRunArchiveTenantPlan(
archive_tenant_ids=paid_tenant_ids,
paid_tenant_ids=paid_tenant_ids,
unpaid_tenant_ids=unpaid_tenant_ids,
)
def _resolve_archive_time_range(
*,
before_days: int,
from_days_ago: int | None,
to_days_ago: int | None,
start_from: datetime.datetime | None,
end_before: datetime.datetime | None,
) -> tuple[int, datetime.datetime | None, datetime.datetime | None]:
if (start_from is None) ^ (end_before is None):
raise click.UsageError("--start-from and --end-before must be provided together.")
if (from_days_ago is None) ^ (to_days_ago is None):
raise click.UsageError("--from-days-ago and --to-days-ago must be provided together.")
if from_days_ago is not None and to_days_ago is not None:
if start_from or end_before:
raise click.UsageError("Choose either day offsets or explicit dates, not both.")
if from_days_ago <= to_days_ago:
raise click.UsageError("--from-days-ago must be greater than --to-days-ago.")
now = datetime.datetime.now(datetime.UTC)
start_from = now - datetime.timedelta(days=from_days_ago)
end_before = now - datetime.timedelta(days=to_days_ago)
before_days = 0
if start_from is not None:
start_from = _normalize_utc_datetime(start_from)
if end_before is not None:
end_before = _normalize_utc_datetime(end_before)
if start_from and end_before and start_from >= end_before:
raise click.UsageError("--start-from must be earlier than --end-before.")
return before_days, start_from, end_before
@click.command("clear-free-plan-tenant-expired-logs", help="Clear free plan tenant expired logs.")
@click.option("--days", prompt=True, help="The days to clear free plan tenant expired logs.", default=30)
@@ -300,150 +139,11 @@ def clean_workflow_runs(
)
@click.command(
"archive-workflow-runs-plan",
help="Plan workflow run archive rollout by tenant ID first hex digit.",
)
@click.option("--before-days", default=90, show_default=True, help="Plan runs older than N days.")
@click.option(
"--from-days-ago",
default=None,
type=click.IntRange(min=0),
help="Lower bound in days ago (older). Must be paired with --to-days-ago.",
)
@click.option(
"--to-days-ago",
default=None,
type=click.IntRange(min=0),
help="Upper bound in days ago (newer). Must be paired with --from-days-ago.",
)
@click.option(
"--start-from",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
default=None,
help="Plan runs created at or after this timestamp (UTC if no timezone).",
)
@click.option(
"--end-before",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
default=None,
help="Plan runs created before this timestamp (UTC if no timezone).",
)
@click.option(
"--include-archived",
is_flag=True,
help="Compatibility no-op for V2 bundle archive; plan counts source rows in the requested window.",
)
def archive_workflow_runs_plan(
before_days: int,
from_days_ago: int | None,
to_days_ago: int | None,
start_from: datetime.datetime | None,
end_before: datetime.datetime | None,
include_archived: bool,
):
"""
Print the 16 tenant-prefix rollout rows used to choose archive execution order.
Counts use the same workflow run eligibility as archive-workflow-runs: ended runs,
supported workflow types, and the requested created_at window. V2 bundle archive
does not maintain per-run archive logs, so this plan reports source-table volume.
"""
from graphon.enums import WorkflowExecutionStatus
from models.workflow import WorkflowNodeExecutionModel, WorkflowRun
from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver
before_days, start_from, end_before = _resolve_archive_time_range(
before_days=before_days,
from_days_ago=from_days_ago,
to_days_ago=to_days_ago,
start_from=start_from,
end_before=end_before,
)
plan_end_before = end_before or datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=before_days)
if include_archived:
click.echo(click.style("--include-archived is a no-op for V2 bundle archive plans.", fg="yellow"))
rows: list[WorkflowRunArchivePlanRow] = []
for prefix in _HEX_PREFIXES:
tenant_ids = _get_archive_candidate_tenant_ids_by_prefix(
prefix,
start_from=start_from,
end_before=plan_end_before,
)
total_tenants = len(tenant_ids)
paid_tenant_ids, unpaid_tenant_ids = _filter_paid_workflow_archive_tenant_ids(tenant_ids)
run_conditions = [
WorkflowRun.created_at < plan_end_before,
WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()),
WorkflowRun.type.in_(WorkflowRunArchiver.ARCHIVED_TYPE),
tenant_prefix_condition(WorkflowRun.tenant_id, prefix),
]
if start_from is not None:
run_conditions.append(WorkflowRun.created_at >= start_from)
workflow_runs = (
db.session.scalar(sa.select(sa.func.count()).select_from(WorkflowRun).where(*run_conditions)) or 0
)
candidate_runs = sa.select(WorkflowRun.id).where(*run_conditions).subquery()
workflow_node_executions = (
db.session.scalar(
sa.select(sa.func.count())
.select_from(WorkflowNodeExecutionModel)
.join(candidate_runs, WorkflowNodeExecutionModel.workflow_run_id == candidate_runs.c.id)
)
or 0
)
rows.append(
WorkflowRunArchivePlanRow(
tenant_prefix=prefix,
total_tenants=total_tenants,
workflow_runs=workflow_runs,
workflow_node_executions=workflow_node_executions,
paid_tenants=len(paid_tenant_ids),
unpaid_tenants=len(unpaid_tenant_ids),
)
)
click.echo(
click.style(
f"Workflow archive plan for runs before {plan_end_before.isoformat()}"
f"{f' and at/after {start_from.isoformat()}' if start_from else ''}.",
fg="white",
)
)
click.echo(
click.style(
"fixed_archive_window="
f"{start_from.isoformat() if start_from else 'unbounded'},{plan_end_before.isoformat()}",
fg="white",
)
)
click.echo("tenant_prefix,total_tenants,workflow_runs,workflow_node_executions,paid_tenants,unpaid_tenants")
for row in rows:
click.echo(
f"{row['tenant_prefix']},{row['total_tenants']},{row['workflow_runs']},"
f"{row['workflow_node_executions']},{row['paid_tenants']},{row['unpaid_tenants']}"
)
ordered_rows = sorted(
rows,
key=lambda row: (row["workflow_runs"] + row["workflow_node_executions"], row["tenant_prefix"]),
)
click.echo("suggested_execution_order=" + ",".join(row["tenant_prefix"] for row in ordered_rows))
@click.command(
"archive-workflow-runs",
help="Archive workflow runs for paid plan tenants to S3-compatible storage.",
)
@click.option("--tenant-ids", default=None, help="Optional comma-separated tenant IDs for grayscale rollout.")
@click.option(
"--tenant-prefixes",
default=None,
help="Optional comma-separated tenant ID first hex digits for rollout waves, e.g. 0,1,a,f.",
)
@click.option("--before-days", default=90, show_default=True, help="Archive runs older than N days.")
@click.option(
"--from-days-ago",
@@ -469,36 +169,13 @@ def archive_workflow_runs_plan(
default=None,
help="Archive runs created before this timestamp (UTC if no timezone).",
)
@click.option("--batch-size", default=10000, show_default=True, help="Maximum workflow runs per archive bundle.")
@click.option(
"--workers",
default=1,
show_default=True,
type=int,
help="Reserved; bundle archive currently runs serially.",
)
@click.option(
"--run-shard-index",
default=None,
type=click.IntRange(min=0),
help="Zero-based workflow run shard index for parallel cron jobs. Must be paired with --run-shard-total.",
)
@click.option(
"--run-shard-total",
default=None,
type=click.IntRange(min=1, max=16),
help="Total workflow run shard count for parallel cron jobs. Must be paired with --run-shard-index.",
)
@click.option("--batch-size", default=100, show_default=True, help="Batch size for processing.")
@click.option("--workers", default=1, show_default=True, type=int, help="Concurrent workflow runs to archive.")
@click.option("--limit", default=None, type=int, help="Maximum number of runs to archive.")
@click.option("--dry-run", is_flag=True, help="Preview without archiving.")
@click.option(
"--delete-after-archive",
is_flag=True,
help="Not supported by bundle archive; use a separate bundle delete workflow after validation.",
)
@click.option("--delete-after-archive", is_flag=True, help="Delete runs and related data after archiving.")
def archive_workflow_runs(
tenant_ids: str | None,
tenant_prefixes: str | None,
before_days: int,
from_days_ago: int | None,
to_days_ago: int | None,
@@ -506,8 +183,6 @@ def archive_workflow_runs(
end_before: datetime.datetime | None,
batch_size: int,
workers: int,
run_shard_index: int | None,
run_shard_total: int | None,
limit: int | None,
dry_run: bool,
delete_after_archive: bool,
@@ -515,19 +190,14 @@ def archive_workflow_runs(
"""
Archive workflow runs for paid plan tenants older than the specified days.
This command writes V2 tenant/month/shard archive bundles. Each bundle contains Parquet snapshots from:
- workflow_runs
- workflow_app_logs
This command archives the following tables to storage:
- workflow_node_executions
- workflow_node_execution_offload
- workflow_pauses
- workflow_pause_reasons
- workflow_trigger_logs
Source database rows are always preserved by archive. Deletion must be handled by
a separate bundle-level delete workflow after manifest, checksum, row-count, and
restore-sampling validation. In --dry-run mode, no storage or database writes
happen; the command estimates per-table Parquet bytes and object size instead.
The workflow_runs and workflow_app_logs tables are preserved for UI listing.
"""
from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver
@@ -539,67 +209,32 @@ def archive_workflow_runs(
)
)
uses_relative_window = start_from is None and end_before is None
try:
before_days, start_from, end_before = _resolve_archive_time_range(
before_days=before_days,
from_days_ago=from_days_ago,
to_days_ago=to_days_ago,
start_from=start_from,
end_before=end_before,
)
parsed_tenant_prefixes = _parse_tenant_prefixes(tenant_prefixes)
except click.UsageError as e:
click.echo(click.style(e.message, fg="red"))
if (start_from is None) ^ (end_before is None):
click.echo(click.style("start-from and end-before must be provided together.", fg="red"))
return
if (from_days_ago is None) ^ (to_days_ago is None):
click.echo(click.style("from-days-ago and to-days-ago must be provided together.", fg="red"))
return
if from_days_ago is not None and to_days_ago is not None:
if start_from or end_before:
click.echo(click.style("Choose either day offsets or explicit dates, not both.", fg="red"))
return
if from_days_ago <= to_days_ago:
click.echo(click.style("from-days-ago must be greater than to-days-ago.", fg="red"))
return
now = datetime.datetime.now()
start_from = now - datetime.timedelta(days=from_days_ago)
end_before = now - datetime.timedelta(days=to_days_ago)
before_days = 0
if start_from and end_before and start_from >= end_before:
click.echo(click.style("start-from must be earlier than end-before.", fg="red"))
return
plan_end_before = end_before or datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=before_days)
if workers < 1:
click.echo(click.style("workers must be at least 1.", fg="red"))
return
if (run_shard_index is None) ^ (run_shard_total is None):
click.echo(click.style("run-shard-index and run-shard-total must be provided together.", fg="red"))
return
if run_shard_index is not None and run_shard_total is not None and run_shard_index >= run_shard_total:
click.echo(click.style("run-shard-index must be less than run-shard-total.", fg="red"))
return
if delete_after_archive:
click.echo(click.style("delete-after-archive is not supported by bundle archive.", fg="red"))
return
if uses_relative_window:
click.echo(
click.style(
"Relative archive windows are evaluated at command start. For multi-day prefix/shard rollout, "
"reuse absolute --start-from/--end-before values from archive-workflow-runs-plan.",
fg="yellow",
)
)
try:
tenant_plan = _resolve_archive_tenant_ids_from_plan(
tenant_ids=tenant_ids,
tenant_prefixes=parsed_tenant_prefixes,
start_from=start_from,
end_before=plan_end_before,
)
except Exception:
logger.exception("Failed to resolve workflow archive tenant plan")
click.echo(click.style("Failed to resolve workflow archive tenant plan.", fg="red"))
return
planned_tenant_ids = tenant_plan["archive_tenant_ids"]
planned_paid_tenant_ids = tenant_plan["paid_tenant_ids"] if planned_tenant_ids is not None else None
paid_tenants = len(tenant_plan["paid_tenant_ids"])
unpaid_tenants = len(tenant_plan["unpaid_tenant_ids"])
if planned_tenant_ids is not None:
click.echo(
click.style(
f"Resolved archive tenant plan: paid_tenants={paid_tenants}, unpaid_tenants={unpaid_tenants}.",
fg="white",
)
)
if not planned_tenant_ids:
click.echo(click.style("No paid tenants matched the archive plan; nothing to archive.", fg="yellow"))
return
archiver = WorkflowRunArchiver(
days=before_days,
@@ -607,11 +242,7 @@ def archive_workflow_runs(
start_from=start_from,
end_before=end_before,
workers=workers,
tenant_ids=planned_tenant_ids,
tenant_prefixes=parsed_tenant_prefixes,
paid_tenant_ids=planned_paid_tenant_ids,
run_shard_index=run_shard_index,
run_shard_total=run_shard_total,
tenant_ids=[tid.strip() for tid in tenant_ids.split(",")] if tenant_ids else None,
limit=limit,
dry_run=dry_run,
delete_after_archive=delete_after_archive,
@@ -621,9 +252,7 @@ def archive_workflow_runs(
click.style(
f"Summary: processed={summary.total_runs_processed}, archived={summary.runs_archived}, "
f"skipped={summary.runs_skipped}, failed={summary.runs_failed}, "
f"bundles_archived={summary.bundles_archived}, bundles_skipped={summary.bundles_skipped}, "
f"bundles_failed={summary.bundles_failed}, "
f"object_size_bytes={summary.total_object_size_bytes}, time={summary.total_elapsed_time:.2f}s",
f"time={summary.total_elapsed_time:.2f}s",
fg="cyan",
)
)
@@ -639,52 +268,6 @@ def archive_workflow_runs(
)
def _echo_bundle_archive_operation_summary(summary) -> None:
status = "completed successfully" if summary.bundles_failed == 0 else "completed with failures"
fg = "green" if summary.bundles_failed == 0 else "red"
click.echo(
click.style(
f"{summary.operation} {status}. "
f"bundles_success={summary.bundles_succeeded} bundles_failed={summary.bundles_failed} "
f"runs={summary.runs_processed} rows={summary.rows_processed} "
f"archive_bytes={summary.archive_bytes} duration={summary.elapsed_time:.2f}s "
f"validation_time={summary.validation_time:.2f}s "
f"runs_per_second={summary.runs_per_second:.2f} rows_per_second={summary.rows_per_second:.2f} "
f"bytes_per_second={summary.bytes_per_second:.2f}",
fg=fg,
)
)
click.echo(click.style("table,row_count", fg="white"))
for table_name in [
"workflow_runs",
"workflow_app_logs",
"workflow_node_executions",
"workflow_node_execution_offload",
"workflow_pauses",
"workflow_pause_reasons",
"workflow_trigger_logs",
]:
click.echo(f"{table_name},{summary.table_counts.get(table_name, 0)}")
for result in summary.results:
if result.success:
click.echo(
click.style(
f" bundle={result.bundle_id} tenant={result.tenant_id} runs={result.run_count} "
f"rows={result.row_count} archive_bytes={result.archive_bytes} "
f"time={result.elapsed_time:.2f}s validation={result.validation_time:.2f}s",
fg="white",
)
)
else:
click.echo(
click.style(
f" failed bundle={result.bundle_id} tenant={result.tenant_id} "
f"object_prefix={result.object_prefix} error={result.error}",
fg="red",
)
)
@click.command(
"restore-workflow-runs",
help="Restore archived workflow runs from S3-compatible storage.",
@@ -707,8 +290,8 @@ def _echo_bundle_archive_operation_summary(summary) -> None:
default=None,
help="Optional upper bound (exclusive) for created_at; must be paired with --start-from.",
)
@click.option("--workers", default=1, show_default=True, type=int, help="V1 --run-id compatibility only.")
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of V2 bundles to restore.")
@click.option("--workers", default=1, show_default=True, type=int, help="Concurrent workflow runs to restore.")
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of runs to restore.")
@click.option("--dry-run", is_flag=True, help="Preview without restoring.")
def restore_workflow_runs(
tenant_ids: str | None,
@@ -720,18 +303,15 @@ def restore_workflow_runs(
dry_run: bool,
):
"""
Restore archived workflow runs from storage to the database.
Restore an archived workflow run from storage to the database.
Batch restore uses V2 bundle metadata and validates archive objects before writing source rows. This restores:
- workflow_runs
- workflow_app_logs
This restores the following tables:
- workflow_node_executions
- workflow_node_execution_offload
- workflow_pauses
- workflow_pause_reasons
- workflow_trigger_logs
"""
from services.retention.workflow_run.bundle_archive_maintenance import WorkflowRunBundleArchiveMaintenance
from services.retention.workflow_run.restore_archived_workflow_run import WorkflowRunRestore
parsed_tenant_ids = None
@@ -755,46 +335,39 @@ def restore_workflow_runs(
)
)
restorer = WorkflowRunRestore(dry_run=dry_run, workers=workers)
if run_id:
restorer = WorkflowRunRestore(dry_run=dry_run, workers=workers)
results = [restorer.restore_by_run_id(run_id)]
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
else:
assert start_from is not None
assert end_before is not None
results = restorer.restore_batch(
parsed_tenant_ids,
start_date=start_from,
end_date=end_before,
limit=limit,
)
successes = sum(1 for result in results if result.success)
failures = len(results) - successes
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
if failures == 0:
click.echo(
click.style(
f"Restore completed successfully. success={successes} duration={elapsed}",
fg="green",
)
successes = sum(1 for result in results if result.success)
failures = len(results) - successes
if failures == 0:
click.echo(
click.style(
f"Restore completed successfully. success={successes} duration={elapsed}",
fg="green",
)
else:
click.echo(
click.style(
f"Restore completed with failures. success={successes} failed={failures} duration={elapsed}",
fg="red",
)
)
else:
click.echo(
click.style(
f"Restore completed with failures. success={successes} failed={failures} duration={elapsed}",
fg="red",
)
return
if workers != 1:
click.echo(
click.style("--workers is ignored for V2 bundle restore; bundles are processed serially.", fg="yellow")
)
assert start_from is not None
assert end_before is not None
bundle_restorer = WorkflowRunBundleArchiveMaintenance(dry_run=dry_run, strict_content_validation=True)
summary = bundle_restorer.restore_batch(
tenant_ids=parsed_tenant_ids,
start_date=start_from,
end_date=end_before,
limit=limit,
)
_echo_bundle_archive_operation_summary(summary)
return
@click.command(
@@ -819,20 +392,8 @@ def restore_workflow_runs(
default=None,
help="Optional upper bound (exclusive) for created_at; must be paired with --start-from.",
)
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of V2 bundles to delete.")
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of runs to delete.")
@click.option("--dry-run", is_flag=True, help="Preview without deleting.")
@click.option(
"--skip-bad-archives",
is_flag=True,
help="Continue batch deletion when one archive object fails validation.",
)
@click.option(
"--restore-sample-interval",
type=int,
default=0,
show_default=True,
help="Run restore dry-run after every N successful deletes; 0 disables restore sampling.",
)
def delete_archived_workflow_runs(
tenant_ids: str | None,
run_id: str | None,
@@ -840,16 +401,10 @@ def delete_archived_workflow_runs(
end_before: datetime.datetime | None,
limit: int,
dry_run: bool,
skip_bad_archives: bool,
restore_sample_interval: int,
):
"""
Delete archived workflow runs from the database.
Batch delete uses V2 bundle metadata and validates object existence, manifest schema, object size, checksum, row
counts, and source/archive content checksums before deleting source rows. `--run-id` keeps the V1 per-run path.
"""
from services.retention.workflow_run.bundle_archive_maintenance import WorkflowRunBundleArchiveMaintenance
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
parsed_tenant_ids = None
@@ -862,8 +417,6 @@ def delete_archived_workflow_runs(
raise click.UsageError("--start-from and --end-before must be provided together.")
if run_id is None and (start_from is None or end_before is None):
raise click.UsageError("--start-from and --end-before are required for batch delete.")
if restore_sample_interval < 0:
raise click.BadParameter("restore-sample-interval must be >= 0")
start_time = datetime.datetime.now(datetime.UTC)
target_desc = f"workflow run {run_id}" if run_id else "workflow runs"
@@ -874,85 +427,56 @@ def delete_archived_workflow_runs(
)
)
deleter = ArchivedWorkflowRunDeletion(dry_run=dry_run)
if run_id:
deleter = ArchivedWorkflowRunDeletion(
dry_run=dry_run,
skip_bad_archives=skip_bad_archives,
restore_sample_interval=restore_sample_interval,
)
results = [deleter.delete_by_run_id(run_id)]
for result in results:
if result.success:
click.echo(
click.style(
f"{'[DRY RUN] Would delete' if dry_run else 'Deleted'} "
f"workflow run {result.run_id} (tenant={result.tenant_id}, "
f"archive_key={result.archive_key}, counts={result.validated_counts})",
fg="green",
)
)
if result.restore_sampled:
sample_status = "passed" if result.restore_sample_success else "failed"
click.echo(
click.style(
f" restore dry-run sample {sample_status} for workflow run {result.run_id}",
fg="green" if result.restore_sample_success else "red",
)
)
else:
click.echo(
click.style(
f"Failed to delete workflow run {result.run_id}: {result.error}",
fg="red",
)
)
click.echo(
click.style(
" runbook: pause this delete window, verify archive storage object and manifest/checksum, "
"retry the same run after fixing storage or DB drift, or rerun with --skip-bad-archives "
"to quarantine this run and continue the batch.",
fg="yellow",
)
)
else:
assert start_from is not None
assert end_before is not None
results = deleter.delete_batch(
parsed_tenant_ids,
start_date=start_from,
end_date=end_before,
limit=limit,
)
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
successes = sum(1 for result in results if result.success)
failures = len(results) - successes
if failures == 0:
for result in results:
if result.success:
click.echo(
click.style(
f"Delete completed successfully. success={successes} duration={elapsed}",
f"{'[DRY RUN] Would delete' if dry_run else 'Deleted'} "
f"workflow run {result.run_id} (tenant={result.tenant_id})",
fg="green",
)
)
else:
click.echo(
click.style(
f"Delete completed with failures. success={successes} failed={failures} duration={elapsed}",
f"Failed to delete workflow run {result.run_id}: {result.error}",
fg="red",
)
)
return
if restore_sample_interval:
click.echo(click.style("--restore-sample-interval is ignored for V2 bundle delete.", fg="yellow"))
assert start_from is not None
assert end_before is not None
bundle_deleter = WorkflowRunBundleArchiveMaintenance(
dry_run=dry_run,
strict_content_validation=True,
stop_on_error=not skip_bad_archives,
)
summary = bundle_deleter.delete_batch(
tenant_ids=parsed_tenant_ids,
start_date=start_from,
end_date=end_before,
limit=limit,
)
_echo_bundle_archive_operation_summary(summary)
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
successes = sum(1 for result in results if result.success)
failures = len(results) - successes
if failures == 0:
click.echo(
click.style(
f"Delete completed successfully. success={successes} duration={elapsed}",
fg="green",
)
)
else:
click.echo(
click.style(
f"Delete completed with failures. success={successes} failed={failures} duration={elapsed}",
fg="red",
)
)
def _find_orphaned_draft_variables(batch_size: int = 1000) -> list[str]:
+2 -3
View File
@@ -13,7 +13,6 @@ from core.rag.index_processor.constant.built_in_field import BuiltInField
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from core.rag.models.document import ChildDocument, Document
from extensions.ext_database import db
from libs.pagination import paginate_query
from models.dataset import Dataset, DatasetCollectionBinding, DatasetMetadata, DatasetMetadataBinding, DocumentSegment
from models.dataset import Document as DatasetDocument
from models.enums import DatasetMetadataType, IndexingStatus, SegmentStatus
@@ -184,7 +183,7 @@ def migrate_knowledge_vector_database():
.order_by(Dataset.created_at.desc())
)
datasets = paginate_query(stmt, page=page, per_page=50, max_per_page=50)
datasets = db.paginate(select=stmt, page=page, per_page=50, max_per_page=50, error_out=False)
if not datasets.items:
break
except SQLAlchemyError:
@@ -410,7 +409,7 @@ def old_metadata_migration():
.where(DatasetDocument.doc_metadata.is_not(None))
.order_by(DatasetDocument.created_at.desc())
)
documents = paginate_query(stmt, page=page, per_page=50, max_per_page=50)
documents = db.paginate(select=stmt, page=page, per_page=50, max_per_page=50, error_out=False)
except SQLAlchemyError:
raise
if not documents:
-11
View File
@@ -29,17 +29,6 @@ class EnterpriseFeatureConfig(BaseSettings):
"This helps gain runtime performance by trading off consistency.",
)
RBAC_ENABLED: bool = Field(
description="Enable enterprise RBAC APIs. When disabled, compatibility responses fall back to legacy roles.",
default=False,
)
ENTERPRISE_RBAC_REQUEST_TIMEOUT: int = Field(
ge=1,
description="Maximum timeout in seconds for inner RBAC requests.",
default=30,
)
class EnterpriseTelemetryConfig(BaseSettings):
"""
+1 -9
View File
@@ -1,4 +1,4 @@
from pydantic import Field, NonNegativeFloat
from pydantic import Field
from pydantic_settings import BaseSettings
@@ -31,11 +31,3 @@ class AgentBackendConfig(BaseSettings):
),
default=False,
)
AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS: NonNegativeFloat = Field(
description=(
"Buffer Agent App assistant text deltas for up to this many seconds before "
"publishing SSE chunks. Set to 0 to publish each delta immediately."
),
default=0.5,
)
+2 -15
View File
@@ -943,10 +943,9 @@ class AuthConfig(BaseSettings):
default=True,
)
OPENAPI_RATE_LIMIT_PER_TOKEN: NonNegativeInt = Field(
OPENAPI_RATE_LIMIT_PER_TOKEN: PositiveInt = Field(
description="Per-token rate limit on /openapi/v1/* (requests per minute). "
"Bucket keyed on sha256(token), shared across api replicas via Redis. "
"Set to 0 to disable the per-token limit entirely.",
"Bucket keyed on sha256(token), shared across api replicas via Redis.",
default=60,
)
@@ -1073,12 +1072,6 @@ class MailConfig(BaseSettings):
default=None,
)
class HomepageConfig(BaseSettings):
"""
Configuration for homepage feature toggles exposed through system features.
"""
ENABLE_TRIAL_APP: bool = Field(
description="Enable trial app",
default=False,
@@ -1089,11 +1082,6 @@ class HomepageConfig(BaseSettings):
default=False,
)
ENABLE_LEARN_APP: bool = Field(
description="Enable Learn App",
default=True,
)
class RagEtlConfig(BaseSettings):
"""
@@ -1500,7 +1488,6 @@ class FeatureConfig(
EndpointConfig,
FileAccessConfig,
FileUploadConfig,
HomepageConfig,
HttpConfig,
InnerAPIConfig,
IndexingConfig,
+19
View File
@@ -2,6 +2,7 @@ from typing import Literal, Protocol, cast
from urllib.parse import quote_plus, urlunparse
from pydantic import AliasChoices, Field
from pydantic.types import NonNegativeInt
from pydantic_settings import BaseSettings
@@ -70,6 +71,24 @@ class RedisPubSubConfig(BaseSettings):
default=600,
)
PUBSUB_LISTENER_JOIN_TIMEOUT_MS: NonNegativeInt = Field(
validation_alias=AliasChoices("EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS", "PUBSUB_LISTENER_JOIN_TIMEOUT_MS"),
description=(
"Maximum time (milliseconds) that ``Subscription.close()`` waits for its listener thread to "
"finish before returning. Bounds the tail latency between a terminal event being delivered to "
"an SSE client and the response stream actually closing.\n\n"
"The listener thread blocks on a polling read (XREAD BLOCK for streams, get_message timeout "
"for pubsub/sharded) with a fixed 1s window, so close() naturally has to wait up to ~1s for "
"the thread to notice the subscription was closed. Setting this lower (e.g. 100) lets close() "
"return promptly while the daemon listener thread cleans itself up on the next poll "
"boundary - safe because the listener holds no critical state and exits within one poll "
"window. Setting it higher (e.g. 5000) gives the listener more grace before close() gives up "
"and logs a warning. Default 2000ms preserves the pre-change behaviour.\n\n"
"Also accepts ENV: EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS."
),
default=2000,
)
def _build_default_pubsub_url(self) -> str:
defaults = _redis_defaults(self)
if not defaults.REDIS_HOST or not defaults.REDIS_PORT:
@@ -20,12 +20,6 @@ class NacosHttpClient:
self.token: str | None = None
self.token_ttl = 18000
self.token_expire_time: float = 0
# Bounded timeouts so a slow or unresponsive Nacos server cannot hang the API
# service indefinitely during startup or token refresh.
self.timeout = httpx.Timeout(
float(os.getenv("DIFY_ENV_NACOS_REQUEST_TIMEOUT", "10.0")),
connect=float(os.getenv("DIFY_ENV_NACOS_CONNECT_TIMEOUT", "3.0")),
)
def http_request(
self, url: str, method: str = "GET", headers: dict[str, str] | None = None, params: dict[str, str] | None = None
@@ -34,17 +28,12 @@ class NacosHttpClient:
headers = {}
if params is None:
params = {}
full_url = "http://" + self.server + url
try:
self._inject_auth_info(headers, params)
response = httpx.request(method, url=full_url, headers=headers, params=params, timeout=self.timeout)
response = httpx.request(method, url="http://" + self.server + url, headers=headers, params=params)
response.raise_for_status()
return response.text
except httpx.TimeoutException as e:
logger.warning("Request to Nacos timed out (url=%s, timeout=%s): %s", full_url, self.timeout, e)
return f"Request to Nacos timed out: {e}"
except httpx.RequestError as e:
logger.warning("Request to Nacos failed (url=%s): %s", full_url, e)
return f"Request to Nacos failed: {e}"
def _inject_auth_info(self, headers: dict[str, str], params: dict[str, str], module: str = "config") -> None:
@@ -89,16 +78,13 @@ class NacosHttpClient:
params = {"username": self.username, "password": self.password}
url = "http://" + self.server + "/nacos/v1/auth/login"
try:
resp = httpx.request("POST", url, headers=None, params=params, timeout=self.timeout)
resp = httpx.request("POST", url, headers=None, params=params)
resp.raise_for_status()
response_data = resp.json()
self.token = response_data.get("accessToken")
self.token_ttl = response_data.get("tokenTtl", 18000)
self.token_expire_time = current_time + self.token_ttl - 10
return self.token
except httpx.TimeoutException:
logger.exception("[get-access-token] request to Nacos timed out (url=%s, timeout=%s)", url, self.timeout)
raise
except Exception:
logger.exception("[get-access-token] exception occur")
raise
File diff suppressed because one or more lines are too long
+9 -5
View File
@@ -7,6 +7,7 @@ consumes injected context managers when it needs to preserve thread-local state.
import contextvars
import threading
from abc import ABC, abstractmethod
from collections.abc import Callable, Generator
from contextlib import AbstractContextManager, contextmanager
from typing import Any, Protocol, final, override, runtime_checkable
@@ -14,25 +15,28 @@ from typing import Any, Protocol, final, override, runtime_checkable
from pydantic import BaseModel
class AppContext(Protocol):
class AppContext(ABC):
"""
Application context interface.
Abstract application context interface.
Application adapters can implement this to restore framework-specific state
such as Flask app context around worker execution.
"""
@abstractmethod
def get_config(self, key: str, default: Any = None) -> Any:
"""Get configuration value by key."""
...
raise NotImplementedError
@abstractmethod
def get_extension(self, name: str) -> Any:
"""Get application extension by name."""
...
raise NotImplementedError
@abstractmethod
def enter(self) -> AbstractContextManager[None]:
"""Enter the application context."""
...
raise NotImplementedError
@runtime_checkable
-107
View File
@@ -1,107 +0,0 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
from services.enterprise import rbac_service as enterprise_rbac_service
if TYPE_CHECKING:
from services.app_service import AppListBaseParams
from services.enterprise.rbac_service import MyPermissionsResponse
# Permission keys (dot-notation, from MyPermissionsResponse) that grant
# list/preview access to an app. Keep this the single source of truth for both
# the console and OpenAPI app-list endpoints.
APP_LIST_PERMISSION_KEYS: frozenset[str] = frozenset({"app.preview", "app.acl.preview", "app.full_access"})
# Workspace permission key that lets a caller see apps they maintain even when
# those apps are not in their preview whitelist.
_MANAGE_OWN_APPS_PERMISSION_KEY = "app.create_and_management"
def has_app_list_permission(permission_keys: Sequence[str]) -> bool:
"""Return True if any of ``permission_keys`` grants app list/preview access."""
return any(permission_key in APP_LIST_PERMISSION_KEYS for permission_key in permission_keys)
@dataclass(frozen=True)
class AppAccessFilter:
"""Resolved RBAC visibility for app list/read endpoints.
``accessible_app_ids`` of ``None`` means the caller can see every app in the
workspace (unrestricted). Otherwise it is the exact set of app ids the
caller may preview; combined with ``can_manage_own_apps`` it also covers
apps the caller maintains.
"""
accessible_app_ids: set[str] | None
can_manage_own_apps: bool
@classmethod
def unrestricted(cls) -> AppAccessFilter:
"""Filter that imposes no restriction (RBAC disabled / not applicable)."""
return cls(accessible_app_ids=None, can_manage_own_apps=False)
def is_app_accessible(self, app_id: str, maintainer: str | None, account_id: str) -> bool:
"""Whether a single app is visible to the caller under this filter.
Mirrors the service-layer query gate: an app is visible when the filter
is unrestricted, the app id is whitelisted, or the caller maintains it
and holds ``app.create_and_management``.
"""
if self.accessible_app_ids is None:
return True
if app_id in self.accessible_app_ids:
return True
return self.can_manage_own_apps and maintainer is not None and maintainer == account_id
def apply_to_params(self, params: AppListBaseParams) -> None:
if self.accessible_app_ids is None:
return
params.accessible_app_ids = sorted(self.accessible_app_ids)
params.include_own_apps = self.can_manage_own_apps
def resolve_app_access_filter(
tenant_id: str,
account_id: str,
*,
permissions: MyPermissionsResponse | None = None,
) -> AppAccessFilter:
"""Compute the RBAC app-access filter for ``account_id`` in ``tenant_id``.
Pass ``permissions`` when the caller has already fetched the snapshot (the
console controller reuses it for per-app permission keys) to avoid a second
inner-API round trip; otherwise it is fetched here.
"""
if permissions is None:
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(tenant_id, account_id)
whitelist_scope = enterprise_rbac_service.RBACService.AppAccess.whitelist_resources(tenant_id, account_id)
can_manage_own_apps = _MANAGE_OWN_APPS_PERMISSION_KEY in permissions.workspace.permission_keys
has_default_preview = has_app_list_permission(permissions.app.default_permission_keys) or has_app_list_permission(
permissions.workspace.permission_keys
)
permission_app_ids: set[str] | None = None
if not has_default_preview:
# Collect apps the caller can preview via per-app permission overrides.
permission_app_ids = {
override.resource_id
for override in permissions.app.overrides
if has_app_list_permission(override.permission_keys)
}
accessible_app_ids: set[str] | None
if getattr(whitelist_scope, "unrestricted", False):
accessible_app_ids = permission_app_ids
else:
accessible_app_ids = set(whitelist_scope.resource_ids)
if permission_app_ids is not None:
accessible_app_ids |= permission_app_ids
elif has_default_preview:
# Default preview overrides the whitelist restriction.
accessible_app_ids = None
return AppAccessFilter(accessible_app_ids=accessible_app_ids, can_manage_own_apps=can_manage_own_apps)
+19 -147
View File
@@ -1,8 +1,7 @@
from copy import deepcopy
from typing import Annotated, Any, Literal, override
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, GetJsonSchemaHandler, WithJsonSchema, model_validator
from pydantic import BaseModel, Field, model_validator
from libs.helper import UUIDStrOrEmpty
@@ -10,53 +9,8 @@ from libs.helper import UUIDStrOrEmpty
class ConversationRenamePayload(BaseModel):
name: str | None = Field(
default=None,
description="Conversation name. Required when `auto_generate` is `false`.",
)
auto_generate: bool = Field(
default=False,
description="Automatically generate the conversation name. When `true`, the `name` field is ignored.",
)
@classmethod
@override
def __get_pydantic_json_schema__(cls, core_schema: Any, handler: GetJsonSchemaHandler) -> dict[str, Any]:
schema = handler.resolve_ref_schema(handler(core_schema))
properties = schema.get("properties")
if not isinstance(properties, dict):
return schema
auto_generate_schema = deepcopy(properties.get("auto_generate", {"type": "boolean"}))
name_schema = deepcopy(properties.get("name", {"type": "string"}))
non_blank_name_schema: dict[str, Any] = {"pattern": r".*\S.*", "type": "string"}
if isinstance(name_schema, dict) and isinstance(name_schema.get("title"), str):
non_blank_name_schema["title"] = name_schema["title"]
auto_generate_true_schema = {**auto_generate_schema, "enum": [True]}
auto_generate_true_schema.pop("default", None)
return {
**schema,
"anyOf": [
{
"properties": {
"auto_generate": auto_generate_true_schema,
"name": name_schema,
},
"required": ["auto_generate"],
"type": "object",
},
{
"properties": {
"auto_generate": {**auto_generate_schema, "enum": [False]},
"name": non_blank_name_schema,
},
"required": ["name"],
"type": "object",
},
],
}
name: str | None = None
auto_generate: bool = False
@model_validator(mode="after")
def validate_name_requirement(self):
@@ -70,28 +24,14 @@ class ConversationRenamePayload(BaseModel):
class MessageListQuery(BaseModel):
conversation_id: UUIDStrOrEmpty = Field(description="Conversation ID.")
first_id: UUIDStrOrEmpty | None = Field(
default=None,
description=(
"The ID of the first chat record on the current page. Omit this value to fetch the latest messages; "
"for subsequent pages, use the first message ID from the current list to fetch older messages."
),
)
limit: int = Field(
default=20,
ge=1,
le=100,
description="Number of chat history messages to return per request.",
)
conversation_id: UUIDStrOrEmpty = Field(description="Conversation UUID")
first_id: UUIDStrOrEmpty | None = Field(default=None, description="First message ID for pagination")
limit: int = Field(default=20, ge=1, le=100, description="Number of messages to return (1-100)")
class MessageFeedbackPayload(BaseModel):
rating: Literal["like", "dislike"] | None = Field(
default=None,
description="Feedback rating. Set to `null` to revoke previously submitted feedback.",
)
content: str | None = Field(default=None, description="Optional text feedback providing additional detail.")
rating: Literal["like", "dislike"] | None = None
content: str | None = None
# --- Saved message schemas ---
@@ -108,39 +48,6 @@ class SavedMessageCreatePayload(BaseModel):
# --- Workflow schemas ---
WORKFLOW_INPUT_FILE_ITEM_SCHEMA: dict[str, object] = {
"type": "object",
"required": ["type", "transfer_method"],
"properties": {
"type": {
"description": "File type.",
"enum": ["document", "image", "audio", "video", "custom"],
"type": "string",
},
"transfer_method": {
"description": "Transfer method: `remote_url` for file URL, `local_file` for uploaded file.",
"enum": ["remote_url", "local_file"],
"type": "string",
},
"url": {
"description": "File URL when `transfer_method` is `remote_url`.",
"format": "url",
"type": "string",
},
"upload_file_id": {
"description": (
"Uploaded file ID obtained from the [Upload File](/api-reference/files/upload-file) API when "
"`transfer_method` is `local_file`."
),
"type": "string",
},
},
}
WORKFLOW_INPUT_FILE_LIST_SCHEMA: dict[str, object] = {
"anyOf": [{"items": WORKFLOW_INPUT_FILE_ITEM_SCHEMA, "type": "array"}, {"type": "null"}]
}
WorkflowInputFileList = Annotated[list[dict[str, Any]] | None, WithJsonSchema(WORKFLOW_INPUT_FILE_LIST_SCHEMA)]
class DefaultBlockConfigQuery(BaseModel):
q: str | None = None
@@ -154,22 +61,8 @@ class WorkflowListQuery(BaseModel):
class WorkflowRunPayload(BaseModel):
inputs: dict[str, Any] = Field(
description=(
"Key-value pairs for workflow input variables. Values for file-type variables should be arrays of "
"file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the "
"`user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) "
"response to discover the variable names and types expected by your app."
)
)
files: WorkflowInputFileList = Field(
default=None,
description=(
"File list for workflow system file inputs. Available when file upload is enabled for the workflow. "
"To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use "
"the returned `id` as `upload_file_id` with `transfer_method: local_file`."
),
)
inputs: dict[str, Any]
files: list[dict[str, Any]] | None = None
class WorkflowUpdatePayload(BaseModel):
@@ -184,49 +77,28 @@ DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
class ChildChunkCreatePayload(BaseModel):
content: str = Field(description="Child chunk text content.")
content: str
class ChildChunkUpdatePayload(BaseModel):
content: str = Field(description="Child chunk text content.")
content: str
class DocumentBatchDownloadZipPayload(BaseModel):
"""Request payload for bulk downloading documents as a zip archive."""
document_ids: list[UUID] = Field(
...,
min_length=1,
max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS,
description="List of document IDs to include in the ZIP download.",
)
document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS)
class MetadataUpdatePayload(BaseModel):
name: str = Field(description="New metadata field name.")
name: str
# --- Audio schemas ---
UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})]
class TextToAudioPayload(BaseModel):
message_id: UUIDString | None = Field(
default=None,
description="Message ID. Takes priority over `text` when both are provided.",
)
voice: str | None = Field(
default=None,
description=(
"Voice to use for text-to-speech. Available voices depend on the TTS provider configured for this app. "
"Omit to use the app's configured voice when available; that value is exposed by "
"[Get App Parameters](/api-reference/applications/get-app-parameters) as `text_to_speech.voice`."
),
)
text: str | None = Field(default=None, description="Speech content to convert.")
streaming: bool | None = Field(
default=None,
description="Reserved for compatibility; TTS response streaming is determined by the provider output.",
)
message_id: str | None = Field(default=None, description="Message ID")
voice: str | None = Field(default=None, description="Voice to use for TTS")
text: str | None = Field(default=None, description="Text to convert to audio")
streaming: bool | None = Field(default=None, description="Enable streaming response")
-10
View File
@@ -41,13 +41,3 @@ class NoFileUploadedError(BaseHTTPException):
error_code = "no_file_uploaded"
description = "Please upload your file."
code = 400
class NotFoundError(BaseHTTPException):
error_code = "not_found"
code = 404
class InvalidArgumentError(BaseHTTPException):
error_code = "invalid_param"
code = 400
+1 -31
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, RootModel, computed_field
from pydantic import BaseModel, ConfigDict, Field, computed_field
from fields.base import ResponseModel
from graphon.file import helpers as file_helpers
@@ -24,34 +24,6 @@ class SimpleResultResponse(ResponseModel):
result: str
class GeneratedAppResponse(RootModel[JSONValue]):
root: JSONValue
class EventStreamResponse(RootModel[str]):
root: str
class TextFileResponse(RootModel[str]):
root: str
class RedirectResponse(RootModel[str]):
root: str
class BinaryFileResponse(RootModel[bytes]):
root: bytes
class AudioBinaryResponse(RootModel[bytes]):
root: bytes
class AudioTranscriptResponse(ResponseModel):
text: str
class SimpleResultMessageResponse(ResponseModel):
result: str
message: str
@@ -153,7 +125,6 @@ class ApiBaseUrlResponse(ResponseModel):
class NewAppResponse(ResponseModel):
new_app_id: str
permission_keys: list[str] = Field(default_factory=list)
class Parameters(BaseModel):
@@ -183,7 +154,6 @@ class Site(BaseModel):
description: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
input_placeholder: str | None = None
custom_disclaimer: str | None = None
default_language: str
show_workflow_steps: bool
+7 -13
View File
@@ -35,23 +35,17 @@ class HumanInputFormSubmitPayload(BaseModel):
),
examples=[HUMAN_INPUT_FORM_INPUT_EXAMPLE],
)
action: str = Field(
description=(
"ID of the action button the recipient selected. Must match one of the `id` values from the form's "
"`user_actions` list."
)
)
action: str
def stringify_form_default_values(values: dict[str, object]) -> dict[str, str]:
"""Serialize default values into strings expected by human-input form clients."""
result: dict[str, str] = {}
for key, value in values.items():
match value:
case None:
result[key] = ""
case dict() | list():
result[key] = json.dumps(value, ensure_ascii=False)
case _:
result[key] = str(value)
if value is None:
result[key] = ""
elif isinstance(value, (dict, list)):
result[key] = json.dumps(value, ensure_ascii=False)
else:
result[key] = str(value)
return result
+44 -80
View File
@@ -1,9 +1,9 @@
"""Helpers for registering Pydantic models with Flask-RESTX namespaces.
Flask-RESTX treats `SchemaModel` bodies as opaque JSON schemas; it does not
promote Pydantic's nested `$defs` into top-level OpenAPI component schemas.
promote Pydantic's nested `$defs` into top-level Swagger `definitions`.
These helpers keep that translation centralized so models registered through
`register_schema_models` emit resolvable OpenAPI 3 references.
`register_schema_models` emit resolvable Swagger 2.0 references.
"""
from collections.abc import Iterable, Mapping
@@ -14,7 +14,7 @@ from flask import request
from flask_restx import Namespace
from pydantic import BaseModel, TypeAdapter
DEFAULT_REF_TEMPLATE_OPENAPI_3_0 = "#/components/schemas/{model}"
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
QueryParamDoc = TypedDict(
@@ -27,18 +27,12 @@ QueryParamDoc = TypedDict(
"description": NotRequired[str],
"enum": NotRequired[list[object]],
"default": NotRequired[object],
"format": NotRequired[str],
"minimum": NotRequired[int | float],
"maximum": NotRequired[int | float],
"exclusiveMinimum": NotRequired[int | float],
"exclusiveMaximum": NotRequired[int | float],
"minLength": NotRequired[int],
"maxLength": NotRequired[int],
"pattern": NotRequired[str],
"minItems": NotRequired[int],
"maxItems": NotRequired[int],
"uniqueItems": NotRequired[bool],
"multipleOf": NotRequired[int | float],
},
)
@@ -54,6 +48,7 @@ class QueryArgs(Protocol):
def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None:
"""Register a JSON schema and promote any nested Pydantic `$defs`."""
schema = _swagger_2_compatible_schema(schema)
nested_definitions = schema.get("$defs")
schema_to_register = dict(schema)
if isinstance(nested_definitions, dict):
@@ -76,12 +71,41 @@ def _register_schema_model(namespace: Namespace, model: type[BaseModel], *, mode
_register_json_schema(
namespace,
model.__name__,
model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0, mode=mode),
model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0, mode=mode),
)
def _swagger_2_compatible_schema(value: Any) -> Any:
if isinstance(value, list):
return [_swagger_2_compatible_schema(item) for item in value]
if not isinstance(value, dict):
return value
converted = {key: _swagger_2_compatible_schema(child) for key, child in value.items()}
any_of = value.get("anyOf")
if not isinstance(any_of, list):
return converted
non_null_candidates = [
candidate for candidate in any_of if isinstance(candidate, Mapping) and candidate.get("type") != "null"
]
has_null_candidate = any(isinstance(candidate, Mapping) and candidate.get("type") == "null" for candidate in any_of)
if not has_null_candidate or len(non_null_candidates) != 1:
return converted
non_null_schema = _swagger_2_compatible_schema(dict(non_null_candidates[0]))
if not isinstance(non_null_schema, dict):
return converted
converted.pop("anyOf", None)
converted.update(non_null_schema)
converted["x-nullable"] = True
return converted
def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None:
"""Register a BaseModel and its nested component schemas for OpenAPI documentation."""
"""Register a BaseModel and its nested schema definitions for Swagger documentation."""
_register_schema_model(namespace, model, mode="validation")
@@ -122,7 +146,7 @@ def register_enum_models(namespace: Namespace, *models: type[StrEnum]) -> None:
_register_json_schema(
namespace,
model.__name__,
TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
@@ -131,11 +155,10 @@ def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]:
`Namespace.expect()` treats Pydantic schema models as request bodies, so GET
endpoints should keep runtime validation on the Pydantic model and feed this
derived mapping to `Namespace.doc(params=...)` for OpenAPI documentation.
derived mapping to `Namespace.doc(params=...)` for Swagger documentation.
"""
schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0)
definitions = _schema_definitions(schema)
schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
properties = schema.get("properties", {})
if not isinstance(properties, Mapping):
return {}
@@ -148,11 +171,7 @@ def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]:
if not isinstance(name, str) or not isinstance(property_schema, Mapping):
continue
params[name] = _query_param_from_property(
property_schema,
required=name in required_names,
definitions=definitions,
)
params[name] = _query_param_from_property(property_schema, required=name in required_names)
return params
@@ -184,7 +203,7 @@ def query_params_from_request[ModelT: BaseModel](
def _drop_malformed_defaulted_integer_params(model: type[BaseModel], params: dict[str, Any]) -> None:
properties = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0).get("properties", {})
properties = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0).get("properties", {})
if not isinstance(properties, Mapping):
return
@@ -209,18 +228,8 @@ def _drop_malformed_defaulted_integer_params(model: type[BaseModel], params: dic
params.pop(name)
def _schema_definitions(schema: Mapping[str, Any]) -> Mapping[str, Any]:
definitions = schema.get("$defs")
return definitions if isinstance(definitions, Mapping) else {}
def _query_param_from_property(
property_schema: Mapping[str, Any],
*,
required: bool,
definitions: Mapping[str, Any],
) -> QueryParamDoc:
param_schema = _resolve_schema_ref(_nullable_property_schema(property_schema), definitions)
def _query_param_from_property(property_schema: Mapping[str, Any], *, required: bool) -> QueryParamDoc:
param_schema = _nullable_property_schema(property_schema)
param_doc: QueryParamDoc = {"in": "query", "required": required}
description = param_schema.get("description")
@@ -233,16 +242,9 @@ def _query_param_from_property(
if schema_type == "array":
items = param_schema.get("items")
if isinstance(items, Mapping):
item_schema = _resolve_schema_ref(items, definitions)
item_type = item_schema.get("type")
item_type = items.get("type")
if isinstance(item_type, str):
param_doc["items"] = {"type": item_type}
item_enum = item_schema.get("enum")
if isinstance(item_enum, list):
param_doc.setdefault("items", {})["enum"] = item_enum
item_format = item_schema.get("format")
if isinstance(item_format, str):
param_doc.setdefault("items", {})["format"] = item_format
enum = param_schema.get("enum")
if isinstance(enum, list):
@@ -252,10 +254,6 @@ def _query_param_from_property(
if default is not None:
param_doc["default"] = default
schema_format = param_schema.get("format")
if isinstance(schema_format, str):
param_doc["format"] = schema_format
minimum = param_schema.get("minimum")
if isinstance(minimum, int | float):
param_doc["minimum"] = minimum
@@ -264,14 +262,6 @@ def _query_param_from_property(
if isinstance(maximum, int | float):
param_doc["maximum"] = maximum
exclusive_minimum = param_schema.get("exclusiveMinimum")
if isinstance(exclusive_minimum, int | float):
param_doc["exclusiveMinimum"] = exclusive_minimum
exclusive_maximum = param_schema.get("exclusiveMaximum")
if isinstance(exclusive_maximum, int | float):
param_doc["exclusiveMaximum"] = exclusive_maximum
min_length = param_schema.get("minLength")
if isinstance(min_length, int):
param_doc["minLength"] = min_length
@@ -280,10 +270,6 @@ def _query_param_from_property(
if isinstance(max_length, int):
param_doc["maxLength"] = max_length
pattern = param_schema.get("pattern")
if isinstance(pattern, str):
param_doc["pattern"] = pattern
min_items = param_schema.get("minItems")
if isinstance(min_items, int):
param_doc["minItems"] = min_items
@@ -292,31 +278,9 @@ def _query_param_from_property(
if isinstance(max_items, int):
param_doc["maxItems"] = max_items
unique_items = param_schema.get("uniqueItems")
if isinstance(unique_items, bool):
param_doc["uniqueItems"] = unique_items
multiple_of = param_schema.get("multipleOf")
if isinstance(multiple_of, int | float):
param_doc["multipleOf"] = multiple_of
return param_doc
def _resolve_schema_ref(property_schema: Mapping[str, Any], definitions: Mapping[str, Any]) -> Mapping[str, Any]:
ref = property_schema.get("$ref")
if not isinstance(ref, str):
return property_schema
ref_name = ref.rsplit("/", 1)[-1]
resolved = definitions.get(ref_name)
if not isinstance(resolved, Mapping):
return property_schema
property_without_ref = {key: value for key, value in property_schema.items() if key != "$ref"}
return {**resolved, **property_without_ref}
def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str, Any]:
any_of = property_schema.get("anyOf")
if not isinstance(any_of, list):
@@ -333,7 +297,7 @@ def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str
__all__ = [
"DEFAULT_REF_TEMPLATE_OPENAPI_3_0",
"DEFAULT_REF_TEMPLATE_SWAGGER_2_0",
"get_or_create_model",
"query_params_from_model",
"query_params_from_request",
-57
View File
@@ -1,57 +0,0 @@
"""Controller session decorators.
`with_session` is an HTTP controller helper: it opens one SQLAlchemy session
for a Resource handler and injects it as the first argument after `self`.
Handlers use a transaction by default so migrated write paths keep
commit/rollback handling; pure read handlers may opt out with `write=False`.
"""
from collections.abc import Callable
from functools import wraps
from typing import Concatenate, overload
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
@overload
def with_session[T, **P, R](
view: Callable[Concatenate[T, Session, P], R],
*,
write: bool = True,
) -> Callable[Concatenate[T, P], R]: ...
@overload
def with_session[T, **P, R](
view: None = None,
*,
write: bool = True,
) -> Callable[[Callable[Concatenate[T, Session, P], R]], Callable[Concatenate[T, P], R]]: ...
def with_session[T, **P, R](
view: Callable[Concatenate[T, Session, P], R] | None = None,
*,
write: bool = True,
) -> (
Callable[Concatenate[T, P], R] | Callable[[Callable[Concatenate[T, Session, P], R]], Callable[Concatenate[T, P], R]]
):
"""Inject a request-scoped session, using a transaction only for write handlers."""
def decorator(view: Callable[Concatenate[T, Session, P], R]) -> Callable[Concatenate[T, P], R]:
@wraps(view)
def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
if write:
with session_factory.get_session_maker().begin() as session:
return view(self, session, *args, **kwargs)
with session_factory.create_session() as session:
return view(self, session, *args, **kwargs)
return wrapper
if view is None:
return decorator
return decorator(view)
-166
View File
@@ -1,166 +0,0 @@
from collections.abc import Callable
from functools import wraps
from sqlalchemy import select
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from core.rbac import RBACPermission, RBACResourceScope
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models.dataset import Dataset
from models.model import App
from services.enterprise.rbac_service import RBACService
__all__ = ["RBACPermission", "RBACResourceScope", "enforce_rbac_access", "rbac_permission_required"]
def enforce_rbac_access(
*,
tenant_id: str,
account_id: str,
resource_type: RBACResourceScope,
scene: RBACPermission,
resource_required: bool = True,
path_args: dict[str, object] | None = None,
) -> None:
"""Enforce enterprise RBAC for an explicit account/tenant pair.
This is the flask-login-independent core of the RBAC gate so it can run
inside request-handling layers that resolve the caller themselves (e.g. the
openapi auth pipeline, which has the account on ``AuthData`` before
flask-login is mounted).
No-op when ``RBAC_ENABLED`` is ``False``. For resource-scoped checks the
resource ID is taken from ``path_args`` merged with ``request.view_args``;
resource ownership short-circuits the check. Raises ``Forbidden`` when
access is denied. For workspace-level checks pass ``resource_required=False``
so the RBAC request omits ``resource_id``.
Args:
tenant_id: The tenant the access is evaluated against.
account_id: The account requesting access.
resource_type: The :class:`RBACResourceScope` member (app/dataset/workspace).
scene: The :class:`RBACPermission` permission point, e.g. ``RBACPermission.APP_DELETE``.
resource_required: Whether a concrete resource ID is required.
path_args: Extra path arguments to merge with ``request.view_args``.
"""
if not dify_config.RBAC_ENABLED:
return
check_resource_type = None if resource_type == RBACResourceScope.WORKSPACE else resource_type
resource_id = None
if resource_required and check_resource_type:
resource_id = _extract_resource_id(resource_type, path_args)
if _is_resource_owned_by_current_user(tenant_id, account_id, resource_type, resource_id):
return
allowed = RBACService.CheckAccess.check(
tenant_id,
account_id,
scene=scene,
resource_type=check_resource_type,
resource_id=resource_id,
)
if not allowed:
raise Forbidden()
def rbac_permission_required[**P, R](
resource_type: RBACResourceScope,
scene: RBACPermission,
*,
resource_required: bool = True,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Check enterprise RBAC permissions for the current flask-login user.
When ``RBAC_ENABLED`` is ``False`` the decorator is a no-op and the
request passes through unchanged. When enabled it resolves the current
account/tenant and delegates to :func:`enforce_rbac_access`, raising
``Forbidden`` if access is denied.
Args:
resource_type: The :class:`RBACResourceScope` member (app/dataset/workspace).
scene: The :class:`RBACPermission` permission point, e.g. ``RBACPermission.APP_DELETE``.
resource_required: Whether a concrete resource ID is required.
"""
def decorator(view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
if not dify_config.RBAC_ENABLED:
return view(*args, **kwargs)
current_user, current_tenant_id = current_account_with_tenant()
enforce_rbac_access(
tenant_id=current_tenant_id,
account_id=current_user.id,
resource_type=resource_type,
scene=scene,
resource_required=resource_required,
path_args=kwargs,
)
return view(*args, **kwargs)
return decorated
return decorator
def _is_resource_owned_by_current_user(
tenant_id: str, account_id: str, resource_type: RBACResourceScope, resource_id: str
) -> bool:
if resource_type == RBACResourceScope.APP:
maintainer = db.session.scalar(
select(App.maintainer).where(
App.id == resource_id,
App.tenant_id == tenant_id,
App.status == "normal",
)
)
return maintainer == account_id
if resource_type == RBACResourceScope.DATASET:
maintainer = db.session.scalar(
select(Dataset.maintainer).where(
Dataset.id == resource_id,
Dataset.tenant_id == tenant_id,
)
)
return maintainer == account_id
return False
def _extract_resource_id(resource_type: RBACResourceScope, path_args: dict[str, object] | None = None) -> str:
"""Extract the resource ID from matched path arguments.
Some legacy route classes use neutral names such as ``resource_id`` for
app/dataset resources, and Agent App routes use ``agent_id`` as the app id.
Dataset endpoints behind a rag-pipeline route contain ``pipeline_id``
instead of ``dataset_id``. In that case we look up the associated
``Dataset`` row via ``Dataset.pipeline_id``.
"""
from flask import request
view_args = request.view_args or {}
matched_args = {**view_args, **(path_args or {})}
if resource_type == RBACResourceScope.APP:
app_id = matched_args.get("app_id") or matched_args.get("agent_id") or matched_args.get("resource_id")
if not app_id:
raise ValueError("Missing app_id in request path")
return str(app_id)
if resource_type == RBACResourceScope.DATASET:
dataset_id = matched_args.get("dataset_id") or matched_args.get("resource_id")
if dataset_id:
return str(dataset_id)
pipeline_id = matched_args.get("pipeline_id")
if pipeline_id:
dataset = db.session.scalar(select(Dataset).where(Dataset.pipeline_id == str(pipeline_id)))
if not dataset:
raise NotFound("Dataset not found for pipeline")
return str(dataset.id)
raise ValueError("Missing dataset_id or pipeline_id in request path")
raise ValueError(f"Unknown resource_type: {resource_type}")
+2 -8
View File
@@ -53,9 +53,7 @@ from .app import (
agent,
agent_app_access,
agent_app_feature,
agent_app_sandbox,
agent_config_inspector,
agent_drive_inspector,
agent_app_workspace,
annotation,
app,
audio,
@@ -140,7 +138,6 @@ from .workspace import (
model_providers,
models,
plugin,
rbac,
snippets,
tool_providers,
trigger_providers,
@@ -156,10 +153,8 @@ __all__ = [
"agent",
"agent_app_access",
"agent_app_feature",
"agent_app_sandbox",
"agent_app_workspace",
"agent_composer",
"agent_config_inspector",
"agent_drive_inspector",
"agent_providers",
"agent_roster",
"annotation",
@@ -215,7 +210,6 @@ __all__ = [
"rag_pipeline_draft_variable",
"rag_pipeline_import",
"rag_pipeline_workflow",
"rbac",
"recommended_app",
"saved_message",
"setup",
@@ -1,20 +0,0 @@
from uuid import UUID
from extensions.ext_database import db
from models.model import App
from services.agent.roster_service import AgentRosterService
def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App:
"""Resolve a roster Agent's public Agent App."""
return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
def resolve_agent_runtime_app_model(*, tenant_id: str, agent_id: UUID) -> App:
"""Resolve the App that backs an Agent runtime surface.
This accepts both roster Agent Apps and workflow-only inline Agents with a
hidden backing App.
"""
return AgentRosterService(db.session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
+29 -107
View File
@@ -1,17 +1,11 @@
from uuid import UUID
from flask import request
from flask_restx import Resource
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user_id,
@@ -28,15 +22,9 @@ from libs.login import login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import (
ComposerSavePayload,
WorkflowAgentComposerQuery,
WorkflowComposerCopyFromRosterPayload,
)
from services.entities.agent_entities import ComposerSavePayload
register_schema_models(
console_ns, ComposerSavePayload, WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload
)
register_schema_models(console_ns, ComposerSavePayload)
register_response_schema_models(
console_ns,
AgentAppComposerResponse,
@@ -52,23 +40,18 @@ class WorkflowAgentComposerApi(Resource):
@console_ns.response(
200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@console_ns.doc(params=query_params_from_model(WorkflowAgentComposerQuery))
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, account_id: str, app_model: App, node_id: str):
query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True))
def get(self, tenant_id: str, app_model: App, node_id: str):
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account_id,
snapshot_id=query.snapshot_id,
),
)
@@ -80,7 +63,6 @@ class WorkflowAgentComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_user_id
@with_current_tenant_id
@@ -98,38 +80,6 @@ class WorkflowAgentComposerApi(Resource):
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/copy-from-roster")
class WorkflowAgentComposerCopyFromRosterApi(Resource):
@console_ns.expect(console_ns.models[WorkflowComposerCopyFromRosterPayload.__name__])
@console_ns.response(
200,
"Workflow roster agent copied to inline agent",
console_ns.models[WorkflowAgentComposerResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, account_id: str, app_model: App, node_id: str):
payload = WorkflowComposerCopyFromRosterPayload.model_validate(console_ns.payload or {})
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.copy_workflow_composer_from_roster(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account_id,
source_agent_id=payload.source_agent_id,
source_snapshot_id=payload.source_snapshot_id,
idempotency_key=payload.idempotency_key,
),
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
class WorkflowAgentComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@@ -140,19 +90,10 @@ class WorkflowAgentComposerValidateApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_publish_payload(payload)
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
),
)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
@@ -164,17 +105,10 @@ class WorkflowAgentComposerCandidatesApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, app_model: App, node_id: str):
def get(self, app_model: App, node_id: str):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_workflow_candidates(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
user_id=current_user_id,
),
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
)
@@ -210,7 +144,6 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_user_id
@with_current_tenant_id
@@ -228,17 +161,18 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
)
@console_ns.route("/agent/<uuid:agent_id>/composer")
class AgentComposerApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
class AgentAppComposerApi(Resource):
@console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model()
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
def get(self, tenant_id: str, app_model: App):
return dump_response(
AgentAppComposerResponse,
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)),
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id),
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@@ -247,24 +181,24 @@ class AgentComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@get_app_model()
@with_current_user_id
@with_current_tenant_id
def put(self, tenant_id: str, account_id: str, agent_id: UUID):
def put(self, tenant_id: str, account_id: str, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
AgentAppComposerResponse,
AgentComposerService.save_agent_composer(
AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
agent_id=str(agent_id),
app_id=app_model.id,
account_id=account_id,
payload=payload,
),
)
@console_ns.route("/agent/<uuid:agent_id>/composer/validate")
class AgentComposerValidateApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
class AgentAppComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Agent app composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
@@ -272,36 +206,24 @@ class AgentComposerValidateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id))
@get_app_model()
def post(self, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_publish_payload(payload)
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
agent_id=str(agent_id),
)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
@console_ns.route("/agent/<uuid:agent_id>/composer/candidates")
class AgentComposerCandidatesApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
class AgentAppComposerCandidatesApi(Resource):
@console_ns.response(
200, "Agent app composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, agent_id: UUID):
@get_app_model()
def get(self, app_model: App):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(
tenant_id=tenant_id,
agent_id=str(agent_id),
user_id=current_user_id,
),
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
)
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -9,7 +9,6 @@ from sqlalchemy import delete, func, select
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.schema import register_response_schema_models
from extensions.ext_database import db
from fields.base import ResponseModel
@@ -23,11 +22,8 @@ from services.api_token_service import ApiTokenCache
from . import console_ns
from .wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
@@ -147,7 +143,7 @@ class BaseApiKeyResource(Resource):
assert self.resource_id_field is not None, "resource_id_field must be set"
_get_resource(resource_id, current_tenant_id, self.resource_model)
if not dify_config.RBAC_ENABLED and not current_user.is_admin_or_owner:
if not current_user.is_admin_or_owner:
raise Forbidden()
key = db.session.scalar(
@@ -190,7 +186,6 @@ class AppApiKeyListResource(BaseApiKeyListResource):
@console_ns.response(400, "Maximum keys exceeded")
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
"""Create a new API key for an app"""
return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201
@@ -209,7 +204,6 @@ class AppApiKeyResource(BaseApiKeyResource):
@console_ns.response(204, "API key deleted successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
def delete(
self, current_tenant_id: str, current_user: Account, resource_id: UUID, api_key_id: UUID
) -> tuple[str, int]:
@@ -240,7 +234,6 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
@console_ns.response(400, "Maximum keys exceeded")
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE)
def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
"""Create a new API key for a dataset"""
return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201
@@ -259,7 +252,6 @@ class DatasetApiKeyResource(BaseApiKeyResource):
@console_ns.response(204, "API key deleted successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE)
def delete(
self, current_tenant_id: str, current_user: Account, resource_id: UUID, api_key_id: UUID
) -> tuple[str, int]:
@@ -1,17 +1,9 @@
from typing import Any
from flask import request
from flask_restx import Resource
from flask_restx import Resource, fields
from pydantic import BaseModel, Field
from controllers.common.schema import (
DEFAULT_REF_TEMPLATE_OPENAPI_3_0,
query_params_from_model,
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, setup_required
from fields.base import ResponseModel
from libs.login import login_required
from services.advanced_prompt_template_service import AdvancedPromptTemplateArgs, AdvancedPromptTemplateService
@@ -23,27 +15,19 @@ class AdvancedPromptTemplateQuery(BaseModel):
model_name: str = Field(..., description="Model name")
class AdvancedPromptTemplateResponse(ResponseModel):
chat_prompt_config: dict[str, Any] | None = Field(default=None)
completion_prompt_config: dict[str, Any] | None = Field(default=None)
console_ns.schema_model(
AdvancedPromptTemplateQuery.__name__,
AdvancedPromptTemplateQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
AdvancedPromptTemplateQuery.model_json_schema(ref_template="#/definitions/{model}"),
)
register_response_schema_models(console_ns, AdvancedPromptTemplateResponse)
@console_ns.route("/app/prompt-templates")
class AdvancedPromptTemplateList(Resource):
@console_ns.doc("get_advanced_prompt_templates")
@console_ns.doc(description="Get advanced prompt templates based on app mode and model configuration")
@console_ns.doc(params=query_params_from_model(AdvancedPromptTemplateQuery))
@console_ns.expect(console_ns.models[AdvancedPromptTemplateQuery.__name__])
@console_ns.response(
200,
"Prompt templates retrieved successfully",
console_ns.models[AdvancedPromptTemplateResponse.__name__],
200, "Prompt templates retrieved successfully", fields.List(fields.Raw(description="Prompt template data"))
)
@console_ns.response(400, "Invalid request parameters")
@setup_required
+9 -488
View File
@@ -1,62 +1,16 @@
from typing import Any
from uuid import UUID
from flask import request
from flask_restx import Resource
from flask_restx import Resource, fields
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_models,
)
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from controllers.console.wraps import account_initialization_required, setup_required
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from models.model import App, AppMode, UploadFile
from services.agent.composer_service import AgentComposerService
from services.agent.skill_package_service import SkillManifest, SkillPackageError
from services.agent.skill_standardize_service import SkillStandardizeService
from services.agent.skill_tool_inference_service import (
SkillToolInferenceError,
SkillToolInferenceResult,
SkillToolInferenceService,
)
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
DriveCommitItem,
DriveFileRef,
normalize_drive_key,
)
from models.model import App, AppMode
from services.agent_service import AgentService
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
_AGENT_SKILL_UPLOAD_PARAMS = {
"file": {
"in": "formData",
"type": "file",
"required": True,
"description": "Skill package (.zip or .skill).",
}
}
class AgentLogQuery(BaseModel):
message_id: str = Field(..., description="Message UUID")
@@ -68,254 +22,7 @@ class AgentLogQuery(BaseModel):
return uuid_value(value)
class AgentDriveFilePayload(BaseModel):
upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
@field_validator("upload_file_id")
@classmethod
def validate_upload_file_id(cls, value: str) -> str:
return uuid_value(value)
class AgentDriveMutationQuery(BaseModel):
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentDriveDeleteFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentLogMetaResponse(ResponseModel):
status: str
executor: str
start_time: str
elapsed_time: float | None = None
total_tokens: int
agent_mode: str
iterations: int
class AgentToolCallResponse(ResponseModel):
status: str
error: str | None = None
time_cost: float | int
tool_name: str
tool_label: str
tool_input: dict[str, Any]
tool_output: dict[str, Any]
tool_parameters: dict[str, Any]
tool_icon: Any = Field(default=None)
class AgentIterationLogResponse(ResponseModel):
tokens: int
tool_calls: list[AgentToolCallResponse]
tool_raw: dict[str, Any]
thought: str | None = None
created_at: str
files: list[Any] = Field(default_factory=list)
class AgentLogResponse(ResponseModel):
meta: AgentLogMetaResponse
iterations: list[AgentIterationLogResponse]
files: list[Any] = Field(default_factory=list)
class AgentUploadedSkillResponse(ResponseModel):
name: str
description: str
path: str
skill_md_key: str
archive_key: str | None = None
class AgentSkillUploadResponse(ResponseModel):
skill: AgentUploadedSkillResponse
manifest: SkillManifest
class AgentDriveFileResponse(ResponseModel):
name: str
drive_key: str
file_id: str
size: int | None = None
mime_type: str | None = None
class AgentDriveFileCommitResponse(ResponseModel):
file: AgentDriveFileResponse
class AgentDriveDeleteResponse(ResponseModel):
result: str
removed_keys: list[str] = Field(default_factory=list)
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
register_response_schema_models(
console_ns,
AgentDriveDeleteResponse,
AgentDriveFileCommitResponse,
AgentDriveFileResponse,
AgentLogResponse,
AgentUploadedSkillResponse,
AgentSkillUploadResponse,
SkillToolInferenceResult,
)
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
if node_id and app_model.mode != AppMode.AGENT:
return AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
return app_model.bound_agent_id
def _agent_not_bound() -> tuple[dict[str, str], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _upload_skill_for_app(*, current_user: Account, app_model: App):
"""Upload one skill package and commit its normalized files into the agent drive."""
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
upload = request.files["file"]
content = upload.stream.read()
try:
result = SkillStandardizeService().standardize(
content=content,
filename=upload.filename or "",
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
)
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
return result, 201
def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveMutationQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(app_model, node_id)
if not agent_id:
return _agent_not_bound()
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
upload_file = db.session.scalar(
select(UploadFile).where(
UploadFile.id == payload.upload_file_id,
UploadFile.tenant_id == app_model.tenant_id,
)
)
if upload_file is None:
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
try:
key = normalize_drive_key(f"files/{upload_file.name}")
committed = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(
key=key,
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
# ADD FILE uploads exist solely to live in the drive, so the
# drive owns (and physically cleans) the value on delete.
value_owned_by_drive=True,
)
],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
row = committed[0]
return {
"file": {
"name": upload_file.name,
"drive_key": row["key"],
"file_id": upload_file.id,
"size": row.get("size"),
"mime_type": row.get("mime_type"),
},
}, 201
def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveDeleteFileQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(app_model, node_id)
if not agent_id:
return _agent_not_bound()
try:
key = normalize_drive_key(query.key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
try:
result = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[DriveCommitItem(key=key, file_ref=None)],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
removed_keys = [item["key"] for item in result if item.get("removed")]
return {"result": "success", "removed_keys": removed_keys}
def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveMutationQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(app_model, node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
result = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
removed_keys = [item["key"] for item in result if item.get("removed")]
return {"result": "success", "removed_keys": removed_keys}
def _infer_skill_tools_for_app(*, app_model: App, slug: str):
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug)
except SkillToolInferenceError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
register_schema_models(console_ns, AgentLogQuery)
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
@@ -323,203 +30,17 @@ class AgentLogApi(Resource):
@console_ns.doc("get_agent_logs")
@console_ns.doc(description="Get agent execution logs for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(AgentLogQuery))
@console_ns.response(200, "Agent logs retrieved successfully", console_ns.models[AgentLogResponse.__name__])
@console_ns.expect(console_ns.models[AgentLogQuery.__name__])
@console_ns.response(
200, "Agent logs retrieved successfully", fields.List(fields.Raw(description="Agent log entries"))
)
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model(mode=[AppMode.AGENT_CHAT])
def get(self, app_model: App):
"""Get agent logs"""
args = AgentLogQuery.model_validate(request.args.to_dict(flat=True))
return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id)
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
class AgentSkillUploadByAgentApi(Resource):
@console_ns.doc("upload_agent_skill_by_agent")
@console_ns.doc(description="Upload + standardize a Skill into an Agent App drive")
@console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS})
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package or no bound agent")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
class AgentSkillUploadApi(Resource):
@console_ns.doc("upload_agent_skill")
@console_ns.doc(description="Upload + standardize a Skill into the agent drive")
@console_ns.doc(
consumes=["multipart/form-data"],
params={
"app_id": "Application ID",
**query_params_from_model(AgentDriveMutationQuery),
**_AGENT_SKILL_UPLOAD_PARAMS,
},
)
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package or no bound agent")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Upload a Skill, validate it, and commit drive-backed skill files."""
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
@console_ns.route("/agent/<uuid:agent_id>/files")
class AgentDriveFilesByAgentApi(Resource):
@console_ns.doc("commit_agent_drive_file_by_agent")
@console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/<name>")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
@console_ns.response(
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
@console_ns.doc("delete_agent_drive_file_by_agent")
@console_ns.doc(description="Delete one Agent App drive file by key")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
@console_ns.route("/apps/<uuid:app_id>/agent/files")
class AgentDriveFilesApi(Resource):
@console_ns.doc("commit_agent_drive_file")
@console_ns.doc(description="Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
@console_ns.response(
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def post(self, current_user: Account, app_model: App):
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model)
@console_ns.doc("delete_agent_drive_file")
@console_ns.doc(description="Delete one drive file by key via drive commit-null semantics")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def delete(self, current_user: Account, app_model: App):
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model)
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>")
class AgentSkillByAgentApi(Resource):
@console_ns.doc("delete_agent_skill_by_agent")
@console_ns.doc(description="Delete a standardized skill from an Agent App drive")
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
class AgentSkillApi(Resource):
@console_ns.doc("delete_agent_skill")
@console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null")
@console_ns.doc(
params={
"app_id": "Application ID",
"slug": "Skill slug (single path segment)",
**query_params_from_model(AgentDriveMutationQuery),
}
)
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def delete(self, current_user: Account, app_model: App, slug: str):
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug)
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>/infer-tools")
class AgentSkillInferToolsByAgentApi(Resource):
@console_ns.doc("infer_agent_skill_tools_by_agent")
@console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
@console_ns.response(
200,
"Inference result (draft suggestions, nothing persisted)",
console_ns.models[SkillToolInferenceResult.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID, slug: str):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
class AgentSkillInferToolsApi(Resource):
@console_ns.doc("infer_agent_skill_tools")
@console_ns.doc(
description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
)
@console_ns.doc(
params={
"app_id": "Application ID",
"slug": "Skill slug (single path segment)",
**query_params_from_model(AgentDriveMutationQuery),
}
)
@console_ns.response(
200,
"Inference result (draft suggestions, nothing persisted)",
console_ns.models[SkillToolInferenceResult.__name__],
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def post(self, app_model: App, slug: str):
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
@@ -5,31 +5,25 @@ reference. This exposes the read-only "Workflow access" surface from the PRD:
which workflow apps use this Agent, without leaking the workflows' internals.
"""
from uuid import UUID
from flask_restx import Resource
from pydantic import Field
from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent.roster_service import AgentRosterService
class AgentReferencingWorkflowResponse(ResponseModel):
app_id: str
app_name: str
app_icon_type: str | None = None
app_icon: str | None = None
app_icon_background: str | None = None
app_mode: str
app_updated_at: int | None = None
workflow_id: str
workflow_version: str
node_ids: list[str] = Field(default_factory=list)
@@ -40,23 +34,23 @@ class AgentReferencingWorkflowsResponse(ResponseModel):
register_response_schema_models(console_ns, AgentReferencingWorkflowsResponse)
@console_ns.route("/agent/<uuid:agent_id>/referencing-workflows")
@console_ns.route("/apps/<uuid:app_id>/agent-referencing-workflows")
class AgentAppReferencingWorkflowsResource(Resource):
@console_ns.doc("list_agent_app_referencing_workflows")
@console_ns.doc(description="List workflow apps that reference this Agent App's bound Agent (read-only)")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200,
"Referencing workflows listed successfully",
console_ns.models[AgentReferencingWorkflowsResponse.__name__],
)
@console_ns.response(404, "Agent not found")
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def get(self, tenant_id: str, app_model: App):
workflows = AgentRosterService(db.session).list_workflows_referencing_app_agent(
tenant_id=tenant_id, app_id=app_model.id
)
@@ -9,27 +9,21 @@ persists them onto the app's ``app_model_config`` without touching anything the
Soul owns.
"""
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from events.app_event import app_model_config_was_updated
from extensions.ext_database import db
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.agent_config_entities import (
@@ -38,6 +32,7 @@ from models.agent_config_entities import (
AgentSuggestedQuestionsAfterAnswerFeatureConfig,
AgentTextToSpeechFeatureConfig,
)
from models.model import App, AppMode
from services.agent_app_feature_service import AgentAppFeatureConfigService
@@ -69,33 +64,30 @@ register_schema_models(console_ns, AgentAppFeaturesPayload)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/agent/<uuid:agent_id>/features")
@console_ns.route("/apps/<uuid:app_id>/agent-features")
class AgentAppFeatureConfigResource(Resource):
@console_ns.doc("update_agent_app_features")
@console_ns.doc(description="Update an Agent App's presentation features (opener, follow-up, citations, ...)")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[AgentAppFeaturesPayload.__name__])
@console_ns.response(200, "Features updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(400, "Invalid configuration")
@console_ns.response(404, "Agent not found")
@console_ns.response(404, "App not found")
@setup_required
@login_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT)
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
def post(self, current_user: Account, app_model: App):
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
new_app_model_config = AgentAppFeatureConfigService.update_features(
app_model=app_model,
account=current_user,
config=args.model_dump(exclude_none=True),
session=db.session,
)
app_model_config_was_updated.send(app_model, app_model_config=new_app_model_config)
return SimpleResultResponse(result="success").model_dump(mode="json")
return dump_response(SimpleResultResponse, {"result": "success"})
@@ -1,346 +0,0 @@
"""Console routes for Agent App and workflow Agent sandbox file access.
The API keeps product-facing locators (conversation or workflow node identity)
on this public boundary and proxies list/read/upload to the agent backend's new
``/sandbox`` contract.
"""
from __future__ import annotations
from typing import Literal
from uuid import UUID
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent_app_sandbox_service import (
AgentAppSandboxService,
AgentSandboxInspectorError,
WorkflowAgentSandboxService,
)
_NODE_EXECUTION_ID_DESCRIPTION = (
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
)
class AgentSandboxListQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
class AgentSandboxInfoQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
class AgentSandboxFileQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
class AgentSandboxUploadPayload(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
class WorkflowAgentSandboxListQuery(BaseModel):
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=_NODE_EXECUTION_ID_DESCRIPTION,
)
class WorkflowAgentSandboxFileQuery(BaseModel):
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=_NODE_EXECUTION_ID_DESCRIPTION,
)
class WorkflowAgentSandboxUploadPayload(BaseModel):
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=_NODE_EXECUTION_ID_DESCRIPTION,
)
class SandboxFileEntryResponse(ResponseModel):
name: str
type: Literal["file", "dir", "symlink", "other"]
size: int | None = None
mtime: int | None = None
class SandboxListResponse(ResponseModel):
path: str
entries: list[SandboxFileEntryResponse] = Field(default_factory=list)
truncated: bool = False
class SandboxInfoResponse(ResponseModel):
session_id: str
workspace_cwd: str
class SandboxReadResponse(ResponseModel):
path: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class SandboxToolFileResponse(ResponseModel):
transfer_method: Literal["tool_file"] = "tool_file"
reference: str
class SandboxUploadResponse(ResponseModel):
path: str
file: SandboxToolFileResponse
register_schema_models(
console_ns,
AgentSandboxUploadPayload,
WorkflowAgentSandboxUploadPayload,
)
register_response_schema_models(
console_ns,
SandboxInfoResponse,
SandboxListResponse,
SandboxReadResponse,
SandboxUploadResponse,
)
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
if isinstance(exc, AgentSandboxInspectorError):
return {"code": exc.code, "message": exc.message}, exc.status_code
if isinstance(exc, DifyAgentHTTPError):
detail = exc.detail
if isinstance(detail, dict):
return {
"code": detail.get("code", "agent_backend_error"),
"message": detail.get("message", str(exc)),
}, exc.status_code
return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code
if isinstance(exc, DifyAgentTimeoutError | DifyAgentClientError):
return {"code": "agent_backend_unreachable", "message": str(exc)}, 502
raise exc
@console_ns.route("/agent/<uuid:agent_id>/sandbox")
class AgentAppSandboxInfoResource(Resource):
@console_ns.doc("get_agent_app_sandbox_info")
@console_ns.doc(description="Get basic information for an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxInfoQuery)})
@console_ns.response(200, "Sandbox information returned", console_ns.models[SandboxInfoResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxInfoQuery)
try:
result = AgentAppSandboxService().get_info(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files")
class AgentAppSandboxListResource(Resource):
@console_ns.doc("list_agent_app_sandbox_files")
@console_ns.doc(description="List a directory in an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxListQuery)})
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxListQuery)
try:
result = AgentAppSandboxService().list_files(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
path=query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/read")
class AgentAppSandboxReadResource(Resource):
@console_ns.doc("read_agent_app_sandbox_file")
@console_ns.doc(description="Read a text/binary preview file in an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxFileQuery)})
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxFileQuery)
try:
result = AgentAppSandboxService().read_file(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
path=query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
class AgentAppSandboxUploadResource(Resource):
@console_ns.doc("upload_agent_app_sandbox_file")
@console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping")
@console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
try:
result = AgentAppSandboxService().upload_file(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=payload.conversation_id,
path=payload.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route("/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files")
class WorkflowAgentSandboxListResource(Resource):
@console_ns.doc("list_workflow_agent_sandbox_files")
@console_ns.doc(description="List a directory in a workflow Agent node sandbox")
@console_ns.doc(
params={
"app_id": "Application ID",
"workflow_run_id": "Workflow run ID",
"node_id": "Workflow Agent node ID",
**query_params_from_model(WorkflowAgentSandboxListQuery),
}
)
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentSandboxListQuery)
try:
result = WorkflowAgentSandboxService().list_files(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route(
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/read"
)
class WorkflowAgentSandboxReadResource(Resource):
@console_ns.doc("read_workflow_agent_sandbox_file")
@console_ns.doc(description="Read a text/binary preview file in a workflow Agent node sandbox")
@console_ns.doc(
params={
"app_id": "Application ID",
"workflow_run_id": "Workflow run ID",
"node_id": "Workflow Agent node ID",
**query_params_from_model(WorkflowAgentSandboxFileQuery),
}
)
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentSandboxFileQuery)
try:
result = WorkflowAgentSandboxService().read_file(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route(
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/upload"
)
class WorkflowAgentSandboxUploadResource(Resource):
@console_ns.doc("upload_workflow_agent_sandbox_file")
@console_ns.doc(description="Upload one workflow Agent sandbox file as a Dify ToolFile mapping")
@console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
payload = WorkflowAgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
try:
result = WorkflowAgentSandboxService().upload_file(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=payload.node_execution_id,
path=payload.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@@ -0,0 +1,319 @@
"""Agent App sandbox file-system inspector (read-only).
Exposes the PRD "rc1-like sandbox file system, downloadable not editable" view
for an Agent App conversation: list a directory, preview a file, or download a
file from the conversation's shell-layer workspace. The API never touches
shellctl directly — it resolves the conversation's sandbox ``session_id`` from
the stored session snapshot and proxies to the agent backend's read-only
workspace endpoints.
"""
from typing import Literal
from uuid import UUID
from flask import Response
from flask_restx import Resource, fields
from pydantic import BaseModel, Field
from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError
from clients.agent_backend.workspace_files_client import WorkspaceDownloadResult
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from fields.base import ResponseModel
from libs.login import current_account_with_tenant, login_required
from models.model import App, AppMode
from services.agent_app_workspace_service import (
AgentAppWorkspaceService,
AgentWorkspaceInspectorError,
WorkflowAgentWorkspaceService,
)
class _WorkspaceFileDownloadField(fields.Raw):
__schema_type__ = "string"
__schema_format__ = "binary"
class AgentWorkspaceListQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
class AgentWorkspaceFileQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
class WorkflowAgentWorkspaceListQuery(BaseModel):
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=(
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
),
)
class WorkflowAgentWorkspaceFileQuery(BaseModel):
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=(
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
),
)
class WorkspaceFileEntryResponse(ResponseModel):
name: str
type: Literal["file", "dir", "symlink"]
size: int
mtime: int
class WorkspaceListResponse(ResponseModel):
path: str
entries: list[WorkspaceFileEntryResponse] = Field(default_factory=list)
truncated: bool = False
class WorkspacePreviewResponse(ResponseModel):
path: str
size: int
truncated: bool
binary: bool
text: str | None = None
register_response_schema_models(console_ns, WorkspaceListResponse)
register_response_schema_models(console_ns, WorkspacePreviewResponse)
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
if isinstance(exc, AgentWorkspaceInspectorError):
return {"code": exc.code, "message": exc.message}, exc.status_code
if isinstance(exc, AgentBackendHTTPError):
detail = exc.detail
if isinstance(detail, dict):
return {
"code": detail.get("code", "agent_backend_error"),
"message": detail.get("message", str(exc)),
}, exc.status_code
return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code
if isinstance(exc, AgentBackendTransportError):
return {"code": "agent_backend_unreachable", "message": str(exc)}, 502
raise exc
def _download_response(result: WorkspaceDownloadResult) -> Response | tuple[dict[str, object], int]:
if result.truncated:
return {
"code": "workspace_file_too_large",
"message": (
"file exceeds the workspace download limit; use preview for partial text or download a smaller file"
),
"size": result.size,
}, 413
filename = result.path.rsplit("/", 1)[-1] or "download"
return Response(
result.content,
mimetype="application/octet-stream",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(result.content)),
"X-Workspace-File-Size": str(result.size),
},
)
@console_ns.route("/apps/<uuid:app_id>/agent-workspace/files")
class AgentAppWorkspaceListResource(Resource):
@console_ns.doc("list_agent_app_workspace_files")
@console_ns.doc(description="List a directory in an Agent App conversation's sandbox workspace (read-only)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceListQuery)})
@console_ns.response(200, "Listing returned", console_ns.models[WorkspaceListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
def get(self, app_model: App):
_, tenant_id = current_account_with_tenant()
query = query_params_from_request(AgentWorkspaceListQuery)
try:
result = AgentAppWorkspaceService().list_files(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return result.model_dump()
@console_ns.route("/apps/<uuid:app_id>/agent-workspace/files/preview")
class AgentAppWorkspacePreviewResource(Resource):
@console_ns.doc("preview_agent_app_workspace_file")
@console_ns.doc(description="Preview a text/binary file in an Agent App conversation's sandbox workspace")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceFileQuery)})
@console_ns.response(200, "Preview returned", console_ns.models[WorkspacePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
def get(self, app_model: App):
_, tenant_id = current_account_with_tenant()
query = query_params_from_request(AgentWorkspaceFileQuery)
try:
result = AgentAppWorkspaceService().preview(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return result.model_dump()
@console_ns.route("/apps/<uuid:app_id>/agent-workspace/files/download")
class AgentAppWorkspaceDownloadResource(Resource):
@console_ns.doc("download_agent_app_workspace_file")
@console_ns.doc(description="Download a file from an Agent App conversation's sandbox workspace (read-only)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceFileQuery)})
@console_ns.doc(produces=["application/octet-stream"])
@console_ns.response(200, "File bytes", _WorkspaceFileDownloadField)
@console_ns.response(413, "File exceeds the workspace download limit")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
def get(self, app_model: App):
_, tenant_id = current_account_with_tenant()
query = query_params_from_request(AgentWorkspaceFileQuery)
try:
result = AgentAppWorkspaceService().download(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return _download_response(result)
@console_ns.route(
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/workspace/files"
)
class WorkflowAgentWorkspaceListResource(Resource):
@console_ns.doc("list_workflow_agent_workspace_files")
@console_ns.doc(description="List a directory in a Workflow Agent node's sandbox workspace (read-only)")
@console_ns.doc(
params={
"app_id": "Application ID",
"workflow_run_id": "Workflow run ID",
"node_id": "Workflow Agent node ID",
**query_params_from_model(WorkflowAgentWorkspaceListQuery),
}
)
@console_ns.response(200, "Listing returned", console_ns.models[WorkspaceListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
def get(self, app_model: App, workflow_run_id: UUID, node_id: str):
_, tenant_id = current_account_with_tenant()
query = query_params_from_request(WorkflowAgentWorkspaceListQuery)
try:
result = WorkflowAgentWorkspaceService().list_files(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return result.model_dump()
@console_ns.route(
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/workspace/files/preview"
)
class WorkflowAgentWorkspacePreviewResource(Resource):
@console_ns.doc("preview_workflow_agent_workspace_file")
@console_ns.doc(description="Preview a text/binary file in a Workflow Agent node's sandbox workspace")
@console_ns.doc(
params={
"app_id": "Application ID",
"workflow_run_id": "Workflow run ID",
"node_id": "Workflow Agent node ID",
**query_params_from_model(WorkflowAgentWorkspaceFileQuery),
}
)
@console_ns.response(200, "Preview returned", console_ns.models[WorkspacePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
def get(self, app_model: App, workflow_run_id: UUID, node_id: str):
_, tenant_id = current_account_with_tenant()
query = query_params_from_request(WorkflowAgentWorkspaceFileQuery)
try:
result = WorkflowAgentWorkspaceService().preview(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return result.model_dump()
@console_ns.route(
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/workspace/files/download"
)
class WorkflowAgentWorkspaceDownloadResource(Resource):
@console_ns.doc("download_workflow_agent_workspace_file")
@console_ns.doc(description="Download a file from a Workflow Agent node's sandbox workspace (read-only)")
@console_ns.doc(
params={
"app_id": "Application ID",
"workflow_run_id": "Workflow run ID",
"node_id": "Workflow Agent node ID",
**query_params_from_model(WorkflowAgentWorkspaceFileQuery),
}
)
@console_ns.doc(produces=["application/octet-stream"])
@console_ns.response(200, "File bytes", _WorkspaceFileDownloadField)
@console_ns.response(413, "File exceeds the workspace download limit")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
def get(self, app_model: App, workflow_run_id: UUID, node_id: str):
_, tenant_id = current_account_with_tenant()
query = query_params_from_request(WorkflowAgentWorkspaceFileQuery)
try:
result = WorkflowAgentWorkspaceService().download(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return _download_response(result)
File diff suppressed because it is too large Load Diff
@@ -1,408 +0,0 @@
"""Console read-only inspector for the agent drive (ENG-624).
``agent-drive`` looks at the *static* drive assets (standardized skills and
committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
sandbox workspace. Unlike the sandbox routes this never proxies to the agent
backend drive data lives in the API's own DB/storage, served straight from
``AgentDriveService``. Download hands the browser an **external** signed URL
(the inner manifest hands agents internal ones the two must never mix).
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any
from uuid import UUID
from flask import Response
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent_drive_service import AgentDriveError, AgentDriveService
class AgentDriveListQuery(BaseModel):
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveListByAgentQuery(BaseModel):
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
class AgentDriveFileQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
class AgentDriveSkillInspectQuery(BaseModel):
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveItemResponse(ResponseModel):
key: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
file_kind: str
created_at: int | None = None
is_skill: bool | None = None
skill_metadata: str | None = None
class AgentDriveListResponse(ResponseModel):
items: list[AgentDriveItemResponse] = Field(default_factory=list)
class AgentDriveSkillItemResponse(ResponseModel):
path: str
skill_md_key: str
archive_key: str | None = None
name: str
description: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
created_at: int | None = None
class AgentDriveSkillListResponse(ResponseModel):
items: list[AgentDriveSkillItemResponse] = Field(default_factory=list)
class AgentDriveSkillFileResponse(ResponseModel):
path: str
name: str
type: str
drive_key: str | None = None
available_in_drive: bool
class AgentDriveSkillMarkdownResponse(ResponseModel):
key: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class AgentDriveSkillInspectResponse(ResponseModel):
path: str
skill_md_key: str
archive_key: str | None = None
name: str
description: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
created_at: int | None = None
source: str
files: list[AgentDriveSkillFileResponse] = Field(default_factory=list)
file_tree: list[dict[str, Any]] = Field(default_factory=list)
skill_md: AgentDriveSkillMarkdownResponse
warnings: list[str] = Field(default_factory=list)
class AgentDrivePreviewResponse(ResponseModel):
key: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class AgentDriveDownloadResponse(ResponseModel):
url: str
register_response_schema_models(
console_ns,
AgentDriveDownloadResponse,
AgentDriveListResponse,
AgentDrivePreviewResponse,
AgentDriveSkillInspectResponse,
AgentDriveSkillListResponse,
)
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
if node_id:
return AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
return app_model.bound_agent_id
def _agent_not_bound() -> tuple[dict[str, object], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
def _json_response(data: Mapping[str, Any]):
return Response(
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
content_type="application/json; charset=utf-8",
)
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
@console_ns.route("/agent/<uuid:agent_id>/drive/files")
class AgentDriveListByAgentApi(Resource):
@console_ns.doc("list_agent_drive_files_by_agent")
@console_ns.doc(description="List agent drive entries for an Agent App")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveListByAgentQuery)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
except AgentDriveError as exc:
return _handle(exc)
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/agent/<uuid:agent_id>/drive/skills")
class AgentDriveSkillListByAgentApi(Resource):
@console_ns.doc("list_agent_drive_skills_by_agent")
@console_ns.doc(description="List drive-backed skills for an Agent App")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@console_ns.route("/agent/<uuid:agent_id>/drive/skills/<path:skill_path>/inspect")
class AgentDriveSkillInspectByAgentApi(Resource):
@console_ns.doc("inspect_agent_drive_skill_by_agent")
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
@console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"})
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID, skill_path: str):
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
return _json_response(
AgentDriveService().inspect_skill(
tenant_id=tenant_id,
agent_id=str(agent_id),
skill_path=skill_path,
)
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
class AgentDrivePreviewByAgentApi(Resource):
@console_ns.doc("preview_agent_drive_file_by_agent")
@console_ns.doc(description="Truncated text preview of one Agent App drive value")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/agent/<uuid:agent_id>/drive/files/download")
class AgentDriveDownloadByAgentApi(Resource):
@console_ns.doc("download_agent_drive_file_by_agent")
@console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
class AgentDriveListApi(Resource):
@console_ns.doc("list_agent_drive_files")
@console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveListQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
except AgentDriveError as exc:
return _handle(exc)
# the inner manifest exposes file_id for agent-side pulls; the console
# inspector is a pure read surface and does not need value pointers
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills")
class AgentDriveSkillListApi(Resource):
@console_ns.doc("list_agent_drive_skills")
@console_ns.doc(description="List drive-backed skills for the bound agent")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveListQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id)
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills/<path:skill_path>/inspect")
class AgentDriveSkillInspectApi(Resource):
@console_ns.doc("inspect_agent_drive_skill")
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
@console_ns.doc(
params={
"app_id": "Application ID",
"skill_path": "Skill path/slug, e.g. tender-analyzer",
**query_params_from_model(AgentDriveSkillInspectQuery),
}
)
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App, skill_path: str):
query = query_params_from_request(AgentDriveSkillInspectQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
return _json_response(
AgentDriveService().inspect_skill(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
skill_path=skill_path,
)
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
class AgentDrivePreviewApi(Resource):
@console_ns.doc("preview_agent_drive_file")
@console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/download")
class AgentDriveDownloadApi(Resource):
@console_ns.doc("download_agent_drive_file")
@console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
__all__ = [
"AgentDriveDownloadApi",
"AgentDriveDownloadByAgentApi",
"AgentDriveListApi",
"AgentDriveListByAgentApi",
"AgentDrivePreviewApi",
"AgentDrivePreviewByAgentApi",
"AgentDriveSkillInspectApi",
"AgentDriveSkillInspectByAgentApi",
"AgentDriveSkillListApi",
"AgentDriveSkillListByAgentApi",
]
+54 -143
View File
@@ -1,41 +1,31 @@
from typing import Any, Literal
from uuid import UUID
from flask import abort, request
from flask import abort, make_response, request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter, field_validator
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from controllers.common.errors import NoFileUploadedError, TooManyFilesError
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
annotation_import_concurrency_limit,
annotation_import_rate_limit,
cloud_edition_billing_resource_check,
edit_permission_required,
rbac_permission_required,
setup_required,
)
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.annotation_fields import (
Annotation,
AnnotationExportList,
AnnotationHitHistory,
AnnotationHitHistoryList,
AnnotationJobStatusDetailResponse,
AnnotationJobStatusResponse,
AnnotationList,
)
from fields.base import ResponseModel
from libs.helper import dump_response, uuid_value
from libs.login import current_account_with_tenant, login_required
from models.model import App
from libs.helper import uuid_value
from libs.login import login_required
from services.annotation_service import (
AppAnnotationService,
EnableAnnotationArgs,
@@ -43,17 +33,6 @@ from services.annotation_service import (
UpdateAnnotationSettingArgs,
UpsertAnnotationArgs,
)
from services.app_ref_service import AppRef, AppRefService
def _get_app_ref(app_id: str) -> AppRef:
_, current_tenant_id = current_account_with_tenant()
app = db.session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
)
if app is None:
raise NotFound("App not found")
return AppRefService.create_app_ref(app)
class AnnotationReplyPayload(BaseModel):
@@ -77,10 +56,7 @@ class CreateAnnotationPayload(BaseModel):
question: str | None = Field(default=None, description="Question text")
answer: str | None = Field(default=None, description="Answer text")
content: str | None = Field(default=None, description="Content text")
annotation_reply: dict[str, Any] | None = Field(
default=None,
description="Annotation reply data",
)
annotation_reply: dict[str, Any] | None = Field(default=None, description="Annotation reply data")
@field_validator("message_id")
@classmethod
@@ -94,18 +70,13 @@ class UpdateAnnotationPayload(BaseModel):
question: str | None = None
answer: str | None = None
content: str | None = None
annotation_reply: dict[str, Any] | None = Field(default=None)
annotation_reply: dict[str, Any] | None = None
class AnnotationReplyStatusQuery(BaseModel):
action: Literal["enable", "disable"]
class AnnotationHitHistoryListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
limit: int = Field(default=20, ge=1, description="Page size")
class AnnotationFilePayload(BaseModel):
message_id: str = Field(..., description="Message ID")
@@ -115,25 +86,6 @@ class AnnotationFilePayload(BaseModel):
return uuid_value(value)
class AnnotationSettingEmbeddingModelResponse(ResponseModel):
embedding_provider_name: str | None = None
embedding_model_name: str | None = None
class AnnotationSettingResponse(ResponseModel):
enabled: bool
id: str | None = None
score_threshold: float | None = None
embedding_model: AnnotationSettingEmbeddingModelResponse | None = None
class AnnotationBatchImportResponse(ResponseModel):
job_id: str | None = None
job_status: str | None = None
record_count: int | None = None
error_msg: str | None = None
register_schema_models(
console_ns,
Annotation,
@@ -147,22 +99,8 @@ register_schema_models(
CreateAnnotationPayload,
UpdateAnnotationPayload,
AnnotationReplyStatusQuery,
AnnotationHitHistoryListQuery,
AnnotationFilePayload,
)
register_response_schema_models(
console_ns,
Annotation,
AnnotationList,
AnnotationExportList,
AnnotationHitHistory,
AnnotationHitHistoryList,
AnnotationJobStatusResponse,
AnnotationJobStatusDetailResponse,
AnnotationSettingEmbeddingModelResponse,
AnnotationSettingResponse,
AnnotationBatchImportResponse,
)
@console_ns.route("/apps/<uuid:app_id>/annotation-reply/<string:action>")
@@ -171,14 +109,13 @@ class AnnotationReplyActionApi(Resource):
@console_ns.doc(description="Enable or disable annotation reply for an app")
@console_ns.doc(params={"app_id": "Application ID", "action": "Action to perform (enable/disable)"})
@console_ns.expect(console_ns.models[AnnotationReplyPayload.__name__])
@console_ns.response(200, "Action completed successfully", console_ns.models[AnnotationJobStatusResponse.__name__])
@console_ns.response(200, "Action completed successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("annotation")
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
def post(self, app_id: UUID, action: Literal["enable", "disable"]):
args = AnnotationReplyPayload.model_validate(console_ns.payload)
match action:
@@ -191,7 +128,7 @@ class AnnotationReplyActionApi(Resource):
result = AppAnnotationService.enable_app_annotation(enable_args, str(app_id))
case "disable":
result = AppAnnotationService.disable_app_annotation(str(app_id))
return dump_response(AnnotationJobStatusResponse, result), 200
return result, 200
@console_ns.route("/apps/<uuid:app_id>/annotation-setting")
@@ -199,20 +136,15 @@ class AppAnnotationSettingDetailApi(Resource):
@console_ns.doc("get_annotation_setting")
@console_ns.doc(description="Get annotation settings for an app")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200,
"Annotation settings retrieved successfully",
console_ns.models[AnnotationSettingResponse.__name__],
)
@console_ns.response(200, "Annotation settings retrieved successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
def get(self, app_id: UUID):
result = AppAnnotationService.get_app_annotation_setting_by_app_id(str(app_id))
return dump_response(AnnotationSettingResponse, result), 200
return result, 200
@console_ns.route("/apps/<uuid:app_id>/annotation-settings/<uuid:annotation_setting_id>")
@@ -221,13 +153,12 @@ class AppAnnotationSettingUpdateApi(Resource):
@console_ns.doc(description="Update annotation settings for an app")
@console_ns.doc(params={"app_id": "Application ID", "annotation_setting_id": "Annotation setting ID"})
@console_ns.expect(console_ns.models[AnnotationSettingUpdatePayload.__name__])
@console_ns.response(200, "Settings updated successfully", console_ns.models[AnnotationSettingResponse.__name__])
@console_ns.response(200, "Settings updated successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
def post(self, app_id: UUID, annotation_setting_id: UUID):
annotation_setting_id_str = str(annotation_setting_id)
@@ -237,7 +168,7 @@ class AppAnnotationSettingUpdateApi(Resource):
result = AppAnnotationService.update_app_annotation_setting(
str(app_id), annotation_setting_id_str, setting_args
)
return dump_response(AnnotationSettingResponse, result), 200
return result, 200
@console_ns.route("/apps/<uuid:app_id>/annotation-reply/<string:action>/status/<uuid:job_id>")
@@ -245,16 +176,13 @@ class AnnotationReplyActionStatusApi(Resource):
@console_ns.doc("get_annotation_reply_action_status")
@console_ns.doc(description="Get status of annotation reply action job")
@console_ns.doc(params={"app_id": "Application ID", "job_id": "Job ID", "action": "Action type"})
@console_ns.response(
200, "Job status retrieved successfully", console_ns.models[AnnotationJobStatusDetailResponse.__name__]
)
@console_ns.response(200, "Job status retrieved successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("annotation")
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
def get(self, app_id: UUID, job_id: UUID, action: str):
job_id_str = str(job_id)
app_annotation_job_key = f"{action}_app_annotation_job_{job_id_str}"
@@ -268,9 +196,7 @@ class AnnotationReplyActionStatusApi(Resource):
app_annotation_error_key = f"{action}_app_annotation_error_{job_id_str}"
error_msg = redis_client.get(app_annotation_error_key).decode()
return AnnotationJobStatusDetailResponse(
job_id=job_id_str, job_status=job_status, error_msg=error_msg
).model_dump(mode="json"), 200
return {"job_id": job_id_str, "job_status": job_status, "error_msg": error_msg}, 200
@console_ns.route("/apps/<uuid:app_id>/annotations")
@@ -278,14 +204,13 @@ class AnnotationApi(Resource):
@console_ns.doc("list_annotations")
@console_ns.doc(description="Get annotations for an app with pagination")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(AnnotationListQuery))
@console_ns.response(200, "Annotations retrieved successfully", console_ns.models[AnnotationList.__name__])
@console_ns.expect(console_ns.models[AnnotationListQuery.__name__])
@console_ns.response(200, "Annotations retrieved successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
def get(self, app_id: UUID):
args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True))
page = args.page
@@ -294,9 +219,14 @@ class AnnotationApi(Resource):
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(str(app_id), page, limit, keyword)
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
return AnnotationList(
data=annotation_models, has_more=len(annotation_list) == limit, limit=limit, total=total, page=page
).model_dump(mode="json"), 200
response = AnnotationList(
data=annotation_models,
has_more=len(annotation_list) == limit,
limit=limit,
total=total,
page=page,
)
return response.model_dump(mode="json"), 200
@console_ns.doc("create_annotation")
@console_ns.doc(description="Create a new annotation for an app")
@@ -309,7 +239,6 @@ class AnnotationApi(Resource):
@account_initialization_required
@cloud_edition_billing_resource_check("annotation")
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
def post(self, app_id: UUID):
args = CreateAnnotationPayload.model_validate(console_ns.payload)
upsert_args: UpsertAnnotationArgs = {}
@@ -322,14 +251,12 @@ class AnnotationApi(Resource):
if args.question is not None:
upsert_args["question"] = args.question
annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, str(app_id))
return dump_response(Annotation, annotation), 201
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT)
@console_ns.response(204, "Annotations deleted successfully")
def delete(self, app_id: UUID):
# Use request.args.getlist to get annotation_ids array directly
@@ -344,8 +271,7 @@ class AnnotationApi(Resource):
"message": "annotation_ids are required if the parameter is provided.",
}, 400
app_ref = _get_app_ref(str(app_id))
AppAnnotationService.delete_app_annotations_in_batch(app_ref, annotation_ids)
AppAnnotationService.delete_app_annotations_in_batch(str(app_id), annotation_ids)
return "", 204
# If no annotation_ids are provided, handle clearing all annotations
else:
@@ -368,18 +294,17 @@ class AnnotationExportApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
def get(self, app_id: UUID):
annotation_list = AppAnnotationService.export_annotation_list_by_app_id(str(app_id))
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
return (
AnnotationExportList(data=annotation_models).model_dump(mode="json"),
200,
{
"Content-Type": "application/json; charset=utf-8",
"X-Content-Type-Options": "nosniff",
},
)
response_data = AnnotationExportList(data=annotation_models).model_dump(mode="json")
# Create response with secure headers for CSV export
response = make_response(response_data, 200)
response.headers["Content-Type"] = "application/json; charset=utf-8"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
@console_ns.route("/apps/<uuid:app_id>/annotations/<uuid:annotation_id>")
@@ -396,7 +321,6 @@ class AnnotationUpdateDeleteApi(Resource):
@account_initialization_required
@cloud_edition_billing_resource_check("annotation")
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
def post(self, app_id: UUID, annotation_id: UUID):
args = UpdateAnnotationPayload.model_validate(console_ns.payload)
update_args: UpdateAnnotationArgs = {}
@@ -404,21 +328,15 @@ class AnnotationUpdateDeleteApi(Resource):
update_args["answer"] = args.answer
if args.question is not None:
update_args["question"] = args.question
app_ref = _get_app_ref(str(app_id))
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
annotation = AppAnnotationService.update_app_annotation_directly(update_args, annotation_ref, db.session)
annotation = AppAnnotationService.update_app_annotation_directly(update_args, str(app_id), str(annotation_id))
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@console_ns.response(204, "Annotation deleted successfully")
def delete(self, app_id: UUID, annotation_id: UUID):
app_ref = _get_app_ref(str(app_id))
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
AppAnnotationService.delete_app_annotation(annotation_ref, db.session)
AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id))
return "", 204
@@ -427,9 +345,7 @@ class AnnotationBatchImportApi(Resource):
@console_ns.doc("batch_import_annotations")
@console_ns.doc(description="Batch import annotations from CSV file with rate limiting and security checks")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200, "Batch import started successfully", console_ns.models[AnnotationBatchImportResponse.__name__]
)
@console_ns.response(200, "Batch import started successfully")
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "No file uploaded or too many files")
@console_ns.response(413, "File too large")
@@ -441,7 +357,6 @@ class AnnotationBatchImportApi(Resource):
@annotation_import_rate_limit
@annotation_import_concurrency_limit
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
def post(self, app_id: UUID):
from configs import dify_config
@@ -475,10 +390,7 @@ class AnnotationBatchImportApi(Resource):
if file_size == 0:
raise ValueError("The uploaded file is empty")
return dump_response(
AnnotationBatchImportResponse,
AppAnnotationService.batch_import_app_annotations(str(app_id), file),
)
return AppAnnotationService.batch_import_app_annotations(str(app_id), file)
@console_ns.route("/apps/<uuid:app_id>/annotations/batch-import-status/<uuid:job_id>")
@@ -486,16 +398,13 @@ class AnnotationBatchImportStatusApi(Resource):
@console_ns.doc("get_batch_import_status")
@console_ns.doc(description="Get status of batch import job")
@console_ns.doc(params={"app_id": "Application ID", "job_id": "Job ID"})
@console_ns.response(
200, "Job status retrieved successfully", console_ns.models[AnnotationJobStatusDetailResponse.__name__]
)
@console_ns.response(200, "Job status retrieved successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("annotation")
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
def get(self, app_id: UUID, job_id: UUID):
indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}"
cache_result = redis_client.get(indexing_cache_key)
@@ -507,9 +416,7 @@ class AnnotationBatchImportStatusApi(Resource):
indexing_error_msg_key = f"app_annotation_batch_import_error_msg_{str(job_id)}"
error_msg = redis_client.get(indexing_error_msg_key).decode()
return AnnotationJobStatusDetailResponse(
job_id=str(job_id), job_status=job_status, error_msg=error_msg
).model_dump(mode="json"), 200
return {"job_id": job_id, "job_status": job_status, "error_msg": error_msg}, 200
@console_ns.route("/apps/<uuid:app_id>/annotations/<uuid:annotation_id>/hit-histories")
@@ -517,7 +424,11 @@ class AnnotationHitHistoryListApi(Resource):
@console_ns.doc("list_annotation_hit_histories")
@console_ns.doc(description="Get hit histories for an annotation")
@console_ns.doc(params={"app_id": "Application ID", "annotation_id": "Annotation ID"})
@console_ns.doc(params=query_params_from_model(AnnotationHitHistoryListQuery))
@console_ns.expect(
console_ns.parser()
.add_argument("page", type=int, location="args", default=1, help="Page number")
.add_argument("limit", type=int, location="args", default=20, help="Page size")
)
@console_ns.response(
200,
"Hit histories retrieved successfully",
@@ -528,20 +439,20 @@ class AnnotationHitHistoryListApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
def get(self, app_id: UUID, annotation_id: UUID):
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
app_ref = _get_app_ref(str(app_id))
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
annotation_hit_history_list, total = AppAnnotationService.get_annotation_hit_histories(
annotation_ref,
page,
limit,
str(app_id), str(annotation_id), page, limit
)
history_models = TypeAdapter(list[AnnotationHitHistory]).validate_python(
annotation_hit_history_list, from_attributes=True
)
return AnnotationHitHistoryList(
data=history_models, has_more=len(annotation_hit_history_list) == limit, limit=limit, total=total, page=page
).model_dump(mode="json")
response = AnnotationHitHistoryList(
data=history_models,
has_more=len(annotation_hit_history_list) == limit,
limit=limit,
total=total,
page=page,
)
return response.model_dump(mode="json")
+116 -180
View File
@@ -1,23 +1,22 @@
import logging
import re
import uuid
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Literal
from typing import Any, Literal, cast
from flask import request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, computed_field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, NotFound
from werkzeug.datastructures import MultiDict
from werkzeug.exceptions import BadRequest
from configs import dify_config
from controllers.common.app_access import resolve_app_access_filter
from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse
from controllers.common.helpers import FileInfo
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_enum_models,
register_response_schema_models,
register_schema_models,
@@ -26,14 +25,11 @@ from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model, with_session
from controllers.console.workspace.models import LoadBalancingPayload
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
cloud_edition_billing_resource_check,
edit_permission_required,
enterprise_license_required,
is_admin_or_owner_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
@@ -52,7 +48,6 @@ from models import Account, App, DatasetPermissionEnum, Workflow
from models.model import IconType
from services.app_dsl_service import AppDslService
from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import ImportMode, ImportStatus
from services.entities.knowledge_entities.knowledge_entities import (
@@ -69,20 +64,20 @@ from services.entities.knowledge_entities.knowledge_entities import (
)
from services.feature_service import FeatureService
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
register_enum_models(console_ns, IconType)
_logger = logging.getLogger(__name__)
_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
_CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"]
DEFAULT_APP_LIST_MODE: AppListMode = "all"
APP_LIST_QUERY_ARRAY_FIELDS = ("tag_ids", "creator_ids")
class AppListBaseQuery(BaseModel):
page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)")
limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)")
mode: AppListMode = Field(default=DEFAULT_APP_LIST_MODE, description="App mode filter")
mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter")
sort_by: AppListSortBy = Field(
default="last_modified",
description="Sort apps by last modified, recently created, or earliest created",
@@ -137,10 +132,40 @@ class StarredAppListQuery(AppListBaseQuery):
pass
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
normalized: dict[str, str | list[str]] = {}
indexed_tag_ids: list[tuple[int, str]] = []
indexed_creator_ids: list[tuple[int, str]] = []
for key in query_args:
match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key)
if match:
indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
continue
match = _CREATOR_IDS_BRACKET_PATTERN.fullmatch(key)
if match:
indexed_creator_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
continue
value = query_args.get(key)
if value is not None:
normalized[key] = value
if indexed_tag_ids:
normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)]
if indexed_creator_ids:
normalized["creator_ids"] = [value for _, value in sorted(indexed_creator_ids)]
return normalized
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = Field(..., description="App mode")
mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] = Field(
..., description="App mode"
)
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@@ -199,9 +224,7 @@ class AppTracePayload(BaseModel):
return value
class AppTraceResponse(ResponseModel):
enabled: bool = False
tracing_provider: str | None = None
type JSONValue = Any
class Tag(ResponseModel):
@@ -224,7 +247,7 @@ class WorkflowPartial(ResponseModel):
class ModelConfigPartial(ResponseModel):
model: Any | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model"))
model: JSONValue | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model"))
pre_prompt: str | None = None
created_by: str | None = None
created_at: int | None = None
@@ -239,52 +262,54 @@ class ModelConfigPartial(ResponseModel):
class ModelConfig(ResponseModel):
opening_statement: str | None = None
suggested_questions: Any | None = Field(
suggested_questions: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("suggested_questions_list", "suggested_questions")
)
suggested_questions_after_answer: Any | None = Field(
suggested_questions_after_answer: JSONValue | None = Field(
default=None,
validation_alias=AliasChoices("suggested_questions_after_answer_dict", "suggested_questions_after_answer"),
)
speech_to_text: Any | None = Field(
speech_to_text: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("speech_to_text_dict", "speech_to_text")
)
text_to_speech: Any | None = Field(
text_to_speech: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("text_to_speech_dict", "text_to_speech")
)
retriever_resource: Any | None = Field(
retriever_resource: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("retriever_resource_dict", "retriever_resource")
)
annotation_reply: Any | None = Field(
annotation_reply: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("annotation_reply_dict", "annotation_reply")
)
more_like_this: Any | None = Field(
more_like_this: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("more_like_this_dict", "more_like_this")
)
sensitive_word_avoidance: Any | None = Field(
sensitive_word_avoidance: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("sensitive_word_avoidance_dict", "sensitive_word_avoidance")
)
external_data_tools: Any | None = Field(
external_data_tools: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("external_data_tools_list", "external_data_tools")
)
model: Any | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model"))
user_input_form: Any | None = Field(
model: JSONValue | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model"))
user_input_form: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("user_input_form_list", "user_input_form")
)
dataset_query_variable: str | None = None
pre_prompt: str | None = None
agent_mode: Any | None = Field(default=None, validation_alias=AliasChoices("agent_mode_dict", "agent_mode"))
agent_mode: JSONValue | None = Field(default=None, validation_alias=AliasChoices("agent_mode_dict", "agent_mode"))
prompt_type: str | None = None
chat_prompt_config: Any | None = Field(
chat_prompt_config: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("chat_prompt_config_dict", "chat_prompt_config")
)
completion_prompt_config: Any | None = Field(
completion_prompt_config: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("completion_prompt_config_dict", "completion_prompt_config")
)
dataset_configs: Any | None = Field(
dataset_configs: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("dataset_configs_dict", "dataset_configs")
)
file_upload: Any | None = Field(default=None, validation_alias=AliasChoices("file_upload_dict", "file_upload"))
file_upload: JSONValue | None = Field(
default=None, validation_alias=AliasChoices("file_upload_dict", "file_upload")
)
created_by: str | None = None
created_at: int | None = None
updated_by: str | None = None
@@ -296,7 +321,7 @@ class ModelConfig(ResponseModel):
return to_timestamp(value)
class AppDetailSiteResponse(ResponseModel):
class Site(ResponseModel):
access_token: str | None = Field(default=None, validation_alias="code")
code: str | None = None
title: str | None = None
@@ -310,7 +335,6 @@ class AppDetailSiteResponse(ResponseModel):
customize_domain: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
input_placeholder: str | None = None
custom_disclaimer: str | None = None
customize_token_strategy: str | None = None
prompt_public: bool | None = None
@@ -371,13 +395,7 @@ class AppPartial(ResponseModel):
create_user_name: str | None = None
author_name: str | None = None
has_draft_trigger: bool | None = None
permission_keys: list[str] = Field(default_factory=list)
# For Agent App type: the roster Agent backing this app (None otherwise).
bound_agent_id: str | None = None
# For Agent App responses exposed through /agent.
app_id: str | None = None
is_starred: bool = False
maintainer: str | None = None
@computed_field(return_type=str | None) # type: ignore
@property
@@ -405,7 +423,7 @@ class AppDetail(ResponseModel):
alias="model_config",
)
workflow: WorkflowPartial | None = None
tracing: Any | None = None
tracing: JSONValue | None = None
use_icon_as_answer_icon: bool | None = None
created_by: str | None = None
created_at: int | None = None
@@ -413,8 +431,6 @@ class AppDetail(ResponseModel):
updated_at: int | None = None
access_mode: str | None = None
tags: list[Tag] = Field(default_factory=list)
permission_keys: list[str] = Field(default_factory=list)
maintainer: str | None = None
@field_validator("created_at", "updated_at", mode="before")
@classmethod
@@ -427,11 +443,9 @@ class AppDetailWithSite(AppDetail):
api_base_url: str | None = None
max_active_requests: int | None = None
deleted_tools: list[DeletedTool] = Field(default_factory=list)
site: AppDetailSiteResponse | None = None
site: Site | None = None
# For Agent App type: the roster Agent backing this app (None otherwise).
bound_agent_id: str | None = None
# For Agent App responses exposed through /agent.
app_id: str | None = None
@computed_field(return_type=str | None) # type: ignore
@property
@@ -451,16 +465,6 @@ class AppExportResponse(ResponseModel):
data: str
class AppImportResponse(ResponseModel):
id: str
status: ImportStatus
app_id: str | None = None
app_mode: str | None = None
current_dsl_version: str
imported_dsl_version: str = ""
error: str = ""
def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None:
if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in apps]
@@ -503,9 +507,7 @@ def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id:
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
register_response_schema_models(
console_ns, RedirectUrlResponse, SimpleResultResponse, AppImportResponse, AppTraceResponse
)
register_response_schema_models(console_ns, RedirectUrlResponse, SimpleResultResponse)
register_schema_models(
console_ns,
@@ -524,9 +526,12 @@ register_schema_models(
WorkflowPartial,
ModelConfigPartial,
ModelConfig,
AppDetailSiteResponse,
Site,
DeletedTool,
AppPartial,
AppDetail,
AppDetailWithSite,
AppPagination,
AppExportResponse,
Segmentation,
PreProcessingRule,
@@ -546,13 +551,6 @@ register_schema_models(
LoadBalancingPayload,
)
register_response_schema_models(
console_ns,
AppPartial,
AppDetailWithSite,
AppPagination,
)
@console_ns.route("/apps")
class AppListApi(Resource):
@@ -569,7 +567,7 @@ class AppListApi(Resource):
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
"""Get app list"""
args = query_params_from_request(AppListQuery, list_fields=APP_LIST_QUERY_ARRAY_FIELDS)
args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args))
params = AppListParams(
page=args.page,
limit=args.limit,
@@ -581,51 +579,27 @@ class AppListApi(Resource):
is_created_by_me=args.is_created_by_me,
)
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
str(current_tenant_id),
current_user_id,
)
if dify_config.RBAC_ENABLED:
access_filter = resolve_app_access_filter(
str(current_tenant_id),
current_user_id,
permissions=permissions,
)
access_filter.apply_to_params(params)
# get app list
app_service = AppService()
app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params, db.session)
app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params)
if not app_pagination:
response = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return response.model_dump(mode="json"), 200
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
app_ids = [str(app.id) for app in app_pagination.items]
permission_keys_map = permissions.app.permission_keys_by_resource_ids(app_ids)
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
if app_pagination.items:
pagination_model = pagination_model.model_copy(
update={
"data": [
item.model_copy(update={"permission_keys": permission_keys_map.get(str(item.id), [])})
for item in pagination_model.data
]
}
)
return pagination_model.model_dump(mode="json"), 200
@console_ns.doc("create_app")
@console_ns.doc(description="Create a new application")
@console_ns.expect(console_ns.models[CreateAppPayload.__name__])
@console_ns.response(201, "App created successfully", console_ns.models[AppDetailWithSite.__name__])
@console_ns.response(201, "App created successfully", console_ns.models[AppDetail.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT, resource_required=False)
@cloud_edition_billing_resource_check("apps")
@edit_permission_required
@with_current_user
@@ -644,14 +618,7 @@ class AppListApi(Resource):
app_service = AppService()
app = app_service.create_app(current_tenant_id, params, current_user)
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
str(current_tenant_id),
current_user.id,
[str(app.id)],
)
app_detail = AppDetailWithSite.model_validate(app, from_attributes=True).model_copy(
update={"permission_keys": permission_keys_map.get(str(app.id), [])}
)
app_detail = AppDetail.model_validate(app, from_attributes=True)
return app_detail.model_dump(mode="json"), 201
@@ -669,7 +636,7 @@ class StarredAppListApi(Resource):
@with_current_user_id
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
args = query_params_from_request(StarredAppListQuery, list_fields=APP_LIST_QUERY_ARRAY_FIELDS)
args = StarredAppListQuery.model_validate(_normalize_app_list_query_args(request.args))
params = StarredAppListParams(
page=args.page,
limit=args.limit,
@@ -681,13 +648,15 @@ class StarredAppListApi(Resource):
is_created_by_me=args.is_created_by_me,
)
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params, db.session)
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params)
if not app_pagination:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
return AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json"), 200
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
return pagination_model.model_dump(mode="json"), 200
@console_ns.route("/apps/<uuid:app_id>/star")
@@ -706,7 +675,7 @@ class AppStarApi(Resource):
@get_app_model(mode=None)
def post(self, session: Session, current_user_id: str, app_model: App):
AppService.star_app(session, app=app_model, account_id=current_user_id)
return SimpleResultResponse(result="success").model_dump(mode="json")
return dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.doc("unstar_app")
@console_ns.doc(description="Remove the current account's star from an application")
@@ -722,7 +691,7 @@ class AppStarApi(Resource):
@get_app_model(mode=None)
def delete(self, session: Session, current_user_id: str, app_model: App):
AppService.unstar_app(session, app=app_model, account_id=current_user_id)
return SimpleResultResponse(result="success").model_dump(mode="json")
return dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.route("/apps/<uuid:app_id>")
@@ -735,11 +704,8 @@ class AppApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model(mode=None)
def get(self, current_tenant_id: str, current_user: Account, app_model: App):
def get(self, app_model: App):
"""Get app detail"""
app_service = AppService()
@@ -747,18 +713,9 @@ class AppApi(Resource):
if FeatureService.get_system_features().webapp_auth.enabled:
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
str(current_tenant_id),
current_user.id,
app_id=str(app_model.id),
)
permission_keys_map = permissions.app.permission_keys_by_resource_ids([str(app_model.id)])
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True).model_copy(
update={"permission_keys": permission_keys_map.get(str(app_model.id), [])}
)
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")
@console_ns.doc("update_app")
@@ -771,9 +728,8 @@ class AppApi(Resource):
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@get_app_model(mode=None)
@edit_permission_required
def put(self, app_model: App):
"""Update app"""
args = UpdateAppPayload.model_validate(console_ns.payload)
@@ -790,19 +746,19 @@ class AppApi(Resource):
"max_active_requests": args.max_active_requests or 0,
}
app_model = app_service.update_app(app_model, args_dict)
return dump_response(AppDetailWithSite, app_model)
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")
@console_ns.doc("delete_app")
@console_ns.doc(description="Delete application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(204, "App deleted successfully")
@console_ns.response(403, "Insufficient permissions")
@get_app_model
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE)
@get_app_model
def delete(self, app_model: App):
"""Delete app"""
app_service = AppService()
@@ -818,17 +774,14 @@ class AppCopyApi(Resource):
@console_ns.doc(params={"app_id": "Application ID to copy"})
@console_ns.expect(console_ns.models[CopyAppPayload.__name__])
@console_ns.response(201, "App copied successfully", console_ns.models[AppDetailWithSite.__name__])
@console_ns.response(202, "App copy requires confirmation", console_ns.models[AppImportResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT)
@with_current_user
@with_current_tenant_id
@get_app_model(mode=None)
def post(self, current_tenant_id: str, current_user: Account, app_model: App):
@edit_permission_required
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Copy app"""
# The role of the current user in the ta table must be admin, owner, or editor
args = CopyAppPayload.model_validate(console_ns.payload or {})
@@ -848,10 +801,10 @@ class AppCopyApi(Resource):
)
if result.status == ImportStatus.FAILED:
session.rollback()
return dump_response(AppImportResponse, result), 400
return result.model_dump(mode="json"), 400
if result.status == ImportStatus.PENDING:
session.rollback()
return dump_response(AppImportResponse, result), 202
return result.model_dump(mode="json"), 202
session.commit()
# Inherit web app permission from original app
@@ -870,17 +823,7 @@ class AppCopyApi(Resource):
stmt = select(App).where(App.id == result.app_id)
app = session.scalar(stmt)
if not app:
raise NotFound("App not found")
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
str(current_tenant_id),
current_user.id,
[str(app.id)],
)
response_model = AppDetailWithSite.model_validate(app, from_attributes=True).model_copy(
update={"permission_keys": permission_keys_map.get(str(app.id), [])}
)
response_model = AppDetailWithSite.model_validate(app, from_attributes=True)
return response_model.model_dump(mode="json"), 201
@@ -889,27 +832,26 @@ class AppExportApi(Resource):
@console_ns.doc("export_app")
@console_ns.doc(description="Export application configuration as DSL")
@console_ns.doc(params={"app_id": "Application ID to export"})
@console_ns.doc(params=query_params_from_model(AppExportQuery))
@console_ns.expect(console_ns.models[AppExportQuery.__name__])
@console_ns.response(200, "App exported successfully", console_ns.models[AppExportResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@get_app_model
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
@get_app_model
def get(self, app_model: App):
"""Export app"""
args = AppExportQuery.model_validate(request.args.to_dict(flat=True))
response = AppExportResponse(
payload = AppExportResponse(
data=AppDslService.export_dsl(
app_model=app_model,
include_secret=args.include_secret,
workflow_id=args.workflow_id,
)
)
return response.model_dump(mode="json")
return payload.model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/publish-to-creators-platform")
@@ -918,12 +860,12 @@ class AppPublishToCreatorsPlatformApi(Resource):
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
@with_current_user_id
@get_app_model(mode=None)
@edit_permission_required
@with_current_user_id
def post(self, current_user_id: str, app_model: App):
"""Publish app to Creators Platform"""
from configs import dify_config
from core.helper.creators import get_redirect_url, upload_dsl
if not dify_config.CREATORS_PLATFORM_FEATURES_ENABLED:
@@ -935,7 +877,7 @@ class AppPublishToCreatorsPlatformApi(Resource):
claim_code = upload_dsl(dsl_bytes)
redirect_url = get_redirect_url(current_user_id, claim_code)
return RedirectUrlResponse(redirect_url=redirect_url).model_dump(mode="json")
return {"redirect_url": redirect_url}
@console_ns.route("/apps/<uuid:app_id>/name")
@@ -948,15 +890,15 @@ class AppNameApi(Resource):
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model: App):
args = AppNamePayload.model_validate(console_ns.payload)
app_service = AppService()
app_model = app_service.update_app_name(app_model, args.name)
return dump_response(AppDetail, app_model)
response_model = AppDetail.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/icon")
@@ -965,14 +907,13 @@ class AppIconApi(Resource):
@console_ns.doc(description="Update application icon")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[AppIconPayload.__name__])
@console_ns.response(200, "Icon updated successfully", console_ns.models[AppDetail.__name__])
@console_ns.response(200, "Icon updated successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model: App):
args = AppIconPayload.model_validate(console_ns.payload or {})
@@ -983,7 +924,8 @@ class AppIconApi(Resource):
args.icon_background or "",
args.icon_type,
)
return dump_response(AppDetail, app_model)
response_model = AppDetail.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/site-enable")
@@ -997,15 +939,15 @@ class AppSiteStatus(Resource):
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model: App):
args = AppSiteStatusPayload.model_validate(console_ns.payload)
app_service = AppService()
app_model = app_service.update_app_site_status(app_model, args.enable_site)
return dump_response(AppDetail, app_model)
response_model = AppDetail.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/api-enable")
@@ -1020,14 +962,14 @@ class AppApiStatus(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@get_app_model(mode=None)
def post(self, app_model: App):
args = AppApiStatusPayload.model_validate(console_ns.payload)
app_service = AppService()
app_model = app_service.update_app_api_status(app_model, args.enable_api)
return dump_response(AppDetail, app_model)
response_model = AppDetail.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/trace")
@@ -1035,22 +977,17 @@ class AppTraceApi(Resource):
@console_ns.doc("get_app_trace")
@console_ns.doc(description="Get app tracing configuration")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200,
"Trace configuration retrieved successfully",
console_ns.models[AppTraceResponse.__name__],
)
@console_ns.response(200, "Trace configuration retrieved successfully")
@setup_required
@login_required
@account_initialization_required
@with_session
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model
def get(self, session: Session, app_model: App):
"""Get app trace"""
app_trace_config = OpsTraceManager.get_app_tracing_config(app_model.id, session)
return dump_response(AppTraceResponse, app_trace_config)
return app_trace_config
@console_ns.doc("update_app_trace")
@console_ns.doc(description="Update app tracing configuration")
@@ -1066,7 +1003,6 @@ class AppTraceApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
@get_app_model
def post(self, app_model: App):
# add app trace
@@ -1078,4 +1014,4 @@ class AppTraceApi(Resource):
tracing_provider=args.tracing_provider,
)
return SimpleResultResponse(result="success").model_dump(mode="json")
return {"result": "success"}
+7 -81
View File
@@ -2,36 +2,25 @@ from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from configs import dify_config
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
cloud_edition_billing_resource_check,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_user,
)
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.login import current_account_with_tenant, login_required
from libs.login import login_required
from models.account import Account
from models.model import App
from services.app_dsl_service import (
IMPORT_INFO_REDIS_KEY_PREFIX,
AppDslService,
Import,
PendingData,
)
from services.app_dsl_service import AppDslService, Import
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.feature_service import FeatureService
from .. import console_ns
from .permission_keys import get_app_permission_keys
class AppImportPayload(BaseModel):
@@ -50,24 +39,6 @@ register_enum_models(console_ns, ImportStatus)
register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult)
def _current_user_and_tenant_id(current_user: Account | None) -> tuple[Account, str | None]:
if current_user is None:
account, tenant_id = current_account_with_tenant()
return account, str(tenant_id) if tenant_id else None
current_tenant_id = getattr(current_user, "current_tenant_id", None)
if current_tenant_id:
return current_user, str(current_tenant_id)
current_tenant = getattr(current_user, "current_tenant", None)
current_tenant_object_id = getattr(current_tenant, "id", None)
if current_tenant_object_id:
return current_user, str(current_tenant_object_id)
account, fallback_tenant_id = current_account_with_tenant()
return account, str(fallback_tenant_id) if fallback_tenant_id else None
@console_ns.route("/apps/imports")
class AppImportApi(Resource):
@console_ns.expect(console_ns.models[AppImportPayload.__name__])
@@ -79,11 +50,10 @@ class AppImportApi(Resource):
@account_initialization_required
@cloud_edition_billing_resource_check("apps")
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL, resource_required=False)
@with_current_user
def post(self, current_user: Account | None = None):
def post(self, current_user: Account):
# Check user role first
args = AppImportPayload.model_validate(console_ns.payload)
current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0]
# AppDslService performs internal commits for some creation paths, so use a plain
# Session here instead of nesting it inside sessionmaker(...).begin().
@@ -107,20 +77,6 @@ class AppImportApi(Resource):
session.rollback()
else:
session.commit()
is_created_app = args.app_id is None and result.status in {
ImportStatus.COMPLETED,
ImportStatus.COMPLETED_WITH_WARNINGS,
}
if dify_config.RBAC_ENABLED and is_created_app and result.app_id:
current_user, current_tenant_id = _current_user_and_tenant_id(current_user)
if current_tenant_id:
result.permission_keys = get_app_permission_keys(
current_tenant_id,
current_user.id,
result.app_id,
)
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
# update web app setting as private
EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private")
@@ -143,16 +99,9 @@ class AppImportConfirmApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL, resource_required=False)
@with_current_user
def post(self, current_user: Account | None = None, import_id: str = ""):
current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0]
redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}"
pending_data_raw = redis_client.get(redis_key)
pending_data: PendingData | None = None
if pending_data_raw:
pending_data = PendingData.model_validate_json(pending_data_raw)
def post(self, current_user: Account, import_id: str):
# Check user role first
with Session(db.engine, expire_on_commit=False) as session:
import_service = AppDslService(session)
# Confirm import
@@ -163,24 +112,6 @@ class AppImportConfirmApi(Resource):
else:
session.commit()
is_created_app = bool(
pending_data
and pending_data.app_id is None
and result.status
in {
ImportStatus.COMPLETED,
ImportStatus.COMPLETED_WITH_WARNINGS,
}
)
if dify_config.RBAC_ENABLED and is_created_app and result.app_id:
current_user, current_tenant_id = _current_user_and_tenant_id(current_user)
if current_tenant_id:
result.permission_keys = get_app_permission_keys(
current_tenant_id,
current_user.id,
result.app_id,
)
# Return appropriate status code based on result
if result.status == ImportStatus.FAILED:
return result.model_dump(mode="json"), 400
@@ -189,17 +120,12 @@ class AppImportConfirmApi(Resource):
@console_ns.route("/apps/imports/<string:app_id>/check-dependencies")
class AppImportCheckDependenciesApi(Resource):
@console_ns.response(
200,
"Dependencies checked",
console_ns.models[CheckDependenciesResult.__name__],
)
@console_ns.response(200, "Dependencies checked", console_ns.models[CheckDependenciesResult.__name__])
@setup_required
@login_required
@get_app_model
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
def get(self, app_model: App):
with Session(db.engine, expire_on_commit=False) as session:
import_service = AppDslService(session)
+17 -55
View File
@@ -1,12 +1,12 @@
import logging
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
from flask_restx import Resource, fields
from pydantic import BaseModel, Field
from werkzeug.exceptions import InternalServerError
import services
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
AppUnavailableError,
@@ -20,21 +20,11 @@ from controllers.console.app.error import (
UnsupportedAudioTypeError,
)
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
rbac_permission_required,
setup_required,
)
from controllers.console.wraps import account_initialization_required, setup_required
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import dump_response
from libs.login import current_user, login_required
from libs.login import login_required
from models import App, AppMode
from services.app_ref_service import AppRefService
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -57,27 +47,11 @@ class TextToSpeechVoiceQuery(BaseModel):
language: str = Field(..., description="Language code")
class AudioTranscriptResponse(ResponseModel):
class AudioTranscriptResponse(BaseModel):
text: str = Field(description="Transcribed text from audio")
class TextToSpeechVoiceResponse(ResponseModel):
# see api/core/plugin/impl/model.py
name: str = Field(description="Voice display name")
value: str = Field(description="Voice identifier")
class TextToSpeechVoiceListResponse(RootModel[list[TextToSpeechVoiceResponse]]):
root: list[TextToSpeechVoiceResponse] = Field(description="Available voices")
register_schema_models(console_ns, TextToSpeechPayload, TextToSpeechVoiceQuery)
register_response_schema_models(
console_ns,
AudioTranscriptResponse,
TextToSpeechVoiceResponse,
TextToSpeechVoiceListResponse,
)
register_schema_models(console_ns, AudioTranscriptResponse, TextToSpeechPayload, TextToSpeechVoiceQuery)
@console_ns.route("/apps/<uuid:app_id>/audio-to-text")
@@ -106,7 +80,7 @@ class ChatMessageAudioApi(Resource):
end_user=None,
)
return dump_response(AudioTranscriptResponse, response)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
@@ -139,34 +113,24 @@ class ChatMessageTextApi(Resource):
@console_ns.doc(description="Convert text to speech for chat messages")
@console_ns.doc(params={"app_id": "App ID"})
@console_ns.expect(console_ns.models[TextToSpeechPayload.__name__])
# TTS returns provider audio bytes, so the success response is intentionally schema-less.
@console_ns.response(200, "Text to speech conversion successful")
@console_ns.response(400, "Bad request - Invalid parameters")
@get_app_model
@setup_required
@login_required
@account_initialization_required
@get_app_model
def post(self, app_model: App):
try:
payload = TextToSpeechPayload.model_validate(console_ns.payload)
message_ref = None
if payload.message_id:
app_ref = AppRefService.create_app_ref(app_model)
message_ref = AppRefService.create_message_ref(
app_ref,
payload.message_id,
account_id=current_user.id,
)
# response-contract:ignore
return AudioService.transcript_tts(
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=payload.text,
voice=payload.voice,
message_ref=message_ref,
message_id=payload.message_id,
is_draft=True,
)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
@@ -197,18 +161,16 @@ class ChatMessageTextApi(Resource):
class TextModesApi(Resource):
@console_ns.doc("get_text_to_speech_voices")
@console_ns.doc(description="Get available TTS voices for a specific language")
@console_ns.doc(params={"app_id": "App ID", **query_params_from_model(TextToSpeechVoiceQuery)})
@console_ns.doc(params={"app_id": "App ID"})
@console_ns.expect(console_ns.models[TextToSpeechVoiceQuery.__name__])
@console_ns.response(
200,
"TTS voices retrieved successfully",
console_ns.models[TextToSpeechVoiceListResponse.__name__],
200, "TTS voices retrieved successfully", fields.List(fields.Raw(description="Available voices"))
)
@console_ns.response(400, "Invalid language parameter")
@get_app_model
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model
def get(self, app_model: App):
try:
args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))
@@ -218,7 +180,7 @@ class TextModesApi(Resource):
language=args.language,
)
return dump_response(TextToSpeechVoiceListResponse, response)
return response
except services.errors.audio.ProviderNotSupportTextToSpeechLanageServiceError:
raise AppUnavailableError("Text to audio voices language parameter loss.")
except NoAudioUploadedServiceError:
+59 -421
View File
@@ -1,20 +1,15 @@
import json
import logging
from collections.abc import Generator, Iterator, Mapping
from typing import Any, Literal, Protocol, runtime_checkable
from uuid import UUID
from typing import Any, Literal
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
import services
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
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,
CompletionRequestError,
@@ -23,36 +18,28 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import get_app_model, with_session
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
with_current_user_id,
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
from core.errors.error import (
ModelCurrentlyNotSupportError,
ProviderTokenNotInitError,
QuotaExceededError,
)
from core.helper.trace_id_helper import get_external_trace_id
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from models.model import App, AppMode
from services.agent.errors import AgentNotFoundError
from services.agent.roster_service import AgentRosterService
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.errors.llm import InvokeRateLimitError
@@ -60,11 +47,6 @@ from services.errors.llm import InvokeRateLimitError
logger = logging.getLogger(__name__)
@runtime_checkable
class _ClosableStream(Protocol):
def close(self) -> None: ...
def _resolve_debugger_chat_streaming(
*, app_mode: AppMode, response_mode: str, response_mode_provided: bool = True
) -> bool:
@@ -82,14 +64,8 @@ class BaseMessagePayload(BaseModel):
# Soul, so no override ``model_config`` is sent; chat / agent-chat / completion
# debugging still pass it. Optional here, required in practice by those modes
# downstream when their config is built from args.
model_config_data: dict[str, Any] = Field(
default_factory=dict,
alias="model_config",
)
files: list[Any] | None = Field(
default=None,
description="Uploaded files",
)
model_config_data: dict[str, Any] = Field(default_factory=dict, alias="model_config")
files: list[Any] | None = Field(default=None, description="Uploaded files")
response_mode: Literal["blocking", "streaming"] = Field(default="blocking", description="Response mode")
retriever_from: str = Field(default="dev", description="Retriever source")
@@ -102,10 +78,6 @@ class ChatMessagePayload(BaseMessagePayload):
query: str = Field(..., description="User query")
conversation_id: str | None = Field(default=None, description="Conversation ID")
parent_message_id: str | None = Field(default=None, description="Parent message ID")
draft_type: Literal["draft", "debug_build"] = Field(
default="draft",
description="Agent App debug config source. Use debug_build while the Agent is in build mode.",
)
@field_validator("conversation_id", "parent_message_id")
@classmethod
@@ -115,29 +87,6 @@ class ChatMessagePayload(BaseMessagePayload):
return uuid_value(value)
_BUILD_CHAT_FINALIZATION_QUERY = """Finalize this Build chat configuration for the agent.
This step is only for persisting Agent config changes discovered in the current Build chat. Do not install packages,
edit workspace files, run validation or debugging commands, make exploratory checks, or perform other work.
Use only the current Build chat message history to identify changes that need to be persisted. Do not inspect, test, or
validate old config unless the message history already shows that the old config is invalid.
Only update the build-draft config note when the current Build chat contains durable context that later runs need.
Do not create, update, delete, inspect, or fill gaps in other Agent config resources, including config files, config
skills, config env, tools, models, knowledge, or prompt settings.
When updating the config note with the Agent config CLI usage provided in the runtime prompt, record only durable
context needed by later runs, such as:
- what you installed or configured outside the workspace for this agent,
- where those external updates live, including CLI tools, packages, and persistent $HOME paths,
- how the agent should use it in later runs,
- any setup, authentication, or user action still required.
After config persistence completes, respond FINISHED."""
register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)
register_response_schema_models(console_ns, SimpleResultResponse)
@@ -155,11 +104,9 @@ class CompletionMessageApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@get_app_model(mode=AppMode.COMPLETION)
@with_session
def post(self, session: Session, current_user: Account, app_model: App):
@with_current_user
def post(self, current_user: Account, app_model: App):
args_model = CompletionMessagePayload.model_validate(console_ns.payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
@@ -168,15 +115,9 @@ class CompletionMessageApi(Resource):
try:
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.DEBUGGER,
streaming=streaming,
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -209,8 +150,8 @@ class CompletionMessageStopApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user_id
@get_app_model(mode=AppMode.COMPLETION)
@with_current_user_id
def post(self, current_user_id: str, app_model: App, task_id: str):
AppTaskService.stop_task(
@@ -220,7 +161,7 @@ class CompletionMessageStopApi(Resource):
app_mode=AppMode.value_of(app_model.mode),
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return {"result": "success"}, 200
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
@@ -235,71 +176,55 @@ class ChatMessageApi(Resource):
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT])
@with_session
def post(self, session: Session, current_tenant_id: str, current_user: Account, app_model: App):
return _create_chat_message(
session=session, current_tenant_id=current_tenant_id, current_user=current_user, app_model=app_model
)
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
class AgentChatMessageApi(Resource):
@console_ns.doc("create_agent_chat_message")
@console_ns.doc(description="Generate an Agent App chat message for debugging")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
@console_ns.response(200, "Chat message generated successfully")
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(404, "Agent or conversation not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@with_current_user
@with_current_tenant_id
@with_session
def post(self, session: Session, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _create_chat_message(
session=session,
current_tenant_id=current_tenant_id,
current_user=current_user,
app_model=app_model,
agent_id=str(agent_id),
)
def post(self, current_user: Account, app_model: App):
raw_payload = console_ns.payload or {}
args_model = ChatMessagePayload.model_validate(raw_payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
@console_ns.route("/agent/<uuid:agent_id>/build-chat/finalize")
class AgentBuildChatFinalizeApi(Resource):
@console_ns.doc("finalize_agent_build_chat")
@console_ns.doc(description="Run a build-draft Agent App turn that asks the agent to push config updates")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(404, "Agent, build draft, or conversation not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@with_current_user
@with_current_tenant_id
@with_session
def post(self, session: Session, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _create_build_chat_finalization_message(
session=session,
current_tenant_id=current_tenant_id,
current_user=current_user,
app_model=app_model,
agent_id=str(agent_id),
streaming = _resolve_debugger_chat_streaming(
app_mode=AppMode.value_of(app_model.mode),
response_mode=args_model.response_mode,
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
args["response_mode"] = "streaming"
args["auto_generate_name"] = False
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
try:
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
)
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("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 ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<string:task_id>/stop")
@@ -311,302 +236,15 @@ class ChatMessageStopApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user_id
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def post(self, current_user_id: str, app_model: App, task_id: str):
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop")
class AgentChatMessageStopApi(Resource):
@console_ns.doc("stop_agent_chat_message")
@console_ns.doc(description="Stop a running Agent App chat message generation")
@console_ns.doc(params={"agent_id": "Agent ID", "task_id": "Task ID to stop"})
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
def post(self, current_user_id: str, app_model: App, task_id: str):
def _resolve_current_user_agent_debug_conversation_id(
*, current_tenant_id: str, current_user: Account, app_model: App, agent_id: str | None
) -> str:
roster_service = AgentRosterService(db.session)
if agent_id:
return roster_service.get_or_create_agent_app_debug_conversation_id(
tenant_id=current_tenant_id,
agent_id=agent_id,
account_id=current_user.id,
)
agent = roster_service.get_app_backing_agent(tenant_id=current_tenant_id, app_id=str(app_model.id))
if agent is None:
raise AgentNotFoundError()
return roster_service.get_or_create_agent_app_debug_conversation_id(
tenant_id=current_tenant_id,
agent_id=agent.id,
account_id=current_user.id,
)
def _create_chat_message(
*,
session: Session,
current_user: Account,
app_model: App,
current_tenant_id: str | None = None,
agent_id: str | None = None,
):
raw_payload = console_ns.payload or {}
args_model = ChatMessagePayload.model_validate(raw_payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
debug_conversation_id = _resolve_current_user_agent_debug_conversation_id(
current_tenant_id=current_tenant_id or app_model.tenant_id,
current_user=current_user,
app_model=app_model,
agent_id=agent_id,
)
if args_model.conversation_id and args_model.conversation_id != debug_conversation_id:
raise NotFound("Conversation Not Exists.")
args["conversation_id"] = debug_conversation_id
streaming = _resolve_debugger_chat_streaming(
app_mode=AppMode.value_of(app_model.mode),
response_mode=args_model.response_mode,
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
args["response_mode"] = "streaming"
args["auto_generate_name"] = False
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
return _generate_chat_message_response(
session=session,
current_user=current_user,
app_model=app_model,
args=args,
streaming=streaming,
)
def _create_build_chat_finalization_message(
*, session: Session, current_user: Account, app_model: App, current_tenant_id: str, agent_id: str
):
debug_conversation_id = _resolve_current_user_agent_debug_conversation_id(
current_tenant_id=current_tenant_id,
current_user=current_user,
app_model=app_model,
agent_id=agent_id,
)
args: dict[str, Any] = {
"query": _BUILD_CHAT_FINALIZATION_QUERY,
"inputs": {},
"response_mode": "streaming",
"draft_type": "debug_build",
"conversation_id": debug_conversation_id,
"auto_generate_name": False,
}
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
response = _generate_chat_message(
session=session,
current_user=current_user,
app_model=app_model,
args=args,
streaming=True,
)
_drain_streaming_generate_response(response)
return {"result": "success"}, 200
def _drain_streaming_generate_response(response: RateLimitGenerator | Generator[str, None, None]) -> None:
"""Consume a streamed app-generate response until a terminal message event arrives.
Finalize keeps the normal Agent App streaming path so the existing queue,
persistence, and runtime-session behavior stay intact. The console API only
changes the HTTP boundary: it drains the SSE stream server-side and returns
success after the generated build-chat message reaches ``message_end``.
"""
try:
for chunk in response:
for raw_event in chunk.split("\n\n"):
if not raw_event.strip():
continue
event_name: str | None = None
data_lines: list[str] = []
for line in raw_event.splitlines():
if line.startswith("event: "):
event_name = line.removeprefix("event: ").strip()
elif line.startswith("data: "):
data_lines.append(line.removeprefix("data: "))
if not data_lines:
if event_name == "ping":
continue
continue
payload = json.loads("\n".join(data_lines))
if not isinstance(payload, dict):
continue
payload_event = payload.get("event")
if payload_event == "message_end":
return
if payload_event == "error":
raise CompletionRequestError(str(payload.get("message") or "Build chat finalization failed."))
finally:
if isinstance(response, _ClosableStream):
response.close()
raise CompletionRequestError("Build chat finalization did not complete.")
def _generate_chat_message(
*,
session: Session,
current_user: Account,
app_model: App,
args: dict[str, Any],
streaming: bool,
):
try:
return AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.DEBUGGER,
streaming=streaming,
user_id=current_user_id,
app_mode=AppMode.value_of(app_model.mode),
)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("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 ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except CompletionRequestError:
raise
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
def _generate_chat_message_response(
*,
session: Session,
current_user: Account,
app_model: App,
args: dict[str, Any],
streaming: bool,
):
response = _generate_chat_message(
session=session,
current_user=current_user,
app_model=app_model,
args=args,
streaming=streaming,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT and streaming:
response = _raise_agent_stream_error_before_response(response)
return helper.compact_generate_response(response)
def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str):
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.DEBUGGER,
user_id=current_user_id,
app_mode=AppMode.value_of(app_model.mode),
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
def _raise_agent_stream_error_before_response(response):
"""Surface immediate Agent App stream errors as HTTP errors before SSE starts.
The shared streaming helper always returns HTTP 200 once the SSE response is
created. Agent v2 configuration errors, such as an invalid model API key,
can be the first real stream event after the initial ping; pre-reading that
first non-ping event lets the console API return the existing 400 error
contract instead of a successful HTTP response carrying only an SSE error.
"""
if isinstance(response, Mapping):
return response
buffered: list[str] = []
iterator = iter(response)
while True:
try:
chunk = next(iterator)
except StopIteration:
return iter(buffered)
if not isinstance(chunk, str):
return _prepend_stream_chunks(buffered, chunk, iterator)
if _is_sse_ping(chunk):
buffered.append(chunk)
continue
error_payload = _extract_sse_error_payload(chunk)
if error_payload is not None:
if isinstance(response, _ClosableStream):
response.close()
message = error_payload.get("message")
raise CompletionRequestError(str(message or "Agent App chat failed."))
return _prepend_stream_chunks(buffered, chunk, iterator)
def _prepend_stream_chunks(buffered: list[Any], first: Any, iterator: Iterator[Any]) -> Generator[Any, None, None]:
yield from buffered
yield first
yield from iterator
def _is_sse_ping(chunk: str) -> bool:
return chunk.strip() == "event: ping"
def _extract_sse_error_payload(chunk: str) -> dict[str, Any] | None:
for raw_event in chunk.split("\n\n"):
data_lines: list[str] = []
for line in raw_event.splitlines():
if line.startswith("data: "):
data_lines.append(line.removeprefix("data: "))
if not data_lines:
continue
try:
payload = json.loads("\n".join(data_lines))
except json.JSONDecodeError:
continue
if isinstance(payload, dict) and payload.get("event") == "error":
return payload
return None
return {"result": "success"}, 200
+28 -34
View File
@@ -9,15 +9,12 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import selectinload
from werkzeug.exceptions import NotFound
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
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,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_user,
)
@@ -39,9 +36,7 @@ from fields.conversation_fields import (
ConversationWithSummaryPagination as ConversationWithSummaryPaginationResponse,
)
from libs.datetime_utils import naive_utc_now, parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from libs.pagination import paginate_query
from models import Conversation, EndUser, Message, MessageAnnotation
from models.account import Account
from models.model import App, AppMode
@@ -81,14 +76,13 @@ register_schema_models(
console_ns,
CompletionConversationQuery,
ChatConversationQuery,
)
register_response_schema_models(
console_ns,
ConversationResponse,
ConversationPaginationResponse,
ConversationMessageDetailResponse,
ConversationWithSummaryPaginationResponse,
ConversationDetailResponse,
CompletionConversationQuery,
ChatConversationQuery,
)
@@ -96,16 +90,16 @@ register_response_schema_models(
class CompletionConversationApi(Resource):
@console_ns.doc("list_completion_conversations")
@console_ns.doc(description="Get completion conversations with pagination and filtering")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(CompletionConversationQuery)})
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[CompletionConversationQuery.__name__])
@console_ns.response(200, "Success", console_ns.models[ConversationPaginationResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@edit_permission_required
@with_current_user
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model(mode=AppMode.COMPLETION)
def get(self, current_user: Account, app_model: App):
args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True))
@@ -157,9 +151,11 @@ class CompletionConversationApi(Resource):
query = query.order_by(Conversation.created_at.desc())
conversations = paginate_query(query, page=args.page, per_page=args.limit)
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
return dump_response(ConversationPaginationResponse, conversations)
return ConversationPaginationResponse.model_validate(conversations, from_attributes=True).model_dump(
mode="json"
)
@console_ns.route("/apps/<uuid:app_id>/completion-conversations/<uuid:conversation_id>")
@@ -173,15 +169,14 @@ class CompletionConversationDetailApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@edit_permission_required
@with_current_user
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model(mode=AppMode.COMPLETION)
def get(self, current_user: Account, app_model: App, conversation_id: UUID):
conversation_id_str = str(conversation_id)
return dump_response(
ConversationMessageDetailResponse, _get_conversation(current_user, app_model, conversation_id_str)
)
return ConversationMessageDetailResponse.model_validate(
_get_conversation(current_user, app_model, conversation_id_str), from_attributes=True
).model_dump(mode="json")
@console_ns.doc("delete_completion_conversation")
@console_ns.doc(description="Delete a completion conversation")
@@ -192,10 +187,9 @@ class CompletionConversationDetailApi(Resource):
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@with_current_user
@get_app_model(mode=AppMode.COMPLETION)
@edit_permission_required
@with_current_user
def delete(self, current_user: Account, app_model: App, conversation_id: UUID):
conversation_id_str = str(conversation_id)
@@ -211,16 +205,16 @@ class CompletionConversationDetailApi(Resource):
class ChatConversationApi(Resource):
@console_ns.doc("list_chat_conversations")
@console_ns.doc(description="Get chat conversations with pagination, filtering and summary")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(ChatConversationQuery)})
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[ChatConversationQuery.__name__])
@console_ns.response(200, "Success", console_ns.models[ConversationWithSummaryPaginationResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@edit_permission_required
@with_current_user
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def get(self, current_user: Account, app_model: App):
args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True))
@@ -311,9 +305,11 @@ class ChatConversationApi(Resource):
case _:
query = query.order_by(Conversation.created_at.desc())
conversations = paginate_query(query, page=args.page, per_page=args.limit)
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
return dump_response(ConversationWithSummaryPaginationResponse, conversations)
return ConversationWithSummaryPaginationResponse.model_validate(conversations, from_attributes=True).model_dump(
mode="json"
)
@console_ns.route("/apps/<uuid:app_id>/chat-conversations/<uuid:conversation_id>")
@@ -327,15 +323,14 @@ class ChatConversationDetailApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@edit_permission_required
@with_current_user
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def get(self, current_user: Account, app_model: App, conversation_id: UUID):
conversation_id_str = str(conversation_id)
return dump_response(
ConversationDetailResponse, _get_conversation(current_user, app_model, conversation_id_str)
)
return ConversationDetailResponse.model_validate(
_get_conversation(current_user, app_model, conversation_id_str), from_attributes=True
).model_dump(mode="json")
@console_ns.doc("delete_chat_conversation")
@console_ns.doc(description="Delete a chat conversation")
@@ -345,11 +340,10 @@ class ChatConversationDetailApi(Resource):
@console_ns.response(404, "Conversation not found")
@setup_required
@login_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@with_current_user
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def delete(self, current_user: Account, app_model: App, conversation_id: UUID):
conversation_id_str = str(conversation_id)
@@ -9,20 +9,14 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from controllers.common.schema import query_params_from_model, register_schema_models
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,
RBACResourceScope,
account_initialization_required,
rbac_permission_required,
setup_required,
)
from controllers.console.wraps import account_initialization_required, setup_required
from extensions.ext_database import db
from fields._value_type_serializer import serialize_value_type
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.helper import to_timestamp
from libs.login import login_required
from models import ConversationVariable
from models.model import App, AppMode
@@ -90,7 +84,7 @@ class ConversationVariablesApi(Resource):
@console_ns.doc("get_conversation_variables")
@console_ns.doc(description="Get conversation variables for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(ConversationVariablesQuery))
@console_ns.expect(console_ns.models[ConversationVariablesQuery.__name__])
@console_ns.response(
200,
"Conversation variables retrieved successfully",
@@ -99,7 +93,6 @@ class ConversationVariablesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT)
@get_app_model(mode=AppMode.ADVANCED_CHAT)
def get(self, app_model: App):
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True))
@@ -119,8 +112,7 @@ class ConversationVariablesApi(Resource):
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
rows = session.scalars(stmt).all()
return dump_response(
PaginatedConversationVariableResponse,
response = PaginatedConversationVariableResponse.model_validate(
{
"page": page,
"limit": page_size,
@@ -136,5 +128,6 @@ class ConversationVariablesApi(Resource):
)
for row in rows
],
},
}
)
return response.model_dump(mode="json")
+23 -185
View File
@@ -1,14 +1,11 @@
import json
from collections.abc import Generator, Sequence
from typing import Any, Literal
from collections.abc import Sequence
from typing import Literal
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
from sqlalchemy import select
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.fields import SimpleDataResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
CompletionRequestError,
@@ -25,10 +22,8 @@ from core.helper.code_executor.javascript.javascript_code_provider import Javasc
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.llm_generator import LLMGenerator
from core.workflow.generator.types import WorkflowGenerateErrorCode
from graphon.model_runtime.entities.llm_entities import LLMMode
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import compact_generate_response
from libs.login import login_required
from models import App
from services.workflow_generator_service import WorkflowGeneratorService
@@ -41,11 +36,7 @@ class InstructionGeneratePayload(BaseModel):
current: str = Field(default="", description="Current instruction text")
language: str = Field(default="javascript", description="Programming language (javascript/python)")
instruction: str = Field(..., description="Instruction for generation")
model_config_data: ModelConfig = Field(
...,
alias="model_config",
description="Model configuration",
)
model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration")
ideal_output: str = Field(default="", description="Expected ideal output")
@@ -53,13 +44,6 @@ class InstructionTemplatePayload(BaseModel):
type: str = Field(..., description="Instruction template type")
# Upper bound for the generator's free-text inputs. Generous for prose (a
# detailed instruction rarely passes 2k chars) while keeping the
# planner+builder prompts well inside every mainstream context window.
# Mirrored by the ``maxLength`` on the frontend generator textarea.
_MAX_INSTRUCTION_LENGTH = 10_000
class WorkflowGeneratePayload(BaseModel):
"""Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
@@ -68,40 +52,16 @@ class WorkflowGeneratePayload(BaseModel):
can reuse its existing handler.
"""
mode: Literal["workflow", "advanced-chat", "auto"] = Field(
...,
description="Target app mode for the generated graph; 'auto' lets the backend classify the instruction",
)
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the generated graph")
instruction: str = Field(..., description="Natural-language workflow description")
ideal_output: str = Field(default="", description="Optional sample output for grounding")
model_config_data: ModelConfig = Field(
...,
alias="model_config",
description="Model configuration",
)
model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration")
current_graph: dict | None = Field(
default=None,
description="Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch",
)
class WorkflowInstructionSuggestionsPayload(BaseModel):
"""Payload for the workflow-generator instruction-suggestions endpoint.
Runs before the user picks a model, so the suggestions come from the
tenant's default model. The underlying generator never raises — an empty
``suggestions`` list is a valid 200 (soft-fail).
"""
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the suggestions")
language: str | None = Field(default=None, description="Optional language to write the suggestions in")
count: int = Field(default=4, ge=1, le=6, description="Number of suggestions to return (1-6)")
class GeneratorResponse(RootModel[Any]):
root: Any
register_enum_models(console_ns, LLMMode)
register_schema_models(
console_ns,
@@ -111,10 +71,8 @@ register_schema_models(
InstructionGeneratePayload,
InstructionTemplatePayload,
WorkflowGeneratePayload,
WorkflowInstructionSuggestionsPayload,
ModelConfig,
)
register_response_schema_models(console_ns, GeneratorResponse, SimpleDataResponse)
@console_ns.route("/rule-generate")
@@ -122,11 +80,7 @@ class RuleGenerateApi(Resource):
@console_ns.doc("generate_rule_config")
@console_ns.doc(description="Generate rule configuration using LLM")
@console_ns.expect(console_ns.models[RuleGeneratePayload.__name__])
@console_ns.response(
200,
"Rule configuration generated successfully",
console_ns.models[GeneratorResponse.__name__],
)
@console_ns.response(200, "Rule configuration generated successfully")
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(402, "Provider quota exceeded")
@setup_required
@@ -155,7 +109,7 @@ class RuleCodeGenerateApi(Resource):
@console_ns.doc("generate_rule_code")
@console_ns.doc(description="Generate code rules using LLM")
@console_ns.expect(console_ns.models[RuleCodeGeneratePayload.__name__])
@console_ns.response(200, "Code rules generated successfully", console_ns.models[GeneratorResponse.__name__])
@console_ns.response(200, "Code rules generated successfully")
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(402, "Provider quota exceeded")
@setup_required
@@ -187,7 +141,7 @@ class RuleStructuredOutputGenerateApi(Resource):
@console_ns.doc("generate_structured_output")
@console_ns.doc(description="Generate structured output rules using LLM")
@console_ns.expect(console_ns.models[RuleStructuredOutputPayload.__name__])
@console_ns.response(200, "Structured output generated successfully", console_ns.models[GeneratorResponse.__name__])
@console_ns.response(200, "Structured output generated successfully")
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(402, "Provider quota exceeded")
@setup_required
@@ -219,7 +173,7 @@ class InstructionGenerateApi(Resource):
@console_ns.doc("generate_instruction")
@console_ns.doc(description="Generate instruction for workflow nodes or general use")
@console_ns.expect(console_ns.models[InstructionGeneratePayload.__name__])
@console_ns.response(200, "Instruction generated successfully", console_ns.models[GeneratorResponse.__name__])
@console_ns.response(200, "Instruction generated successfully")
@console_ns.response(400, "Invalid request parameters or flow/workflow not found")
@console_ns.response(402, "Provider quota exceeded")
@setup_required
@@ -237,9 +191,7 @@ class InstructionGenerateApi(Resource):
try:
# Generate from nothing for a workflow node
if (args.current in (code_template, "")) and args.node_id != "":
app = session.scalar(
select(App).where(App.id == args.flow_id, App.tenant_id == current_tenant_id).limit(1)
)
app = session.get(App, args.flow_id)
if not app:
return {"error": f"app {args.flow_id} not found"}, 400
workflow = WorkflowService().get_draft_workflow(app_model=app, session=session)
@@ -316,7 +268,7 @@ class InstructionGenerationTemplateApi(Resource):
@console_ns.doc("get_instruction_template")
@console_ns.doc(description="Get instruction generation template")
@console_ns.expect(console_ns.models[InstructionTemplatePayload.__name__])
@console_ns.response(200, "Template retrieved successfully", console_ns.models[SimpleDataResponse.__name__])
@console_ns.response(200, "Template retrieved successfully")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@@ -336,34 +288,6 @@ class InstructionGenerationTemplateApi(Resource):
raise ValueError(f"Invalid type: {args.type}")
def _workflow_instruction_guard(args: WorkflowGeneratePayload) -> tuple[dict, int] | None:
"""Shared boundary guard for the workflow-generate endpoints.
Returns a ``(body, 400)`` tuple when the instruction is empty / whitespace
or either free-text field exceeds the cap, else ``None``. Pydantic only
validates the field is a str; a whitespace-only or pasted-document input
would otherwise waste a slow planner+builder roundtrip on a response the
validator rejects anyway. Both the blocking and streaming endpoints call
this so they reject identical inputs.
"""
if not args.instruction.strip():
return {
"error": "Instruction is required",
"errors": [{"code": WorkflowGenerateErrorCode.EMPTY_INSTRUCTION, "detail": "Instruction is required"}],
}, 400
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
return {
"error": "Instruction is too long",
"errors": [
{
"code": WorkflowGenerateErrorCode.INSTRUCTION_TOO_LONG,
"detail": f"Instruction and ideal output must each be at most {_MAX_INSTRUCTION_LENGTH} characters",
}
],
}, 400
return None
@console_ns.route("/workflow-generate")
class WorkflowGenerateApi(Resource):
"""Generate a Workflow / Chatflow draft graph from a natural-language description.
@@ -376,7 +300,7 @@ class WorkflowGenerateApi(Resource):
@console_ns.doc("generate_workflow_graph")
@console_ns.doc(description="Generate a Dify workflow graph from natural language")
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
@console_ns.response(200, "Workflow graph generated successfully", console_ns.models[GeneratorResponse.__name__])
@console_ns.response(200, "Workflow graph generated successfully")
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(402, "Provider quota exceeded")
@setup_required
@@ -386,11 +310,15 @@ class WorkflowGenerateApi(Resource):
def post(self, current_tenant_id: str):
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
# Reject empty / over-length instructions at the boundary (shared with
# the streaming endpoint) before spending a planner+builder roundtrip.
guard = _workflow_instruction_guard(args)
if guard is not None:
return guard
# Reject obviously-empty instructions at the boundary — Pydantic only
# validates ``instruction`` is a str, but a whitespace-only string
# would still hit the LLM and waste a planner+builder roundtrip on a
# response that the postprocess validator would reject anyway.
if not args.instruction.strip():
return {
"error": "Instruction is required",
"errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}],
}, 400
try:
result = WorkflowGeneratorService.generate_workflow_graph(
@@ -411,93 +339,3 @@ class WorkflowGenerateApi(Resource):
raise CompletionRequestError(e.description)
return result
@console_ns.route("/workflow-generate/suggestions")
class WorkflowInstructionSuggestionsApi(Resource):
"""Suggest short, buildable example instructions for the cmd+k generator.
Runs before a model is selected (uses the tenant's default model). The
underlying generator never raises, so an empty list is a valid 200 the
frontend renders "no suggestions" rather than an error, so no provider-error
mapping is needed here.
"""
@console_ns.doc("generate_workflow_instruction_suggestions")
@console_ns.doc(description="Suggest example workflow-generator instructions for the tenant")
@console_ns.expect(console_ns.models[WorkflowInstructionSuggestionsPayload.__name__])
@console_ns.response(200, "Suggestions generated successfully", console_ns.models[GeneratorResponse.__name__])
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str):
args = WorkflowInstructionSuggestionsPayload.model_validate(console_ns.payload)
suggestions = LLMGenerator.generate_workflow_instruction_suggestions(
tenant_id=current_tenant_id,
mode=args.mode,
language=args.language,
count=args.count,
)
return {"suggestions": suggestions}
@console_ns.route("/workflow-generate/stream")
class WorkflowGenerateStreamApi(Resource):
"""Plan-first streaming variant of ``/workflow-generate`` (Server-Sent Events).
Emits a ``plan`` event (high-level node list + app metadata) as soon as the
planner returns, then a final ``result`` event with the full graph the
SAME envelope ``/workflow-generate`` returns. Provider-init / invoke errors
are surfaced as a single ``result`` event (code ``MODEL_ERROR``) so the
frontend's stream parser always receives a result rather than a non-SSE HTTP
error.
"""
@console_ns.doc("generate_workflow_graph_stream")
@console_ns.doc(description="Stream a Dify workflow graph (plan then result) via SSE")
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
@console_ns.response(200, "Server-Sent Events stream of plan/result events")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str):
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
# Same boundary guards as the blocking endpoint — return a normal 400
# JSON for these BEFORE opening the stream.
guard = _workflow_instruction_guard(args)
if guard is not None:
return guard
def generate() -> Generator[str, None, None]:
try:
for event_name, payload in WorkflowGeneratorService.generate_workflow_graph_stream(
tenant_id=current_tenant_id,
mode=args.mode,
instruction=args.instruction,
model_config=args.model_config_data,
ideal_output=args.ideal_output,
current_graph=args.current_graph,
):
body = {"event": event_name, **payload}
yield f"data: {json.dumps(body)}\n\n"
except (ProviderTokenNotInitError, QuotaExceededError, ModelCurrentlyNotSupportError, InvokeError) as e:
# The model instance is resolved inside the service (lazily, on
# first iteration), so a provider / init error surfaces here.
# Emit it as a single SSE result event rather than a non-SSE
# error response so the frontend's stream parser always gets a
# result it can render.
detail = getattr(e, "description", None) or str(e) or "Model invocation failed"
error_body = {
"event": "result",
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0.0, "y": 0.0, "zoom": 0.7}},
"error": detail,
"errors": [{"code": WorkflowGenerateErrorCode.MODEL_ERROR, "detail": detail}],
}
yield f"data: {json.dumps(error_body)}\n\n"
return compact_generate_response(generate())
+10 -34
View File
@@ -12,38 +12,28 @@ 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,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.helper import 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
class MCPServerCreatePayload(BaseModel):
description: str | None = Field(default=None, description="Server description")
parameters: dict[str, Any] = Field(
...,
description="Server parameters configuration",
)
parameters: dict[str, Any] = Field(..., description="Server parameters configuration")
class MCPServerUpdatePayload(BaseModel):
id: str = Field(..., description="Server ID")
description: str | None = Field(default=None, description="Server description")
parameters: dict[str, Any] = Field(
...,
description="Server parameters configuration",
)
parameters: dict[str, Any] = Field(..., description="Server parameters configuration")
status: str | None = Field(default=None, description="Server status")
@@ -87,13 +77,12 @@ class AppMCPServerController(Resource):
@login_required
@account_initialization_required
@setup_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model
def get(self, app_model: App):
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
if server is None:
return {}
return dump_response(AppMCPServerResponse, server)
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json")
@console_ns.doc("create_app_mcp_server")
@console_ns.doc(description="Create MCP server configuration for an application")
@@ -104,12 +93,11 @@ class AppMCPServerController(Resource):
)
@console_ns.response(403, "Insufficient permissions")
@account_initialization_required
@get_app_model
@login_required
@setup_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@with_current_tenant_id
@get_app_model
def post(self, current_tenant_id: str, app_model: App):
payload = MCPServerCreatePayload.model_validate(console_ns.payload or {})
@@ -128,7 +116,7 @@ class AppMCPServerController(Resource):
)
db.session.add(server)
db.session.commit()
return dump_response(AppMCPServerResponse, server), 201
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json"), 201
@console_ns.doc("update_app_mcp_server")
@console_ns.doc(description="Update MCP server configuration for an application")
@@ -139,25 +127,14 @@ class AppMCPServerController(Resource):
)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found")
@get_app_model
@login_required
@setup_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@get_app_model
def put(self, app_model: App):
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
app_ref = AppRefService.create_app_ref(app_model)
server_ref = AppRefService.create_mcp_server_ref(app_ref, payload.id)
server = db.session.scalar(
select(AppMCPServer)
.where(
AppMCPServer.id == server_ref.server_id,
AppMCPServer.tenant_id == server_ref.tenant_id,
AppMCPServer.app_id == server_ref.app_id,
)
.limit(1)
)
server = db.session.get(AppMCPServer, payload.id)
if not server:
raise NotFound()
@@ -176,7 +153,7 @@ class AppMCPServerController(Resource):
except ValueError:
raise ValueError("Invalid status")
db.session.commit()
return dump_response(AppMCPServerResponse, server)
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json")
@console_ns.route("/apps/<uuid:server_id>/server/refresh")
@@ -191,7 +168,6 @@ class AppMCPServerRefreshController(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
def get(self, current_tenant_id: str, server_id: UUID):
server = db.session.scalar(
@@ -203,4 +179,4 @@ class AppMCPServerRefreshController(Resource):
raise NotFound()
server.server_code = AppMCPServer.generate_server_code(16)
db.session.commit()
return dump_response(AppMCPServerResponse, server)
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json")

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