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
2419 changed files with 71021 additions and 92383 deletions
@@ -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:
@@ -91,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.
@@ -122,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.
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/karpathy-guidelines
+2 -2
View File
@@ -23,8 +23,8 @@
/docs/ @crazywoola
# CLI
/cli/ @GareArc
/.github/workflows/cli-tests.yml @GareArc
/cli/ @langgenius/maintainers
/.github/workflows/cli-tests.yml @langgenius/maintainers
# Backend (default owner, more specific rules below will override)
/api/ @QuantumGhost
-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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
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@v4
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
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@v4
with:
name: e2e-last-${{ github.run_id }}
path: cli/test-results/
retention-days: 3
+1 -6
View File
@@ -37,17 +37,12 @@ jobs:
- 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' }}
+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 -6
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,
@@ -41,6 +39,8 @@ from clients.agent_backend.request_builder import (
AgentBackendOutputConfig,
AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput,
CleanupLayerSpec,
extract_cleanup_layer_specs,
redact_for_agent_backend_log,
)
@@ -51,7 +51,6 @@ __all__ = [
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
"WORKFLOW_USER_PROMPT_LAYER_ID",
"AgentBackendAgentAppRunInput",
"AgentBackendDeferredToolCallInternalEvent",
"AgentBackendError",
"AgentBackendHTTPError",
"AgentBackendInternalEvent",
@@ -64,6 +63,7 @@ __all__ = [
"AgentBackendRunEventAdapter",
"AgentBackendRunFailedError",
"AgentBackendRunFailedInternalEvent",
"AgentBackendRunPausedInternalEvent",
"AgentBackendRunRequestBuilder",
"AgentBackendRunStartedInternalEvent",
"AgentBackendRunSucceededInternalEvent",
@@ -72,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 -38
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"
@@ -69,13 +67,13 @@ class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
session_snapshot: CompositorSessionSnapshot
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
session_snapshot: CompositorSessionSnapshot | None = None
class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
@@ -97,7 +95,7 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
type AgentBackendInternalEvent = Annotated[
AgentBackendRunStartedInternalEvent
| AgentBackendStreamInternalEvent
| AgentBackendDeferredToolCallInternalEvent
| AgentBackendRunPausedInternalEvent
| AgentBackendRunSucceededInternalEvent
| AgentBackendRunFailedInternalEvent
| AgentBackendRunCancelledInternalEvent,
@@ -130,18 +128,6 @@ 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,
)
]
return [
AgentBackendRunSucceededInternalEvent(
run_id=event.run_id,
@@ -150,6 +136,16 @@ class AgentBackendRunEventAdapter:
session_snapshot=event.data.session_snapshot,
)
]
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():
return [
AgentBackendRunFailedInternalEvent(
@@ -169,18 +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}'."
+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=[]),
),
),
+74 -43
View File
@@ -11,8 +11,7 @@ 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
@@ -41,7 +40,6 @@ from dify_agent.protocol import (
RunComposition,
RunLayerSpec,
RunPurpose,
RuntimeLayerSpec,
)
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
@@ -53,10 +51,71 @@ DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
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.
@@ -83,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.
@@ -248,7 +283,7 @@ 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,
),
)
)
@@ -265,14 +300,12 @@ class AgentBackendRunRequestBuilder:
)
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
# the agent server can mint per-command Agent Stub env (back proxy);
# shellctl connection itself is server-injected.
# 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_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.shell_config or DifyShellLayerConfig(),
)
@@ -307,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:
@@ -320,9 +353,9 @@ class AgentBackendRunRequestBuilder:
composition and the snapshot before submission because their configs
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 {})
@@ -335,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",
@@ -404,7 +437,7 @@ 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,
),
),
]
@@ -422,14 +455,12 @@ class AgentBackendRunRequestBuilder:
)
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
# the agent server can mint per-command Agent Stub env (back proxy);
# shellctl connection itself is server-injected.
# 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_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.shell_config or DifyShellLayerConfig(),
)
@@ -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",
]
+2
View File
@@ -11,6 +11,7 @@ from .data_migration import (
migration_data_wizard,
)
from .plugin import (
backfill_plugin_auto_upgrade,
extract_plugins,
extract_unique_plugins,
install_plugins,
@@ -49,6 +50,7 @@ from .vector import (
__all__ = [
"add_qdrant_index",
"archive_workflow_runs",
"backfill_plugin_auto_upgrade",
"clean_expired_messages",
"clean_workflow_runs",
"cleanup_orphaned_draft_variables",
+1 -1
View File
@@ -145,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,
+108 -1
View File
@@ -1,10 +1,11 @@
import json
import logging
import time
from typing import Any, cast
import click
from pydantic import TypeAdapter
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from sqlalchemy.engine import CursorResult
from configs import dify_config
@@ -15,11 +16,13 @@ from core.plugin.plugin_service import PluginService
from core.tools.utils.system_encryption import encrypt_system_params
from extensions.ext_database import db
from models import Tenant
from models.account import TenantPluginAutoUpgradeStrategy
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
from models.provider_ids import DatasourceProviderID, ToolProviderID
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
from models.tools import ToolOAuthSystemClient
from services.plugin.data_migration import PluginDataMigration
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_migration import PluginMigration
logger = logging.getLogger(__name__)
@@ -402,6 +405,110 @@ def migrate_data_for_plugin():
click.echo(click.style("Migrate data for plugin completed.", fg="green"))
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
stmt = (
select(TenantPluginAutoUpgradeStrategy.tenant_id)
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)
.having(func.count(func.distinct(TenantPluginAutoUpgradeStrategy.category)) < category_count)
.order_by(TenantPluginAutoUpgradeStrategy.tenant_id)
)
if limit is not None:
stmt = stmt.limit(limit)
return stmt
def _count_auto_upgrade_strategy_tenant_ids(limit: int | None) -> int:
candidate_stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).subquery()
return db.session.scalar(select(func.count()).select_from(candidate_stmt)) or 0
def _iter_auto_upgrade_strategy_tenant_ids(limit: int | None):
stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).execution_options(yield_per=1000)
yield from db.session.scalars(stmt)
@click.command(
"backfill-plugin-auto-upgrade",
help="Backfill category-scoped plugin auto-upgrade strategies and normalize plugin lists.",
)
@click.option("--tenant-id", multiple=True, help="Tenant ID to backfill. Can be passed multiple times.")
@click.option("--limit", type=int, default=None, help="Maximum number of candidate tenants to process.")
@click.option("--batch-size", type=int, default=500, show_default=True, help="Progress reporting batch size.")
@click.option("--dry-run", is_flag=True, help="Only print candidate tenant count.")
def backfill_plugin_auto_upgrade(
tenant_id: tuple[str, ...],
limit: int | None,
batch_size: int,
dry_run: bool,
):
"""
Backfill historical auto-upgrade strategies after the category column exists.
Missing category rows are created from the tenant's tool/default row. Pure default
strategies become latest for model plugins and fix-only for all other categories.
Tenants with include/exclude plugin IDs are split
by installed plugin category using plugin daemon metadata.
"""
start_at = time.perf_counter()
candidate_count = len(tenant_id) if tenant_id else _count_auto_upgrade_strategy_tenant_ids(limit)
click.echo(click.style(f"Found {candidate_count} candidate tenants.", fg="yellow"))
if dry_run:
elapsed = time.perf_counter() - start_at
click.echo(click.style(f"Dry run completed. elapsed={elapsed:.2f}s", fg="green"))
return
tenant_ids = list(tenant_id) if tenant_id else _iter_auto_upgrade_strategy_tenant_ids(limit)
backfilled_count = 0
created_count = 0
normalized_count = 0
skipped_count = 0
failed_count = 0
for index, current_tenant_id in enumerate(tenant_ids, start=1):
try:
result = PluginAutoUpgradeService.backfill_strategy_categories(
current_tenant_id,
)
except Exception as e:
failed_count += 1
click.echo(click.style(f"Failed tenant {current_tenant_id}: {str(e)}", fg="red"))
continue
if result.created_count > 0:
backfilled_count += 1
created_count += result.created_count
elif not result.normalized:
skipped_count += 1
if result.normalized:
normalized_count += 1
if batch_size > 0 and index % batch_size == 0:
click.echo(
click.style(
f"Processed {index}/{candidate_count} tenants. "
f"backfilled={backfilled_count}, created_rows={created_count}, "
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
f"elapsed={time.perf_counter() - start_at:.2f}s",
fg="yellow",
)
)
elapsed = time.perf_counter() - start_at
click.echo(
click.style(
f"Backfill plugin auto-upgrade strategy categories completed. "
f"backfilled={backfilled_count}, created_rows={created_count}, "
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
f"elapsed={elapsed:.2f}s",
fg="green",
)
)
@click.command("extract-plugins", help="Extract plugins.")
@click.option("--output_file", prompt=True, help="The file to store the extracted plugins.", default="plugins.jsonl")
@click.option("--workers", prompt=True, help="The number of workers to extract plugins.", default=10)
+2 -3
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,
)
+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
+1 -1
View File
@@ -62,7 +62,7 @@ class WorkflowListQuery(BaseModel):
class WorkflowRunPayload(BaseModel):
inputs: dict[str, Any]
files: list[dict[str, Any]] | None = Field(default=None)
files: list[dict[str, Any]] | None = None
class WorkflowUpdatePayload(BaseModel):
-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 -29
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
+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",
+2 -2
View File
@@ -53,7 +53,7 @@ from .app import (
agent,
agent_app_access,
agent_app_feature,
agent_app_sandbox,
agent_app_workspace,
annotation,
app,
audio,
@@ -153,7 +153,7 @@ __all__ = [
"agent",
"agent_app_access",
"agent_app_feature",
"agent_app_sandbox",
"agent_app_workspace",
"agent_composer",
"agent_providers",
"agent_roster",
+12 -29
View File
@@ -90,12 +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_save_payload(payload)
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
@@ -107,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),
)
@@ -176,7 +167,7 @@ class AgentAppComposerApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@get_app_model()
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
return dump_response(
@@ -190,7 +181,7 @@ class AgentAppComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.AGENT])
@get_app_model()
@with_current_user_id
@with_current_tenant_id
def put(self, tenant_id: str, account_id: str, app_model: App):
@@ -215,13 +206,11 @@ class AgentAppComposerValidateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App):
@get_app_model()
def post(self, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
@@ -232,15 +221,9 @@ class AgentAppComposerCandidatesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, app_model: App):
@get_app_model()
def get(self, app_model: App):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(
tenant_id=tenant_id,
app_id=app_model.id,
user_id=current_user_id,
),
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
)
-2
View File
@@ -18,7 +18,6 @@ from fields.agent_fields import (
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
@@ -49,7 +48,6 @@ register_response_schema_models(
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
@@ -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
+8 -142
View File
@@ -1,24 +1,15 @@
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, field_validator
from flask_restx import Resource, fields
from pydantic import BaseModel, Field, field_validator
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 account_initialization_required, setup_required, 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
from services.agent.skill_package_service import SkillPackageError, SkillPackageService
from services.agent.skill_standardize_service import SkillStandardizeService
from services.agent_drive_service import AgentDriveError
from services.agent_service import AgentService
from services.file_service import FileService
class AgentLogQuery(BaseModel):
@@ -31,53 +22,7 @@ class AgentLogQuery(BaseModel):
return uuid_value(value)
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 AgentSkillUploadResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class AgentSkillStandardizeResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
register_schema_models(console_ns, AgentLogQuery)
register_response_schema_models(console_ns, AgentLogResponse, AgentSkillUploadResponse, AgentSkillStandardizeResponse)
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
@@ -85,8 +30,10 @@ 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
@@ -97,84 +44,3 @@ class AgentLogApi(Resource):
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("/apps/<uuid:app_id>/agent/skills/upload")
class AgentSkillUploadApi(Resource):
@console_ns.doc("upload_agent_skill")
@console_ns.doc(description="Upload + validate a Skill package (.zip/.skill) and extract its manifest")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(201, "Skill validated", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Validate an uploaded Skill package and persist the archive.
Returns a validated skill ref (to bind into the Agent soul config on save)
plus its manifest. Standardizing into the agent drive is ENG-594.
"""
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:
manifest = SkillPackageService().validate_and_extract(content=content, filename=upload.filename or "")
except SkillPackageError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
upload_file = FileService(db.engine).upload_file(
filename=upload.filename or "skill.zip",
content=content,
mimetype=upload.mimetype or "application/zip",
user=current_user,
)
skill_ref = manifest.to_skill_ref(file_id=upload_file.id)
return {"skill": skill_ref.model_dump(exclude_none=True), "manifest": manifest.model_dump()}, 201
@console_ns.route("/apps/<uuid:app_id>/agent/skills/standardize")
class AgentSkillStandardizeApi(Resource):
@console_ns.doc("standardize_agent_skill")
@console_ns.doc(description="Validate + standardize a Skill into the agent drive (ENG-594)")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
201,
"Skill standardized into drive",
console_ns.models[AgentSkillStandardizeResponse.__name__],
)
@console_ns.response(400, "Invalid skill package or no bound agent")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Upload a Skill, validate it, and standardize it into the app agent's drive."""
agent_id = app_model.bound_agent_id
if not agent_id:
return {"code": "no_bound_agent", "message": "app has no bound agent"}, 400
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
@@ -1,306 +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.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 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 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, 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("/apps/<uuid:app_id>/agent-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={"app_id": "Application ID", **query_params_from_model(AgentSandboxListQuery)})
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
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("/apps/<uuid:app_id>/agent-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={"app_id": "Application ID", **query_params_from_model(AgentSandboxFileQuery)})
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
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("/apps/<uuid:app_id>/agent-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
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App):
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)
+16 -69
View File
@@ -6,7 +6,7 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter, field_validator
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 (
account_initialization_required,
@@ -24,7 +24,6 @@ from fields.annotation_fields import (
AnnotationHitHistoryList,
AnnotationList,
)
from fields.base import ResponseModel
from libs.helper import uuid_value
from libs.login import login_required
from services.annotation_service import (
@@ -57,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
@@ -74,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")
@@ -95,25 +86,6 @@ class AnnotationFilePayload(BaseModel):
return uuid_value(value)
class AnnotationJobStatusResponse(ResponseModel):
job_id: str | None = None
job_status: str | None = None
error_msg: str | None = None
record_count: int | None = None
class AnnotationEmbeddingModelResponse(ResponseModel):
embedding_provider_name: str | None = None
embedding_model_name: str | None = None
class AnnotationSettingResponse(ResponseModel):
id: str | None = None
enabled: bool
score_threshold: float | None = None
embedding_model: AnnotationEmbeddingModelResponse | None = None
register_schema_models(
console_ns,
Annotation,
@@ -127,19 +99,8 @@ register_schema_models(
CreateAnnotationPayload,
UpdateAnnotationPayload,
AnnotationReplyStatusQuery,
AnnotationHitHistoryListQuery,
AnnotationFilePayload,
)
register_response_schema_models(
console_ns,
Annotation,
AnnotationList,
AnnotationExportList,
AnnotationHitHistory,
AnnotationHitHistoryList,
AnnotationJobStatusResponse,
AnnotationSettingResponse,
)
@console_ns.route("/apps/<uuid:app_id>/annotation-reply/<string:action>")
@@ -148,7 +109,7 @@ 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
@@ -175,11 +136,7 @@ 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
@@ -196,7 +153,7 @@ 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
@@ -219,11 +176,7 @@ 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[AnnotationJobStatusResponse.__name__],
)
@console_ns.response(200, "Job status retrieved successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@@ -251,8 +204,8 @@ 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
@@ -304,7 +257,6 @@ class AnnotationApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@console_ns.response(204, "Annotations deleted successfully")
def delete(self, app_id: UUID):
# Use request.args.getlist to get annotation_ids array directly
@@ -383,7 +335,6 @@ class AnnotationUpdateDeleteApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@console_ns.response(204, "Annotation deleted successfully")
def delete(self, app_id: UUID, annotation_id: UUID):
AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id))
return "", 204
@@ -394,11 +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[AnnotationJobStatusResponse.__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")
@@ -451,11 +398,7 @@ 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[AnnotationJobStatusResponse.__name__],
)
@console_ns.response(200, "Job status retrieved successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@@ -481,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",
+137 -56
View File
@@ -1,6 +1,7 @@
import logging
import re
import uuid
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Literal, cast
@@ -41,12 +42,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.enums import WorkflowExecutionStatus
from libs.helper import build_icon_url, to_timestamp
from libs.helper import build_icon_url, dump_response, to_timestamp
from libs.login import login_required
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, AppService, CreateAppParams
from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import ImportMode, ImportStatus
from services.entities.knowledge_entities.knowledge_entities import (
@@ -73,10 +74,14 @@ _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"]
class AppListQuery(BaseModel):
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=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",
)
name: str | None = Field(default=None, description="Filter by app name")
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs")
@@ -119,6 +124,14 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in creator_ids.") from exc
class AppListQuery(AppListBaseQuery):
pass
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]] = []
@@ -211,11 +224,6 @@ class AppTracePayload(BaseModel):
return value
class AppTraceResponse(ResponseModel):
enabled: bool
tracing_provider: str | None = None
type JSONValue = Any
@@ -387,6 +395,7 @@ class AppPartial(ResponseModel):
create_user_name: str | None = None
author_name: str | None = None
has_draft_trigger: bool | None = None
is_starred: bool = False
@computed_field(return_type=str | None) # type: ignore
@property
@@ -456,12 +465,54 @@ class AppExportResponse(ResponseModel):
data: 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]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in apps:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [str(app.id) for app in apps if app.mode in {"workflow", "advanced-chat"}]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
Workflow.tenant_id == tenant_id,
)
)
.scalars()
.all()
)
trigger_node_types = TRIGGER_NODE_TYPES
for workflow in draft_workflows:
node_id = None
try:
for node_id, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
continue
for app in apps:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
register_response_schema_models(console_ns, AppTraceResponse, RedirectUrlResponse, SimpleResultResponse)
register_response_schema_models(console_ns, RedirectUrlResponse, SimpleResultResponse)
register_schema_models(
console_ns,
AppListQuery,
StarredAppListQuery,
CreateAppPayload,
UpdateAppPayload,
CopyAppPayload,
@@ -521,6 +572,7 @@ class AppListApi(Resource):
page=args.page,
limit=args.limit,
mode=args.mode,
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
@@ -534,46 +586,7 @@ class AppListApi(Resource):
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in app_pagination.items]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in app_pagination.items:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [
str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
Workflow.tenant_id == current_tenant_id,
)
)
.scalars()
.all()
)
trigger_node_types = TRIGGER_NODE_TYPES
for workflow in draft_workflows:
node_id = None
try:
for node_id, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
continue
for app in app_pagination.items:
app.has_draft_trigger = str(app.id) in draft_trigger_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)
return pagination_model.model_dump(mode="json"), 200
@@ -609,6 +622,78 @@ class AppListApi(Resource):
return app_detail.model_dump(mode="json"), 201
@console_ns.route("/apps/starred")
class StarredAppListApi(Resource):
@console_ns.doc("list_starred_apps")
@console_ns.doc(description="Get applications starred by the current account")
@console_ns.doc(params=query_params_from_model(StarredAppListQuery))
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_session(write=False)
@with_current_user_id
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
args = StarredAppListQuery.model_validate(_normalize_app_list_query_args(request.args))
params = StarredAppListParams(
page=args.page,
limit=args.limit,
mode=args.mode,
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
is_created_by_me=args.is_created_by_me,
)
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)
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")
class AppStarApi(Resource):
@console_ns.doc("star_app")
@console_ns.doc(description="Star an application for the current account")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user_id
@with_session
@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 dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.doc("unstar_app")
@console_ns.doc(description="Remove the current account's star from an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user_id
@with_session
@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 dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.route("/apps/<uuid:app_id>")
class AppApi(Resource):
@console_ns.doc("get_app_detail")
@@ -747,7 +832,7 @@ 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
@@ -822,7 +907,7 @@ 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
@@ -892,11 +977,7 @@ 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
+6 -19
View File
@@ -1,14 +1,12 @@
import logging
from typing import Any
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.fields import AudioBinaryResponse
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,
@@ -53,12 +51,7 @@ class AudioTranscriptResponse(BaseModel):
text: str = Field(description="Transcribed text from audio")
class TextToSpeechVoiceListResponse(RootModel[list[dict[str, Any]]]):
root: list[dict[str, Any]]
register_schema_models(console_ns, AudioTranscriptResponse, TextToSpeechPayload, TextToSpeechVoiceQuery)
register_response_schema_models(console_ns, AudioBinaryResponse, TextToSpeechVoiceListResponse)
@console_ns.route("/apps/<uuid:app_id>/audio-to-text")
@@ -120,11 +113,7 @@ 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__])
@console_ns.response(
200,
"Text to speech conversion successful",
console_ns.models[AudioBinaryResponse.__name__],
)
@console_ns.response(200, "Text to speech conversion successful")
@console_ns.response(400, "Bad request - Invalid parameters")
@get_app_model
@setup_required
@@ -173,11 +162,9 @@ 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"})
@console_ns.doc(params=query_params_from_model(TextToSpeechVoiceQuery))
@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
+6 -12
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
import services
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
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.app.error import (
@@ -64,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")
@@ -94,7 +88,7 @@ class ChatMessagePayload(BaseMessagePayload):
register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)
register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse)
register_response_schema_models(console_ns, SimpleResultResponse)
# define completion message api for user
@@ -104,7 +98,7 @@ class CompletionMessageApi(Resource):
@console_ns.doc(description="Generate completion message for debugging")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[CompletionMessagePayload.__name__])
@console_ns.response(200, "Completion generated successfully", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Completion generated successfully")
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(404, "App not found")
@setup_required
@@ -176,7 +170,7 @@ class ChatMessageApi(Resource):
@console_ns.doc(description="Generate chat message for debugging")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
@console_ns.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Chat message generated successfully")
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(404, "App or conversation not found")
@setup_required
+3 -3
View File
@@ -9,7 +9,7 @@ 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_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 (
@@ -91,7 +91,7 @@ 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"})
@console_ns.doc(params=query_params_from_model(CompletionConversationQuery))
@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
@@ -206,7 +206,7 @@ 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"})
@console_ns.doc(params=query_params_from_model(ChatConversationQuery))
@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
@@ -9,7 +9,7 @@ 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 account_initialization_required, setup_required
@@ -84,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",
+11 -52
View File
@@ -1,12 +1,11 @@
from collections.abc import Sequence
from typing import Any, Literal
from typing import Literal
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
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,
@@ -37,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")
@@ -49,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.
@@ -67,21 +55,13 @@ class WorkflowGeneratePayload(BaseModel):
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 GeneratorResponse(RootModel[Any]):
root: Any
register_enum_models(console_ns, LLMMode)
register_schema_models(
console_ns,
@@ -93,7 +73,6 @@ register_schema_models(
WorkflowGeneratePayload,
ModelConfig,
)
register_response_schema_models(console_ns, GeneratorResponse, SimpleDataResponse)
@console_ns.route("/rule-generate")
@@ -101,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
@@ -134,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
@@ -166,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
@@ -198,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
@@ -293,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
@@ -325,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
@@ -345,22 +320,6 @@ class WorkflowGenerateApi(Resource):
"errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}],
}, 400
# Bound the prompt at the boundary too: an arbitrarily long
# instruction (or pasted document) blows the planner/builder context
# window and fails with an opaque provider error after two slow LLM
# calls. The cap matches the frontend textarea's maxLength.
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
return {
"error": "Instruction is too long",
"errors": [
{
"code": "INSTRUCTION_TOO_LONG",
"detail": f"Instruction and ideal output must each be at most "
f"{_MAX_INSTRUCTION_LENGTH} characters",
}
],
}, 400
try:
result = WorkflowGeneratorService.generate_workflow_graph(
tenant_id=current_tenant_id,
+2 -8
View File
@@ -27,19 +27,13 @@ from models.model import App, AppMCPServer
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")
+6 -10
View File
@@ -10,8 +10,8 @@ from sqlalchemy import exists, func, select
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
from controllers.common.fields import SimpleResultResponse, TextFileResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
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.app.error import (
CompletionRequestError,
@@ -166,7 +166,7 @@ register_schema_models(
MessageDetailResponse,
MessageInfiniteScrollPaginationResponse,
)
register_response_schema_models(console_ns, SimpleResultResponse, TextFileResponse)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
@@ -174,7 +174,7 @@ class ChatMessageListApi(Resource):
@console_ns.doc("list_chat_messages")
@console_ns.doc(description="Get chat messages for a conversation with pagination")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(ChatMessagesQuery))
@console_ns.expect(console_ns.models[ChatMessagesQuery.__name__])
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__])
@console_ns.response(404, "Conversation not found")
@login_required
@@ -372,12 +372,8 @@ class MessageFeedbackExportApi(Resource):
@console_ns.doc("export_feedbacks")
@console_ns.doc(description="Export user feedback data for Google Sheets")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(FeedbackExportQuery))
@console_ns.response(
200,
"Feedback data exported successfully",
console_ns.models[TextFileResponse.__name__],
)
@console_ns.expect(console_ns.models[FeedbackExportQuery.__name__])
@console_ns.response(200, "Feedback data exported successfully")
@console_ns.response(400, "Invalid parameters")
@console_ns.response(500, "Internal server error")
@get_app_model
+10 -40
View File
@@ -5,8 +5,7 @@ from flask import request
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.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 (
@@ -30,44 +29,19 @@ from services.app_model_config_service import AppModelConfigService
class ModelConfigRequest(BaseModel):
provider: str | None = Field(default=None, description="Model provider")
model: str | None = Field(default=None, description="Model name")
configs: dict[str, Any] | None = Field(
default=None,
description="Model configuration parameters",
)
configs: dict[str, Any] | None = Field(default=None, description="Model configuration parameters")
opening_statement: str | None = Field(default=None, description="Opening statement")
suggested_questions: list[str] | None = Field(default=None, description="Suggested questions")
more_like_this: dict[str, Any] | None = Field(
default=None,
description="More like this configuration",
)
speech_to_text: dict[str, Any] | None = Field(
default=None,
description="Speech to text configuration",
)
text_to_speech: dict[str, Any] | None = Field(
default=None,
description="Text to speech configuration",
)
retrieval_model: dict[str, Any] | None = Field(
default=None,
description="Retrieval model configuration",
)
tools: list[dict[str, Any]] | None = Field(
default=None,
description="Available tools",
)
dataset_configs: dict[str, Any] | None = Field(
default=None,
description="Dataset configurations",
)
agent_mode: dict[str, Any] | None = Field(
default=None,
description="Agent mode configuration",
)
more_like_this: dict[str, Any] | None = Field(default=None, description="More like this configuration")
speech_to_text: dict[str, Any] | None = Field(default=None, description="Speech to text configuration")
text_to_speech: dict[str, Any] | None = Field(default=None, description="Text to speech configuration")
retrieval_model: dict[str, Any] | None = Field(default=None, description="Retrieval model configuration")
tools: list[dict[str, Any]] | None = Field(default=None, description="Available tools")
dataset_configs: dict[str, Any] | None = Field(default=None, description="Dataset configurations")
agent_mode: dict[str, Any] | None = Field(default=None, description="Agent mode configuration")
register_schema_models(console_ns, ModelConfigRequest)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/apps/<uuid:app_id>/model-config")
@@ -76,11 +50,7 @@ class ModelConfigResource(Resource):
@console_ns.doc(description="Update application model configuration")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[ModelConfigRequest.__name__])
@console_ns.response(
200,
"Model configuration updated successfully",
console_ns.models[SimpleResultResponse.__name__],
)
@console_ns.response(200, "Model configuration updated successfully")
@console_ns.response(400, "Invalid configuration")
@console_ns.response(404, "App not found")
@setup_required
+8 -34
View File
@@ -1,16 +1,15 @@
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 werkzeug.exceptions import BadRequest
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 TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist
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 login_required
from models import App
from services.ops_service import OpsService
@@ -22,27 +21,10 @@ class TraceProviderQuery(BaseModel):
class TraceConfigPayload(BaseModel):
tracing_provider: str = Field(..., description="Tracing provider name")
tracing_config: dict[str, Any] = Field(
...,
description="Tracing configuration data",
)
class TraceAppConfigResponse(ResponseModel):
result: str | None = None
error: str | None = None
has_not_configured: bool | None = None
id: str | None = None
app_id: str | None = None
tracing_provider: str | None = None
tracing_config: dict[str, Any] | None = Field(default=None)
is_active: bool | None = None
created_at: str | None = None
updated_at: str | None = None
tracing_config: dict[str, Any] = Field(..., description="Tracing configuration data")
register_schema_models(console_ns, TraceProviderQuery, TraceConfigPayload)
register_response_schema_models(console_ns, TraceAppConfigResponse)
@console_ns.route("/apps/<uuid:app_id>/trace-config")
@@ -54,11 +36,9 @@ class TraceAppConfigApi(Resource):
@console_ns.doc("get_trace_app_config")
@console_ns.doc(description="Get tracing configuration for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(TraceProviderQuery))
@console_ns.expect(console_ns.models[TraceProviderQuery.__name__])
@console_ns.response(
200,
"Tracing configuration retrieved successfully",
console_ns.models[TraceAppConfigResponse.__name__],
200, "Tracing configuration retrieved successfully", fields.Raw(description="Tracing configuration data")
)
@console_ns.response(400, "Invalid request parameters")
@setup_required
@@ -83,9 +63,7 @@ class TraceAppConfigApi(Resource):
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[TraceConfigPayload.__name__])
@console_ns.response(
201,
"Tracing configuration created successfully",
console_ns.models[TraceAppConfigResponse.__name__],
201, "Tracing configuration created successfully", fields.Raw(description="Created configuration data")
)
@console_ns.response(400, "Invalid request parameters or configuration already exists")
@setup_required
@@ -112,11 +90,7 @@ class TraceAppConfigApi(Resource):
@console_ns.doc(description="Update an existing tracing configuration for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[TraceConfigPayload.__name__])
@console_ns.response(
200,
"Tracing configuration updated successfully",
console_ns.models[TraceAppConfigResponse.__name__],
)
@console_ns.response(200, "Tracing configuration updated successfully", fields.Raw(description="Success response"))
@console_ns.response(400, "Invalid request parameters or configuration not found")
@setup_required
@login_required
@@ -139,7 +113,7 @@ class TraceAppConfigApi(Resource):
@console_ns.doc("delete_trace_app_config")
@console_ns.doc(description="Delete an existing tracing configuration for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(TraceProviderQuery))
@console_ns.expect(console_ns.models[TraceProviderQuery.__name__])
@console_ns.response(204, "Tracing configuration deleted successfully")
@console_ns.response(400, "Invalid request parameters or configuration not found")
@setup_required
+18 -104
View File
@@ -2,16 +2,15 @@ from decimal import Decimal
import sqlalchemy as sa
from flask import abort, jsonify, request
from flask_restx import Resource
from flask_restx import Resource, fields
from pydantic import BaseModel, Field, field_validator
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 account_initialization_required, setup_required, with_current_user
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.datetime_utils import parse_time_range
from libs.helper import convert_datetime_to_date
from libs.login import login_required
@@ -32,92 +31,7 @@ class StatisticTimeRangeQuery(BaseModel):
return value
class DailyMessageStatisticItem(ResponseModel):
date: str
message_count: int
class DailyMessageStatisticResponse(ResponseModel):
data: list[DailyMessageStatisticItem]
class DailyConversationStatisticItem(ResponseModel):
date: str
conversation_count: int
class DailyConversationStatisticResponse(ResponseModel):
data: list[DailyConversationStatisticItem]
class DailyTerminalStatisticItem(ResponseModel):
date: str
terminal_count: int
class DailyTerminalStatisticResponse(ResponseModel):
data: list[DailyTerminalStatisticItem]
class DailyTokenCostStatisticItem(ResponseModel):
date: str
token_count: int
total_price: str | float
currency: str
class DailyTokenCostStatisticResponse(ResponseModel):
data: list[DailyTokenCostStatisticItem]
class AverageSessionInteractionStatisticItem(ResponseModel):
date: str
interactions: float
class AverageSessionInteractionStatisticResponse(ResponseModel):
data: list[AverageSessionInteractionStatisticItem]
class UserSatisfactionRateStatisticItem(ResponseModel):
date: str
rate: float
class UserSatisfactionRateStatisticResponse(ResponseModel):
data: list[UserSatisfactionRateStatisticItem]
class AverageResponseTimeStatisticItem(ResponseModel):
date: str
latency: float
class AverageResponseTimeStatisticResponse(ResponseModel):
data: list[AverageResponseTimeStatisticItem]
class TokensPerSecondStatisticItem(ResponseModel):
date: str
tps: float
class TokensPerSecondStatisticResponse(ResponseModel):
data: list[TokensPerSecondStatisticItem]
register_schema_models(console_ns, StatisticTimeRangeQuery)
register_response_schema_models(
console_ns,
DailyMessageStatisticResponse,
DailyConversationStatisticResponse,
DailyTerminalStatisticResponse,
DailyTokenCostStatisticResponse,
AverageSessionInteractionStatisticResponse,
UserSatisfactionRateStatisticResponse,
AverageResponseTimeStatisticResponse,
TokensPerSecondStatisticResponse,
)
@console_ns.route("/apps/<uuid:app_id>/statistics/daily-messages")
@@ -125,11 +39,11 @@ class DailyMessageStatistic(Resource):
@console_ns.doc("get_daily_message_statistics")
@console_ns.doc(description="Get daily message statistics for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
@console_ns.response(
200,
"Daily message statistics retrieved successfully",
console_ns.models[DailyMessageStatisticResponse.__name__],
fields.List(fields.Raw(description="Daily message count data")),
)
@get_app_model
@setup_required
@@ -185,11 +99,11 @@ class DailyConversationStatistic(Resource):
@console_ns.doc("get_daily_conversation_statistics")
@console_ns.doc(description="Get daily conversation statistics for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
@console_ns.response(
200,
"Daily conversation statistics retrieved successfully",
console_ns.models[DailyConversationStatisticResponse.__name__],
fields.List(fields.Raw(description="Daily conversation count data")),
)
@get_app_model
@setup_required
@@ -244,11 +158,11 @@ class DailyTerminalsStatistic(Resource):
@console_ns.doc("get_daily_terminals_statistics")
@console_ns.doc(description="Get daily terminal/end-user statistics for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
@console_ns.response(
200,
"Daily terminal statistics retrieved successfully",
console_ns.models[DailyTerminalStatisticResponse.__name__],
fields.List(fields.Raw(description="Daily terminal count data")),
)
@get_app_model
@setup_required
@@ -304,11 +218,11 @@ class DailyTokenCostStatistic(Resource):
@console_ns.doc("get_daily_token_cost_statistics")
@console_ns.doc(description="Get daily token cost statistics for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
@console_ns.response(
200,
"Daily token cost statistics retrieved successfully",
console_ns.models[DailyTokenCostStatisticResponse.__name__],
fields.List(fields.Raw(description="Daily token cost data")),
)
@get_app_model
@setup_required
@@ -367,11 +281,11 @@ class AverageSessionInteractionStatistic(Resource):
@console_ns.doc("get_average_session_interaction_statistics")
@console_ns.doc(description="Get average session interaction statistics for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
@console_ns.response(
200,
"Average session interaction statistics retrieved successfully",
console_ns.models[AverageSessionInteractionStatisticResponse.__name__],
fields.List(fields.Raw(description="Average session interaction data")),
)
@setup_required
@login_required
@@ -446,11 +360,11 @@ class UserSatisfactionRateStatistic(Resource):
@console_ns.doc("get_user_satisfaction_rate_statistics")
@console_ns.doc(description="Get user satisfaction rate statistics for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
@console_ns.response(
200,
"User satisfaction rate statistics retrieved successfully",
console_ns.models[UserSatisfactionRateStatisticResponse.__name__],
fields.List(fields.Raw(description="User satisfaction rate data")),
)
@get_app_model
@setup_required
@@ -515,11 +429,11 @@ class AverageResponseTimeStatistic(Resource):
@console_ns.doc("get_average_response_time_statistics")
@console_ns.doc(description="Get average response time statistics for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
@console_ns.response(
200,
"Average response time statistics retrieved successfully",
console_ns.models[AverageResponseTimeStatisticResponse.__name__],
fields.List(fields.Raw(description="Average response time data")),
)
@setup_required
@login_required
@@ -575,11 +489,11 @@ class TokensPerSecondStatistic(Resource):
@console_ns.doc("get_tokens_per_second_statistics")
@console_ns.doc(description="Get tokens per second statistics for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
@console_ns.response(
200,
"Tokens per second statistics retrieved successfully",
console_ns.models[TokensPerSecondStatisticResponse.__name__],
fields.List(fields.Raw(description="Tokens per second data")),
)
@get_app_model
@setup_required
+81 -203
View File
@@ -6,34 +6,22 @@ from typing import Any, NotRequired, TypedDict
from flask import abort, request
from flask_restx import Resource, fields
from pydantic import AliasChoices, BaseModel, Field, RootModel, ValidationError, field_validator
from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.errors import InvalidArgumentError
from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse
from controllers.common.fields import NewAppResponse, SimpleResultResponse
from controllers.common.schema import (
query_params_from_model,
register_response_schema_model,
register_response_schema_models,
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.app.error import (
ConversationCompletedError,
DraftWorkflowNotExist,
DraftWorkflowNotSync,
)
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.app.apps.base_app_queue_manager import AppQueueManager
@@ -62,12 +50,11 @@ from graphon.file import helpers as file_helpers
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.variables import SecretVariable, SegmentType, VariableBase
from graphon.variables.exc import VariableError
from libs import helper
from libs.datetime_utils import naive_utc_now
from libs.helper import TimestampField, dump_response, to_timestamp, uuid_value
from libs.login import login_required
from models import Account, App
from libs.login import current_account_with_tenant, login_required
from models import App
from models.model import AppMode
from models.workflow import Workflow
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
@@ -99,20 +86,16 @@ class SyncDraftWorkflowPayload(BaseModel):
graph: dict[str, Any]
features: dict[str, Any]
hash: str | None = None
environment_variables: list[dict[str, Any]] = Field(
default_factory=list,
)
conversation_variables: list[dict[str, Any]] = Field(
default_factory=list,
)
environment_variables: list[dict[str, Any]] = Field(default_factory=list)
conversation_variables: list[dict[str, Any]] = Field(default_factory=list)
class BaseWorkflowRunPayload(BaseModel):
files: list[dict[str, Any]] | None = Field(default=None)
files: list[dict[str, Any]] | None = None
class AdvancedChatWorkflowRunPayload(BaseWorkflowRunPayload):
inputs: dict[str, Any] | None = Field(default=None)
inputs: dict[str, Any] | None = None
query: str = ""
conversation_id: str | None = None
parent_message_id: str | None = None
@@ -126,11 +109,11 @@ class AdvancedChatWorkflowRunPayload(BaseWorkflowRunPayload):
class IterationNodeRunPayload(BaseModel):
inputs: dict[str, Any] | None = Field(default=None)
inputs: dict[str, Any] | None = None
class LoopNodeRunPayload(BaseModel):
inputs: dict[str, Any] | None = Field(default=None)
inputs: dict[str, Any] | None = None
class DraftWorkflowRunPayload(BaseWorkflowRunPayload):
@@ -155,10 +138,7 @@ class ConvertToWorkflowPayload(BaseModel):
class WorkflowFeaturesPayload(BaseModel):
features: dict[str, Any] = Field(
...,
description="Workflow feature configuration",
)
features: dict[str, Any] = Field(..., description="Workflow feature configuration")
class WorkflowOnlineUsersPayload(BaseModel):
@@ -174,7 +154,7 @@ class WorkflowConversationVariableResponse(ResponseModel):
id: str
name: str
value_type: str
value: Any
value: Any = Field(json_schema_extra={"type": "object"})
description: str
@field_validator("value_type", mode="before")
@@ -193,7 +173,7 @@ class PipelineVariableResponse(ResponseModel):
max_length: int | None = None
required: bool
unit: str | None = None
default_value: Any = Field(default=None)
default_value: Any = Field(default=None, json_schema_extra={"type": "object"})
options: list[str] | None = None
placeholder: str | None = None
tooltips: str | None = None
@@ -210,18 +190,14 @@ class WorkflowEnvironmentVariableResponse(ResponseModel):
value_type: str
id: str
name: str
value: Any
value: Any = Field(json_schema_extra={"type": "object"})
description: str
class WorkflowResponse(ResponseModel):
id: str
graph: dict[str, Any] = Field(
validation_alias=AliasChoices("graph_dict", "graph"),
)
features: dict[str, Any] = Field(
validation_alias=AliasChoices("features_dict", "features"),
)
graph: dict[str, Any] = Field(validation_alias=AliasChoices("graph_dict", "graph"))
features: dict[str, Any] = Field(validation_alias=AliasChoices("features_dict", "features"))
hash: str = Field(validation_alias=AliasChoices("unique_hash", "hash"))
version: str
marked_name: str
@@ -278,46 +254,6 @@ class WorkflowOnlineUsersResponse(ResponseModel):
data: list[WorkflowOnlineUsersByApp]
class WorkflowPublishResponse(ResponseModel):
result: str
created_at: int
class WorkflowRestoreResponse(ResponseModel):
result: str
hash: str
updated_at: int
class DefaultBlockConfigsResponse(RootModel[list[dict[str, Any]]]):
root: list[dict[str, Any]]
class DefaultBlockConfigResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class HumanInputFormPreviewResponse(ResponseModel):
form_id: str
node_id: str
node_title: str
form_content: str
inputs: list[dict[str, Any]] = Field(default_factory=list)
actions: list[dict[str, Any]] = Field(default_factory=list)
display_in_ui: bool | None = None
form_token: str | None = None
resolved_default_values: dict[str, Any] = Field(default_factory=dict)
expiration_time: int | None = None
class HumanInputFormSubmitResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class EmptyObjectResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class DraftWorkflowTriggerRunPayload(BaseModel):
node_id: str
@@ -355,14 +291,6 @@ register_response_schema_models(
WorkflowOnlineUser,
WorkflowOnlineUsersByApp,
WorkflowOnlineUsersResponse,
WorkflowPublishResponse,
WorkflowRestoreResponse,
DefaultBlockConfigsResponse,
DefaultBlockConfigResponse,
HumanInputFormPreviewResponse,
HumanInputFormSubmitResponse,
EmptyObjectResponse,
GeneratedAppResponse,
NewAppResponse,
SimpleResultResponse,
)
@@ -473,12 +401,13 @@ class DraftWorkflowApi(Resource):
)
@console_ns.response(400, "Invalid workflow configuration")
@console_ns.response(403, "Permission denied")
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App):
def post(self, app_model: App):
"""
Sync draft workflow
"""
current_user, _ = current_account_with_tenant()
content_type = request.headers.get("Content-Type", "")
if "application/json" in content_type:
@@ -518,8 +447,6 @@ class DraftWorkflowApi(Resource):
)
except WorkflowHashNotEqualError:
raise DraftWorkflowNotSync()
except VariableError as e:
raise InvalidArgumentError(description=str(e))
return {
"result": "success",
@@ -534,19 +461,20 @@ class AdvancedChatDraftWorkflowRunApi(Resource):
@console_ns.doc(description="Run draft workflow for advanced chat application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[AdvancedChatWorkflowRunPayload.__name__])
@console_ns.response(200, "Workflow run started successfully", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Workflow run started successfully")
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(403, "Permission denied")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App):
def post(self, app_model: App):
"""
Run draft workflow
"""
current_user, _ = current_account_with_tenant()
args_model = AdvancedChatWorkflowRunPayload.model_validate(console_ns.payload or {})
args = args_model.model_dump(exclude_none=True)
@@ -579,23 +507,19 @@ class AdvancedChatDraftRunIterationNodeApi(Resource):
@console_ns.doc(description="Run draft workflow iteration node for advanced chat")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models[IterationNodeRunPayload.__name__])
@console_ns.response(
200,
"Iteration node run started successfully",
console_ns.models[GeneratedAppResponse.__name__],
)
@console_ns.response(200, "Iteration node run started successfully")
@console_ns.response(403, "Permission denied")
@console_ns.response(404, "Node not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Run draft workflow iteration node
"""
current_user, _ = current_account_with_tenant()
args = IterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
try:
@@ -621,23 +545,19 @@ class WorkflowDraftRunIterationNodeApi(Resource):
@console_ns.doc(description="Run draft workflow iteration node")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models[IterationNodeRunPayload.__name__])
@console_ns.response(
200,
"Workflow iteration node run started successfully",
console_ns.models[GeneratedAppResponse.__name__],
)
@console_ns.response(200, "Workflow iteration node run started successfully")
@console_ns.response(403, "Permission denied")
@console_ns.response(404, "Node not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Run draft workflow iteration node
"""
current_user, _ = current_account_with_tenant()
args = IterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
try:
@@ -663,19 +583,19 @@ class AdvancedChatDraftRunLoopNodeApi(Resource):
@console_ns.doc(description="Run draft workflow loop node for advanced chat")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models[LoopNodeRunPayload.__name__])
@console_ns.response(200, "Loop node run started successfully", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Loop node run started successfully")
@console_ns.response(403, "Permission denied")
@console_ns.response(404, "Node not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Run draft workflow loop node
"""
current_user, _ = current_account_with_tenant()
args = LoopNodeRunPayload.model_validate(console_ns.payload or {})
try:
@@ -701,23 +621,19 @@ class WorkflowDraftRunLoopNodeApi(Resource):
@console_ns.doc(description="Run draft workflow loop node")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models[LoopNodeRunPayload.__name__])
@console_ns.response(
200,
"Workflow loop node run started successfully",
console_ns.models[GeneratedAppResponse.__name__],
)
@console_ns.response(200, "Workflow loop node run started successfully")
@console_ns.response(403, "Permission denied")
@console_ns.response(404, "Node not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Run draft workflow loop node
"""
current_user, _ = current_account_with_tenant()
args = LoopNodeRunPayload.model_validate(console_ns.payload or {})
try:
@@ -745,10 +661,7 @@ class HumanInputFormPreviewPayload(BaseModel):
class HumanInputFormSubmitPayload(BaseModel):
form_inputs: dict[str, Any] = Field(
...,
description="Values the user provides for the form's own fields",
)
form_inputs: dict[str, Any] = Field(..., description="Values the user provides for the form's own fields")
inputs: dict[str, Any] = Field(
...,
description="Values used to fill missing upstream variables referenced in form_content",
@@ -778,17 +691,16 @@ class AdvancedChatDraftHumanInputFormPreviewApi(Resource):
@console_ns.doc(description="Get human input form preview for advanced chat workflow")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models[HumanInputFormPreviewPayload.__name__])
@console_ns.response(200, "Human input form preview", console_ns.models[HumanInputFormPreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Preview human input form content and placeholders
"""
current_user, _ = current_account_with_tenant()
args = HumanInputFormPreviewPayload.model_validate(console_ns.payload or {})
inputs = args.inputs
@@ -808,21 +720,16 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource):
@console_ns.doc(description="Submit human input form preview for advanced chat workflow")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__])
@console_ns.response(
200,
"Human input form submission result",
console_ns.models[HumanInputFormSubmitResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Submit human input form preview
"""
current_user, _ = current_account_with_tenant()
args = HumanInputFormSubmitPayload.model_validate(console_ns.payload or {})
workflow_service = WorkflowService()
result = workflow_service.submit_human_input_form_preview(
@@ -842,17 +749,16 @@ class WorkflowDraftHumanInputFormPreviewApi(Resource):
@console_ns.doc(description="Get human input form preview for workflow")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models[HumanInputFormPreviewPayload.__name__])
@console_ns.response(200, "Human input form preview", console_ns.models[HumanInputFormPreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Preview human input form content and placeholders
"""
current_user, _ = current_account_with_tenant()
args = HumanInputFormPreviewPayload.model_validate(console_ns.payload or {})
inputs = args.inputs
@@ -872,21 +778,16 @@ class WorkflowDraftHumanInputFormRunApi(Resource):
@console_ns.doc(description="Submit human input form preview for workflow")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__])
@console_ns.response(
200,
"Human input form submission result",
console_ns.models[HumanInputFormSubmitResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Submit human input form preview
"""
current_user, _ = current_account_with_tenant()
workflow_service = WorkflowService()
args = HumanInputFormSubmitPayload.model_validate(console_ns.payload or {})
result = workflow_service.submit_human_input_form_preview(
@@ -906,17 +807,16 @@ class WorkflowDraftHumanInputDeliveryTestApi(Resource):
@console_ns.doc(description="Test human input delivery for workflow")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models[HumanInputDeliveryTestPayload.__name__])
@console_ns.response(200, "Human input delivery test result", console_ns.models[EmptyObjectResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Test human input delivery
"""
current_user, _ = current_account_with_tenant()
workflow_service = WorkflowService()
args = HumanInputDeliveryTestPayload.model_validate(console_ns.payload or {})
workflow_service.test_human_input_delivery(
@@ -935,22 +835,18 @@ class DraftWorkflowRunApi(Resource):
@console_ns.doc(description="Run draft workflow")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[DraftWorkflowRunPayload.__name__])
@console_ns.response(
200,
"Draft workflow run started successfully",
console_ns.models[GeneratedAppResponse.__name__],
)
@console_ns.response(200, "Draft workflow run started successfully")
@console_ns.response(403, "Permission denied")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App):
def post(self, app_model: App):
"""
Run draft workflow
"""
current_user, _ = current_account_with_tenant()
args = DraftWorkflowRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
external_trace_id = get_external_trace_id(request)
@@ -1015,12 +911,12 @@ class DraftWorkflowNodeRunApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Run draft workflow node
"""
current_user, _ = current_account_with_tenant()
args_model = DraftWorkflowNodeRunPayload.model_validate(console_ns.payload or {})
args = args_model.model_dump(exclude_none=True)
@@ -1081,17 +977,16 @@ class PublishedWorkflowApi(Resource):
return dump_response(WorkflowResponse, workflow)
@console_ns.expect(console_ns.models[PublishWorkflowPayload.__name__])
@console_ns.response(200, "Workflow published successfully", console_ns.models[WorkflowPublishResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App):
def post(self, app_model: App):
"""
Publish workflow
"""
current_user, _ = current_account_with_tenant()
args = PublishWorkflowPayload.model_validate(console_ns.payload or {})
@@ -1125,11 +1020,7 @@ class DefaultBlockConfigsApi(Resource):
@console_ns.doc("get_default_block_configs")
@console_ns.doc(description="Get default block configurations for workflow")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200,
"Default block configurations retrieved successfully",
console_ns.models[DefaultBlockConfigsResponse.__name__],
)
@console_ns.response(200, "Default block configurations retrieved successfully")
@setup_required
@login_required
@account_initialization_required
@@ -1149,13 +1040,9 @@ class DefaultBlockConfigApi(Resource):
@console_ns.doc("get_default_block_config")
@console_ns.doc(description="Get default block configuration by type")
@console_ns.doc(params={"app_id": "Application ID", "block_type": "Block type"})
@console_ns.response(
200,
"Default block configuration retrieved successfully",
console_ns.models[DefaultBlockConfigResponse.__name__],
)
@console_ns.response(200, "Default block configuration retrieved successfully")
@console_ns.response(404, "Block type not found")
@console_ns.doc(params=query_params_from_model(DefaultBlockConfigQuery))
@console_ns.expect(console_ns.models[DefaultBlockConfigQuery.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -1196,14 +1083,14 @@ class ConvertToWorkflowApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.COMPLETION])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App):
def post(self, app_model: App):
"""
Convert basic mode of chatbot app to workflow mode
Convert expert mode of chatbot app to workflow mode
Convert Completion App to Workflow App
"""
current_user, _ = current_account_with_tenant()
payload = console_ns.payload or {}
args = ConvertToWorkflowPayload.model_validate(payload).model_dump(exclude_none=True)
@@ -1235,9 +1122,9 @@ class WorkflowFeaturesApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App):
def post(self, app_model: App):
current_user, _ = current_account_with_tenant()
args = WorkflowFeaturesPayload.model_validate(console_ns.payload or {})
features = args.features
@@ -1250,7 +1137,7 @@ class WorkflowFeaturesApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/workflows")
class PublishedAllWorkflowApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkflowListQuery))
@console_ns.expect(console_ns.models[WorkflowListQuery.__name__])
@console_ns.doc("get_all_published_workflows")
@console_ns.doc(description="Get all published workflows for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@@ -1263,12 +1150,12 @@ class PublishedAllWorkflowApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def get(self, current_user: Account, app_model: App):
def get(self, app_model: App):
"""
Get published workflows
"""
current_user, _ = current_account_with_tenant()
args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True))
page = args.page
@@ -1305,16 +1192,16 @@ class DraftWorkflowRestoreApi(Resource):
@console_ns.doc("restore_workflow_to_draft")
@console_ns.doc(description="Restore a published workflow version into the draft workflow")
@console_ns.doc(params={"app_id": "Application ID", "workflow_id": "Published workflow ID"})
@console_ns.response(200, "Workflow restored successfully", console_ns.models[WorkflowRestoreResponse.__name__])
@console_ns.response(200, "Workflow restored successfully")
@console_ns.response(400, "Source workflow must be published")
@console_ns.response(404, "Workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, workflow_id: str):
def post(self, app_model: App, workflow_id: str):
current_user, _ = current_account_with_tenant()
workflow_service = WorkflowService()
try:
@@ -1350,12 +1237,12 @@ class WorkflowByIdApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def patch(self, current_user: Account, app_model: App, workflow_id: str):
def patch(self, app_model: App, workflow_id: str):
"""
Update workflow attributes
"""
current_user, _ = current_account_with_tenant()
args = WorkflowUpdatePayload.model_validate(console_ns.payload or {})
# Prepare update data
@@ -1390,7 +1277,6 @@ class WorkflowByIdApi(Resource):
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@edit_permission_required
@console_ns.response(204, "Workflow deleted successfully")
def delete(self, app_model: App, workflow_id: str):
"""
Delete workflow
@@ -1462,23 +1348,19 @@ class DraftWorkflowTriggerRunApi(Resource):
},
)
)
@console_ns.response(
200,
"Trigger event received and workflow executed successfully",
console_ns.models[GeneratedAppResponse.__name__],
)
@console_ns.response(200, "Trigger event received and workflow executed successfully")
@console_ns.response(403, "Permission denied")
@console_ns.response(500, "Internal server error")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App):
def post(self, app_model: App):
"""
Poll for trigger events and execute full workflow when event arrives
"""
current_user, _ = current_account_with_tenant()
args = DraftWorkflowTriggerRunPayload.model_validate(console_ns.payload or {})
node_id = args.node_id
workflow_service = WorkflowService()
@@ -1530,23 +1412,19 @@ class DraftWorkflowTriggerNodeApi(Resource):
@console_ns.doc("poll_draft_workflow_trigger_node")
@console_ns.doc(description="Poll for trigger events and execute single node when event arrives")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.response(
200,
"Trigger event received and node executed successfully",
console_ns.models[GeneratedAppResponse.__name__],
)
@console_ns.response(200, "Trigger event received and node executed successfully")
@console_ns.response(403, "Permission denied")
@console_ns.response(500, "Internal server error")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
"""
Poll for trigger events and execute single node when event arrives
"""
current_user, _ = current_account_with_tenant()
workflow_service = WorkflowService()
draft_workflow = workflow_service.get_draft_workflow(app_model)
@@ -1614,19 +1492,19 @@ class DraftWorkflowTriggerRunAllApi(Resource):
@console_ns.doc(description="Full workflow debug when the start node is a trigger")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[DraftWorkflowTriggerRunAllPayload.__name__])
@console_ns.response(200, "Workflow executed successfully", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Workflow executed successfully")
@console_ns.response(403, "Permission denied")
@console_ns.response(500, "Internal server error")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model: App):
def post(self, app_model: App):
"""
Full workflow debug when the start node is a trigger
"""
current_user, _ = current_account_with_tenant()
args = DraftWorkflowTriggerRunAllPayload.model_validate(console_ns.payload or {})
node_ids = args.node_ids
@@ -1687,8 +1565,7 @@ class WorkflowOnlineUsersApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str):
def post(self):
args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {})
app_ids = args.app_ids
@@ -1698,6 +1575,7 @@ class WorkflowOnlineUsersApi(Resource):
if not app_ids:
return {"data": []}
_, current_tenant_id = current_account_with_tenant()
workflow_service = WorkflowService()
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id)
ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids]
@@ -7,7 +7,7 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
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 account_initialization_required, setup_required
@@ -166,7 +166,7 @@ class WorkflowAppLogApi(Resource):
@console_ns.doc("get_workflow_app_logs")
@console_ns.doc(description="Get workflow application execution logs")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(WorkflowAppLogQuery))
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
@console_ns.response(
200,
"Workflow app logs retrieved successfully",
@@ -209,7 +209,7 @@ class WorkflowArchivedLogApi(Resource):
@console_ns.doc("get_workflow_archived_logs")
@console_ns.doc(description="Get workflow archived execution logs")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(WorkflowAppLogQuery))
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
@console_ns.response(
200,
"Workflow archived logs retrieved successfully",
@@ -1,7 +1,7 @@
import logging
from collections.abc import Callable
from functools import wraps
from typing import Any, Concatenate, TypedDict, override
from typing import Any, Concatenate, TypedDict
from uuid import UUID
from flask import Response, request
@@ -9,9 +9,7 @@ from flask_restx import Resource, fields, marshal, marshal_with
from pydantic import BaseModel, Field
from sqlalchemy.orm import sessionmaker
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
DraftWorkflowNotExist,
@@ -23,13 +21,13 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from controllers.web.error import InvalidArgumentError, NotFoundError
from core.app.file_access import DatabaseFileAccessController
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db
from factories import variable_factory
from factories.file_factory import build_from_mapping, build_from_mappings
from factories.variable_factory import build_segment_with_type
from fields.base import ResponseModel
from graphon.file import helpers as file_helpers
from graphon.variables.segment_group import SegmentGroup
from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment
@@ -44,28 +42,6 @@ logger = logging.getLogger(__name__)
_file_access_controller = DatabaseFileAccessController()
class OpaqueRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "object"}
class JsonValueRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {
"anyOf": [
{"type": "string"},
{"type": "integer"},
{"type": "number"},
{"type": "boolean"},
{"type": "object", "additionalProperties": True},
{"type": "array", "items": {}},
{"type": "null"},
]
}
class WorkflowDraftVariableListQuery(BaseModel):
page: int = Field(default=1, ge=1, le=100_000, description="Page number")
limit: int = Field(default=20, ge=1, le=100, description="Items per page")
@@ -78,33 +54,12 @@ class WorkflowDraftVariableUpdatePayload(BaseModel):
class ConversationVariableUpdatePayload(BaseModel):
conversation_variables: list[dict[str, Any]] = Field(
...,
description="Conversation variables for the draft workflow",
..., description="Conversation variables for the draft workflow"
)
class EnvironmentVariableUpdatePayload(BaseModel):
environment_variables: list[dict[str, Any]] = Field(
...,
description="Environment variables for the draft workflow",
)
class EnvironmentVariableItemResponse(ResponseModel):
id: str
type: str
name: str
description: str | None = None
selector: list[str]
value_type: str
value: Any
edited: bool
visible: bool
editable: bool
class EnvironmentVariableListResponse(ResponseModel):
items: list[EnvironmentVariableItemResponse]
environment_variables: list[dict[str, Any]] = Field(..., description="Environment variables for the draft workflow")
register_schema_models(
@@ -114,7 +69,6 @@ register_schema_models(
ConversationVariableUpdatePayload,
EnvironmentVariableUpdatePayload,
)
register_response_schema_models(console_ns, SimpleResultResponse, EnvironmentVariableListResponse)
def _convert_values_to_json_serializable_object(value: Segment):
@@ -201,8 +155,8 @@ _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS = {
_WORKFLOW_DRAFT_VARIABLE_FIELDS = {
**_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS,
"value": JsonValueRawField(attribute=_serialize_var_value),
"full_content": OpaqueRawField(attribute=_serialize_full_content),
"value": fields.Raw(attribute=_serialize_var_value),
"full_content": fields.Raw(attribute=_serialize_full_content),
}
_WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS = {
@@ -227,7 +181,7 @@ def _get_items(var_list: WorkflowDraftVariableList) -> list[WorkflowDraftVariabl
_WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS = {
"items": fields.List(fields.Nested(_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS), attribute=_get_items),
"total": fields.Integer,
"total": fields.Raw(),
}
_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS = {
@@ -294,7 +248,7 @@ def _api_prerequisite[T, **P, R](
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/variables")
class WorkflowVariableCollectionApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkflowDraftVariableListQuery))
@console_ns.expect(console_ns.models[WorkflowDraftVariableListQuery.__name__])
@console_ns.doc("get_workflow_variables")
@console_ns.doc(description="Get draft workflow variables")
@console_ns.doc(params={"app_id": "Application ID"})
@@ -590,11 +544,7 @@ class ConversationVariableCollectionApi(Resource):
@console_ns.doc("update_conversation_variables")
@console_ns.doc(description="Update conversation variables for workflow draft")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200,
"Conversation variables updated successfully",
console_ns.models[SimpleResultResponse.__name__],
)
@console_ns.response(200, "Conversation variables updated successfully")
@setup_required
@login_required
@account_initialization_required
@@ -637,11 +587,7 @@ class EnvironmentVariableCollectionApi(Resource):
@console_ns.doc("get_environment_variables")
@console_ns.doc(description="Get environment variables for workflow")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200,
"Environment variables retrieved successfully",
console_ns.models[EnvironmentVariableListResponse.__name__],
)
@console_ns.response(200, "Environment variables retrieved successfully")
@console_ns.response(404, "Draft workflow not found")
@_api_prerequisite
def get(self, _current_user: Account, app_model: App):
@@ -679,11 +625,7 @@ class EnvironmentVariableCollectionApi(Resource):
@console_ns.doc("update_environment_variables")
@console_ns.doc(description="Update environment variables for workflow draft")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200,
"Environment variables updated successfully",
console_ns.models[SimpleResultResponse.__name__],
)
@console_ns.response(200, "Environment variables updated successfully")
@setup_required
@login_required
@account_initialization_required
@@ -30,8 +30,6 @@ from uuid import UUID
from flask import Response
from flask_restx import Resource
from controllers.common.fields import EventStreamResponse
from controllers.common.schema import 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
@@ -40,13 +38,8 @@ from libs.login import login_required
from models import App, AppMode
from services.workflow import inspector_events
from services.workflow.node_output_inspector_service import (
CheckResultView,
NodeOutputInspectorError,
NodeOutputInspectorService,
NodeOutputsView,
NodeOutputView,
OutputPreviewView,
WorkflowRunSnapshotView,
)
logger = logging.getLogger(__name__)
@@ -61,16 +54,6 @@ _HEARTBEAT_EVERY_TICKS = 15
# many ticks (= seconds).
_STREAM_HARD_TIMEOUT_TICKS = 1800 # 30 min
register_response_schema_models(
console_ns,
EventStreamResponse,
CheckResultView,
NodeOutputView,
NodeOutputsView,
WorkflowRunSnapshotView,
OutputPreviewView,
)
def _service() -> NodeOutputInspectorService:
"""One-line factory so tests can monkeypatch a stub if needed."""
@@ -141,7 +124,6 @@ class WorkflowDraftRunNodeOutputsApi(Resource):
@console_ns.doc("get_workflow_draft_run_node_outputs")
@console_ns.doc(description="Snapshot of every node's declared outputs for a draft workflow run.")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(200, "Workflow run node outputs", console_ns.models[WorkflowRunSnapshotView.__name__])
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
@@ -164,7 +146,6 @@ class WorkflowDraftRunNodeOutputDetailApi(Resource):
"node_id": "Node ID inside the workflow graph",
}
)
@console_ns.response(200, "Workflow run node output detail", console_ns.models[NodeOutputsView.__name__])
@console_ns.response(404, "Workflow run / node not found")
@setup_required
@login_required
@@ -190,7 +171,6 @@ class WorkflowDraftRunNodeOutputPreviewApi(Resource):
"output_name": "Declared output name as exposed by Composer",
}
)
@console_ns.response(200, "Workflow run node output preview", console_ns.models[OutputPreviewView.__name__])
@console_ns.response(404, "Workflow run / node / output not found")
@setup_required
@login_required
@@ -329,11 +309,6 @@ class WorkflowDraftRunNodeOutputEventsApi(Resource):
@console_ns.doc("stream_workflow_draft_run_node_output_events")
@console_ns.doc(description="Server-Sent Events stream of inspector deltas for a draft workflow run.")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(
200,
"Workflow run node output event stream",
console_ns.models[EventStreamResponse.__name__],
)
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
@@ -363,7 +338,6 @@ class WorkflowPublishedRunNodeOutputsApi(Resource):
@console_ns.doc("get_workflow_published_run_node_outputs")
@console_ns.doc(description="Snapshot of every node's declared outputs for a published workflow run.")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(200, "Workflow run node outputs", console_ns.models[WorkflowRunSnapshotView.__name__])
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
@@ -386,7 +360,6 @@ class WorkflowPublishedRunNodeOutputDetailApi(Resource):
"node_id": "Node ID inside the workflow graph",
}
)
@console_ns.response(200, "Workflow run node output detail", console_ns.models[NodeOutputsView.__name__])
@console_ns.response(404, "Workflow run / node not found")
@setup_required
@login_required
@@ -413,7 +386,6 @@ class WorkflowPublishedRunNodeOutputPreviewApi(Resource):
"output_name": "Declared output name as exposed by Composer",
}
)
@console_ns.response(200, "Workflow run node output preview", console_ns.models[OutputPreviewView.__name__])
@console_ns.response(404, "Workflow run / node / output not found")
@setup_required
@login_required
@@ -430,11 +402,6 @@ class WorkflowPublishedRunNodeOutputEventsApi(Resource):
@console_ns.doc("stream_workflow_published_run_node_output_events")
@console_ns.doc(description="Server-Sent Events stream of inspector deltas for a published workflow run.")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(
200,
"Workflow run node output event stream",
console_ns.models[EventStreamResponse.__name__],
)
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
+1 -1
View File
@@ -9,7 +9,6 @@ from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from controllers.common.errors import NotFoundError
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
@@ -19,6 +18,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from controllers.web.error import NotFoundError
from core.workflow.human_input_forms import load_form_tokens_by_form_id as _load_form_tokens_by_form_id
from extensions.ext_database import db
from fields.base import ResponseModel
@@ -3,12 +3,11 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import sessionmaker
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 account_initialization_required, setup_required, with_current_user
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.datetime_utils import parse_time_range
from libs.login import login_required
from models.account import Account
@@ -29,50 +28,7 @@ class WorkflowStatisticQuery(BaseModel):
return value
class WorkflowDailyRunsStatisticItem(ResponseModel):
date: str
runs: int
class WorkflowDailyRunsStatisticResponse(ResponseModel):
data: list[WorkflowDailyRunsStatisticItem]
class WorkflowDailyTerminalsStatisticItem(ResponseModel):
date: str
terminal_count: int
class WorkflowDailyTerminalsStatisticResponse(ResponseModel):
data: list[WorkflowDailyTerminalsStatisticItem]
class WorkflowDailyTokenCostStatisticItem(ResponseModel):
date: str
token_count: int
class WorkflowDailyTokenCostStatisticResponse(ResponseModel):
data: list[WorkflowDailyTokenCostStatisticItem]
class WorkflowAverageAppInteractionStatisticItem(ResponseModel):
date: str
interactions: float
class WorkflowAverageAppInteractionStatisticResponse(ResponseModel):
data: list[WorkflowAverageAppInteractionStatisticItem]
register_schema_models(console_ns, WorkflowStatisticQuery)
register_response_schema_models(
console_ns,
WorkflowDailyRunsStatisticResponse,
WorkflowDailyTerminalsStatisticResponse,
WorkflowDailyTokenCostStatisticResponse,
WorkflowAverageAppInteractionStatisticResponse,
)
@console_ns.route("/apps/<uuid:app_id>/workflow/statistics/daily-conversations")
@@ -85,12 +41,8 @@ class WorkflowDailyRunsStatistic(Resource):
@console_ns.doc("get_workflow_daily_runs_statistic")
@console_ns.doc(description="Get workflow daily runs statistics")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery))
@console_ns.response(
200,
"Daily runs statistics retrieved successfully",
console_ns.models[WorkflowDailyRunsStatisticResponse.__name__],
)
@console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__])
@console_ns.response(200, "Daily runs statistics retrieved successfully")
@get_app_model
@setup_required
@login_required
@@ -128,12 +80,8 @@ class WorkflowDailyTerminalsStatistic(Resource):
@console_ns.doc("get_workflow_daily_terminals_statistic")
@console_ns.doc(description="Get workflow daily terminals statistics")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery))
@console_ns.response(
200,
"Daily terminals statistics retrieved successfully",
console_ns.models[WorkflowDailyTerminalsStatisticResponse.__name__],
)
@console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__])
@console_ns.response(200, "Daily terminals statistics retrieved successfully")
@get_app_model
@setup_required
@login_required
@@ -171,12 +119,8 @@ class WorkflowDailyTokenCostStatistic(Resource):
@console_ns.doc("get_workflow_daily_token_cost_statistic")
@console_ns.doc(description="Get workflow daily token cost statistics")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery))
@console_ns.response(
200,
"Daily token cost statistics retrieved successfully",
console_ns.models[WorkflowDailyTokenCostStatisticResponse.__name__],
)
@console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__])
@console_ns.response(200, "Daily token cost statistics retrieved successfully")
@get_app_model
@setup_required
@login_required
@@ -214,12 +158,8 @@ class WorkflowAverageAppInteractionStatistic(Resource):
@console_ns.doc("get_workflow_average_app_interaction_statistic")
@console_ns.doc(description="Get workflow average app interaction statistics")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery))
@console_ns.response(
200,
"Average app interaction statistics retrieved successfully",
console_ns.models[WorkflowAverageAppInteractionStatisticResponse.__name__],
)
@console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__])
@console_ns.response(200, "Average app interaction statistics retrieved successfully")
@setup_required
@login_required
@account_initialization_required
@@ -9,7 +9,7 @@ from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import NotFound
from configs import dify_config
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.common.schema import register_schema_models
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
@@ -86,7 +86,7 @@ register_schema_models(
class WebhookTriggerApi(Resource):
"""Webhook Trigger API"""
@console_ns.doc(params=query_params_from_model(Parser))
@console_ns.expect(console_ns.models[Parser.__name__])
@setup_required
@login_required
@account_initialization_required
+4 -9
View File
@@ -2,17 +2,15 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from configs import dify_config
from constants.languages import supported_language
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from controllers.console.error import AlreadyActivateError
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, timezone
from models import AccountStatus
from services.account_service import RegisterService
from services.billing_service import BillingService
class ActivateCheckQuery(BaseModel):
@@ -69,7 +67,7 @@ register_schema_models(
class ActivateCheckApi(Resource):
@console_ns.doc("check_activation_token")
@console_ns.doc(description="Check if activation token is valid")
@console_ns.doc(params=query_params_from_model(ActivateCheckQuery))
@console_ns.expect(console_ns.models[ActivateCheckQuery.__name__])
@console_ns.response(
200,
"Success",
@@ -122,12 +120,9 @@ class ActivateApi(Resource):
if invitation is None:
raise AlreadyActivateError()
account = invitation["account"]
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
raise AccountInFreezeError()
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
account = invitation["account"]
account.name = args.name
account.interface_language = args.interface_language
@@ -3,7 +3,6 @@ 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 fields.base import ResponseModel
from libs.login import login_required
@@ -34,12 +33,7 @@ class ApiKeyAuthDataSourceListResponse(ResponseModel):
register_schema_models(console_ns, ApiKeyAuthBindingPayload)
register_response_schema_models(
console_ns,
SimpleResultResponse,
ApiKeyAuthDataSourceItem,
ApiKeyAuthDataSourceListResponse,
)
register_response_schema_models(console_ns, ApiKeyAuthDataSourceItem, ApiKeyAuthDataSourceListResponse)
@console_ns.route("/api-key-auth/data-source")
@@ -70,7 +64,6 @@ class ApiKeyAuthDataSource(Resource):
@console_ns.route("/api-key-auth/data-source/binding")
class ApiKeyAuthDataSourceBinding(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -7,8 +7,7 @@ from flask_restx import Resource
from pydantic import BaseModel, Field
from configs import dify_config
from controllers.common.fields import RedirectResponse
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models
from controllers.common.schema import register_schema_models
from libs.login import login_required
from libs.oauth_data_source import NotionOAuth
@@ -30,24 +29,12 @@ class OAuthDataSourceSyncResponse(BaseModel):
result: str = Field(description="Operation result")
class OAuthDataSourceCallbackQuery(BaseModel):
code: str | None = Field(default=None, description="Authorization code from OAuth provider")
error: str | None = Field(default=None, description="Error message from OAuth provider")
class OAuthDataSourceBindingQuery(BaseModel):
code: str = Field(description="Authorization code from OAuth provider")
register_schema_models(
console_ns,
OAuthDataSourceResponse,
OAuthDataSourceBindingResponse,
OAuthDataSourceSyncResponse,
OAuthDataSourceCallbackQuery,
OAuthDataSourceBindingQuery,
)
register_response_schema_model(console_ns, RedirectResponse)
def get_oauth_providers():
@@ -97,9 +84,14 @@ class OAuthDataSource(Resource):
class OAuthDataSourceCallback(Resource):
@console_ns.doc("oauth_data_source_callback")
@console_ns.doc(description="Handle OAuth callback from data source provider")
@console_ns.doc(params={"provider": "Data source provider name (notion)"})
@console_ns.doc(params=query_params_from_model(OAuthDataSourceCallbackQuery))
@console_ns.response(302, "Redirect to console with result", console_ns.models[RedirectResponse.__name__])
@console_ns.doc(
params={
"provider": "Data source provider name (notion)",
"code": "Authorization code from OAuth provider",
"error": "Error message from OAuth provider",
}
)
@console_ns.response(302, "Redirect to console with result")
@console_ns.response(400, "Invalid provider")
def get(self, provider: str):
OAUTH_DATASOURCE_PROVIDERS = get_oauth_providers()
@@ -123,8 +115,9 @@ class OAuthDataSourceCallback(Resource):
class OAuthDataSourceBinding(Resource):
@console_ns.doc("oauth_data_source_binding")
@console_ns.doc(description="Bind OAuth data source with authorization code")
@console_ns.doc(params={"provider": "Data source provider name (notion)"})
@console_ns.doc(params=query_params_from_model(OAuthDataSourceBindingQuery))
@console_ns.doc(
params={"provider": "Data source provider name (notion)", "code": "Authorization code from OAuth provider"}
)
@console_ns.response(
200,
"Data source binding success",
+1 -22
View File
@@ -15,7 +15,6 @@ from controllers.console.auth.error import (
InvalidTokenError,
PasswordMismatchError,
)
from fields.base import ResponseModel
from libs.helper import EmailStr, extract_remote_ip
from libs.helper import timezone as validate_timezone_string
from libs.password import valid_password
@@ -59,24 +58,8 @@ class EmailRegisterResetPayload(BaseModel):
return validate_timezone_string(value)
class EmailRegisterTokenPairResponse(ResponseModel):
access_token: str
refresh_token: str
csrf_token: str
class EmailRegisterResetResponse(ResponseModel):
result: str
data: EmailRegisterTokenPairResponse
register_schema_models(console_ns, EmailRegisterSendPayload, EmailRegisterValidityPayload, EmailRegisterResetPayload)
register_response_schema_models(
console_ns,
SimpleResultDataResponse,
VerificationTokenResponse,
EmailRegisterResetResponse,
)
register_response_schema_models(console_ns, SimpleResultDataResponse, VerificationTokenResponse)
@console_ns.route("/email-register/send-email")
@@ -84,7 +67,6 @@ class EmailRegisterSendEmailApi(Resource):
@setup_required
@email_password_login_enabled
@email_register_enabled
@console_ns.expect(console_ns.models[EmailRegisterSendPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
def post(self):
args = EmailRegisterSendPayload.model_validate(console_ns.payload)
@@ -110,7 +92,6 @@ class EmailRegisterCheckApi(Resource):
@setup_required
@email_password_login_enabled
@email_register_enabled
@console_ns.expect(console_ns.models[EmailRegisterValidityPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
def post(self):
args = EmailRegisterValidityPayload.model_validate(console_ns.payload)
@@ -152,8 +133,6 @@ class EmailRegisterResetApi(Resource):
@setup_required
@email_password_login_enabled
@email_register_enabled
@console_ns.expect(console_ns.models[EmailRegisterResetPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[EmailRegisterResetResponse.__name__])
def post(self):
args = EmailRegisterResetPayload.model_validate(console_ns.payload)
+12 -24
View File
@@ -4,13 +4,10 @@ import urllib.parse
import httpx
from flask import current_app, redirect, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from werkzeug.exceptions import Unauthorized
from configs import dify_config
from constants.languages import languages
from controllers.common.fields import RedirectResponse
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models
from events.tenant_event import tenant_was_created
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
@@ -34,21 +31,6 @@ from .. import console_ns
logger = logging.getLogger(__name__)
class OAuthLoginQuery(BaseModel):
invite_token: str | None = Field(default=None, description="Optional invitation token")
timezone: str | None = Field(default=None, description="Preferred timezone")
language: str | None = Field(default=None, description="Preferred interface language")
class OAuthCallbackQuery(BaseModel):
code: str = Field(description="Authorization code from OAuth provider")
state: str | None = Field(default=None, description="OAuth state parameter")
register_schema_models(console_ns, OAuthLoginQuery, OAuthCallbackQuery)
register_response_schema_model(console_ns, RedirectResponse)
def get_oauth_providers():
with current_app.app_context():
if not dify_config.GITHUB_CLIENT_ID or not dify_config.GITHUB_CLIENT_SECRET:
@@ -101,9 +83,10 @@ def _preferred_interface_language(language: str | None = None) -> str:
class OAuthLogin(Resource):
@console_ns.doc("oauth_login")
@console_ns.doc(description="Initiate OAuth login process")
@console_ns.doc(params={"provider": "OAuth provider name (github/google)"})
@console_ns.doc(params=query_params_from_model(OAuthLoginQuery))
@console_ns.response(302, "Redirect to OAuth authorization URL", console_ns.models[RedirectResponse.__name__])
@console_ns.doc(
params={"provider": "OAuth provider name (github/google)", "invite_token": "Optional invitation token"}
)
@console_ns.response(302, "Redirect to OAuth authorization URL")
@console_ns.response(400, "Invalid provider")
def get(self, provider: str):
invite_token = request.args.get("invite_token") or None
@@ -127,9 +110,14 @@ class OAuthLogin(Resource):
class OAuthCallback(Resource):
@console_ns.doc("oauth_callback")
@console_ns.doc(description="Handle OAuth callback and complete login process")
@console_ns.doc(params={"provider": "OAuth provider name (github/google)"})
@console_ns.doc(params=query_params_from_model(OAuthCallbackQuery))
@console_ns.response(302, "Redirect to console with access token", console_ns.models[RedirectResponse.__name__])
@console_ns.doc(
params={
"provider": "OAuth provider name (github/google)",
"code": "Authorization code from OAuth provider",
"state": "Optional state parameter (used for invite token)",
}
)
@console_ns.response(302, "Redirect to console with access token")
@console_ns.response(400, "OAuth process failed")
def get(self, provider: str):
OAUTH_PROVIDERS = get_oauth_providers()
+1 -45
View File
@@ -1,6 +1,6 @@
from collections.abc import Callable
from functools import wraps
from typing import Any, Concatenate
from typing import Concatenate
from flask import jsonify, request
from flask.typing import ResponseReturnValue
@@ -8,7 +8,6 @@ from flask_restx import Resource
from pydantic import BaseModel
from werkzeug.exceptions import BadRequest, NotFound
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.login import login_required
@@ -37,41 +36,6 @@ class OAuthTokenRequest(BaseModel):
refresh_token: str | None = None
class OAuthProviderAppResponse(BaseModel):
app_icon: str
app_label: dict[str, Any]
scope: str
class OAuthProviderAuthorizeResponse(BaseModel):
code: str
class OAuthProviderTokenResponse(BaseModel):
access_token: str
token_type: str
expires_in: int
refresh_token: str
class OAuthProviderAccountResponse(BaseModel):
name: str
email: str
avatar: str | None = None
interface_language: str
timezone: str
register_schema_models(console_ns, OAuthClientPayload, OAuthProviderRequest, OAuthTokenRequest)
register_response_schema_models(
console_ns,
OAuthProviderAccountResponse,
OAuthProviderAppResponse,
OAuthProviderAuthorizeResponse,
OAuthProviderTokenResponse,
)
def oauth_server_client_id_required[T, **P, R](
view: Callable[Concatenate[T, OAuthProviderApp, P], R],
) -> Callable[Concatenate[T, P], R]:
@@ -146,8 +110,6 @@ def oauth_server_access_token_required[T, **P, R](
@console_ns.route("/oauth/provider")
class OAuthServerAppApi(Resource):
@setup_required
@console_ns.expect(console_ns.models[OAuthProviderRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[OAuthProviderAppResponse.__name__])
@oauth_server_client_id_required
def post(self, oauth_provider_app: OAuthProviderApp):
payload = OAuthProviderRequest.model_validate(request.get_json())
@@ -172,8 +134,6 @@ class OAuthServerUserAuthorizeApi(Resource):
@login_required
@account_initialization_required
@with_current_user
@console_ns.expect(console_ns.models[OAuthClientPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[OAuthProviderAuthorizeResponse.__name__])
@oauth_server_client_id_required
def post(self, oauth_provider_app: OAuthProviderApp, current_user: Account):
user_account_id = current_user.id
@@ -188,8 +148,6 @@ class OAuthServerUserAuthorizeApi(Resource):
@console_ns.route("/oauth/provider/token")
class OAuthServerUserTokenApi(Resource):
@setup_required
@console_ns.expect(console_ns.models[OAuthTokenRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[OAuthProviderTokenResponse.__name__])
@oauth_server_client_id_required
def post(self, oauth_provider_app: OAuthProviderApp):
payload = OAuthTokenRequest.model_validate(request.get_json())
@@ -240,8 +198,6 @@ class OAuthServerUserTokenApi(Resource):
@console_ns.route("/oauth/provider/account")
class OAuthServerUserAccountApi(Resource):
@setup_required
@console_ns.expect(console_ns.models[OAuthClientPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[OAuthProviderAccountResponse.__name__])
@oauth_server_client_id_required
@oauth_server_access_token_required
def post(self, oauth_provider_app: OAuthProviderApp, account: Account):
+4 -12
View File
@@ -1,12 +1,12 @@
import base64
from typing import Any, Literal
from typing import Literal
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, Field
from werkzeug.exceptions import BadRequest
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 (
account_initialization_required,
@@ -30,18 +30,11 @@ class PartnerTenantsPayload(BaseModel):
click_id: str = Field(..., description="Click Id from partner referral link")
class BillingResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
register_schema_models(console_ns, SubscriptionQuery, PartnerTenantsPayload)
register_response_schema_models(console_ns, BillingResponse)
@console_ns.route("/billing/subscription")
class Subscription(Resource):
@console_ns.doc(params=query_params_from_model(SubscriptionQuery))
@console_ns.response(200, "Success", console_ns.models[BillingResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -56,7 +49,6 @@ class Subscription(Resource):
@console_ns.route("/billing/invoices")
class Invoices(Resource):
@console_ns.response(200, "Success", console_ns.models[BillingResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -74,7 +66,7 @@ class PartnerTenants(Resource):
@console_ns.doc(description="Sync partner tenants bindings")
@console_ns.doc(params={"partner_key": "Partner key"})
@console_ns.expect(console_ns.models[PartnerTenantsPayload.__name__])
@console_ns.response(200, "Tenants synced to partner successfully", console_ns.models[BillingResponse.__name__])
@console_ns.response(200, "Tenants synced to partner successfully")
@console_ns.response(400, "Invalid partner information")
@setup_required
@login_required
+3 -13
View File
@@ -1,16 +1,12 @@
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, Field
from controllers.common.schema import query_params_from_model, register_response_schema_models
from libs.helper import extract_remote_ip
from libs.login import login_required
from models import Account
from services.billing_service import BillingService
from ...common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0
from .. import console_ns
from ..wraps import (
account_initialization_required,
@@ -25,23 +21,17 @@ class ComplianceDownloadQuery(BaseModel):
doc_name: str = Field(..., description="Compliance document name")
class ComplianceDownloadResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
console_ns.schema_model(
ComplianceDownloadQuery.__name__,
ComplianceDownloadQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
ComplianceDownloadQuery.model_json_schema(ref_template="#/definitions/{model}"),
)
register_response_schema_models(console_ns, ComplianceDownloadResponse)
@console_ns.route("/compliance/download")
class ComplianceApi(Resource):
@console_ns.doc(params=query_params_from_model(ComplianceDownloadQuery))
@console_ns.expect(console_ns.models[ComplianceDownloadQuery.__name__])
@console_ns.doc("download_compliance_document")
@console_ns.doc(description="Get compliance document download link")
@console_ns.response(200, "Success", console_ns.models[ComplianceDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
+33 -38
View File
@@ -22,8 +22,6 @@ from controllers.console.wraps import (
enterprise_license_required,
is_admin_or_owner_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.indexing_runner import IndexingRunner
@@ -38,9 +36,9 @@ from fields.base import ResponseModel
from fields.dataset_fields import DatasetDetailResponse
from graphon.model_runtime.entities.model_entities import ModelType
from libs.helper import build_icon_url, dump_response, to_timestamp
from libs.login import login_required
from libs.login import current_account_with_tenant, login_required
from libs.url_utils import normalize_api_base_url
from models import Account, ApiToken, Dataset, Document, DocumentSegment, UploadFile
from models import ApiToken, Dataset, Document, DocumentSegment, UploadFile
from models.dataset import DatasetPermission, DatasetPermissionEnum
from models.enums import ApiTokenType, SegmentStatus
from models.provider_ids import ModelProviderID
@@ -95,13 +93,13 @@ class DatasetUpdatePayload(BaseModel):
indexing_technique: str | None = None
embedding_model: str | None = None
embedding_model_provider: str | None = None
retrieval_model: dict[str, Any] | None = Field(default=None)
summary_index_setting: dict[str, Any] | None = Field(default=None)
retrieval_model: dict[str, Any] | None = None
summary_index_setting: dict[str, Any] | None = None
partial_member_list: list[dict[str, str]] | None = None
external_retrieval_model: dict[str, Any] | None = Field(default=None)
external_retrieval_model: dict[str, Any] | None = None
external_knowledge_id: str | None = None
external_knowledge_api_id: str | None = None
icon_info: dict[str, Any] | None = Field(default=None)
icon_info: dict[str, Any] | None = None
is_multimodal: bool | None = False
@field_validator("indexing_technique")
@@ -391,9 +389,8 @@ class DatasetListApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
def get(self):
current_user, current_tenant_id = current_account_with_tenant()
# Convert query parameters to dict, handling list parameters correctly
query_params: dict[str, str | list[str]] = dict(request.args.to_dict())
# Handle ids and tag_ids as lists (Flask request.args.getlist returns list even for single value)
@@ -474,10 +471,9 @@ class DatasetListApi(Resource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
def post(self):
payload = DatasetCreatePayload.model_validate(console_ns.payload or {})
current_user, current_tenant_id = current_account_with_tenant()
# The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
if not current_user.is_dataset_editor:
@@ -516,9 +512,8 @@ class DatasetApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID):
def get(self, dataset_id: UUID):
current_user, current_tenant_id = current_account_with_tenant()
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
@@ -571,15 +566,14 @@ class DatasetApi(Resource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account, dataset_id: UUID):
def patch(self, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
payload = DatasetUpdatePayload.model_validate(console_ns.payload or {})
current_user, current_tenant_id = current_account_with_tenant()
# check embedding model setting
if (
payload.indexing_technique == IndexTechniqueType.HIGH_QUALITY
@@ -620,9 +614,9 @@ class DatasetApi(Resource):
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Dataset deleted successfully")
@with_current_user
def delete(self, current_user: Account, dataset_id: UUID):
def delete(self, dataset_id: UUID):
dataset_id_str = str(dataset_id)
current_user, _ = current_account_with_tenant()
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
raise Forbidden()
@@ -670,8 +664,8 @@ class DatasetQueryApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
def get(self, current_user: Account, dataset_id: UUID):
def get(self, dataset_id: UUID):
current_user, _ = current_account_with_tenant()
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
@@ -710,10 +704,10 @@ class DatasetIndexingEstimateApi(Resource):
@login_required
@account_initialization_required
@console_ns.expect(console_ns.models[IndexingEstimatePayload.__name__])
@with_current_tenant_id
def post(self, current_tenant_id: str):
def post(self):
payload = IndexingEstimatePayload.model_validate(console_ns.payload or {})
args = payload.model_dump()
_, current_tenant_id = current_account_with_tenant()
# validate args
DocumentService.estimate_args_validate(args)
extract_settings = []
@@ -810,8 +804,8 @@ class DatasetRelatedAppListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
def get(self, current_user: Account, dataset_id: UUID):
def get(self, dataset_id: UUID):
current_user, _ = current_account_with_tenant()
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
@@ -846,8 +840,8 @@ class DatasetIndexingStatusApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, dataset_id: UUID):
def get(self, dataset_id: UUID):
_, current_tenant_id = current_account_with_tenant()
dataset_id_str = str(dataset_id)
documents = db.session.scalars(
select(Document).where(Document.dataset_id == dataset_id_str, Document.tenant_id == current_tenant_id)
@@ -904,8 +898,8 @@ class DatasetApiKeyApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
def get(self):
_, current_tenant_id = current_account_with_tenant()
keys = db.session.scalars(
select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id)
).all()
@@ -917,8 +911,9 @@ class DatasetApiKeyApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str):
def post(self):
_, current_tenant_id = current_account_with_tenant()
current_key_count = (
db.session.scalar(
select(func.count(ApiToken.id)).where(
@@ -957,8 +952,8 @@ class DatasetApiDeleteApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
@with_current_tenant_id
def delete(self, current_tenant_id: str, api_key_id: UUID):
def delete(self, api_key_id: UUID):
_, current_tenant_id = current_account_with_tenant()
api_key_id_str = str(api_key_id)
key = db.session.scalar(
select(ApiToken)
@@ -1084,8 +1079,8 @@ class DatasetPermissionUserListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
def get(self, current_user: Account, dataset_id: UUID):
def get(self, dataset_id: UUID):
current_user, _ = current_account_with_tenant()
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
@@ -10,13 +10,13 @@ from uuid import UUID
import sqlalchemy as sa
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, field_validator
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import asc, desc, func, select
from werkzeug.exceptions import Forbidden, NotFound
import services
from controllers.common.controller_schemas import DocumentBatchDownloadZipPayload
from controllers.common.fields import BinaryFileResponse, SimpleResultMessageResponse, SimpleResultResponse, UrlResponse
from controllers.common.fields import SimpleResultMessageResponse, SimpleResultResponse, UrlResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from core.errors.error import (
@@ -145,10 +145,6 @@ class DocumentWithSegmentsListResponse(ResponseModel):
page: int
class OpaqueObjectResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
register_schema_models(
console_ns,
KnowledgeConfig,
@@ -162,7 +158,6 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
BinaryFileResponse,
SimpleResultMessageResponse,
SimpleResultResponse,
UrlResponse,
@@ -172,7 +167,6 @@ register_response_schema_models(
DocumentWithSegmentsResponse,
DatasetAndDocumentResponse,
DocumentWithSegmentsListResponse,
OpaqueObjectResponse,
)
@@ -222,7 +216,7 @@ class GetProcessRuleApi(Resource):
@console_ns.doc("get_process_rule")
@console_ns.doc(description="Get dataset document processing rules")
@console_ns.doc(params={"document_id": "Document ID (optional)"})
@console_ns.response(200, "Process rules retrieved successfully", console_ns.models[OpaqueObjectResponse.__name__])
@console_ns.response(200, "Process rules retrieved successfully")
@setup_required
@login_required
@account_initialization_required
@@ -543,11 +537,7 @@ class DocumentIndexingEstimateApi(DocumentResource):
@console_ns.doc("estimate_document_indexing")
@console_ns.doc(description="Estimate document indexing cost")
@console_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@console_ns.response(
200,
"Indexing estimate calculated successfully",
console_ns.models[OpaqueObjectResponse.__name__],
)
@console_ns.response(200, "Indexing estimate calculated successfully")
@console_ns.response(404, "Document not found")
@console_ns.response(400, "Document already finished")
@setup_required
@@ -616,11 +606,6 @@ class DocumentIndexingEstimateApi(DocumentResource):
@console_ns.route("/datasets/<uuid:dataset_id>/batch/<string:batch>/indexing-estimate")
class DocumentBatchIndexingEstimateApi(DocumentResource):
@console_ns.response(
200,
"Batch indexing estimate calculated successfully",
console_ns.models[OpaqueObjectResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -839,7 +824,7 @@ class DocumentApi(DocumentResource):
"metadata": "Metadata inclusion (all/only/without)",
}
)
@console_ns.response(200, "Document retrieved successfully", console_ns.models[OpaqueObjectResponse.__name__])
@console_ns.response(200, "Document retrieved successfully")
@console_ns.response(404, "Document not found")
@setup_required
@login_required
@@ -981,7 +966,6 @@ class DocumentBatchDownloadZipApi(DocumentResource):
@console_ns.doc("download_dataset_documents_as_zip")
@console_ns.doc(description="Download selected dataset documents as a single ZIP archive (upload-file only)")
@console_ns.response(200, "ZIP archive generated successfully", console_ns.models[BinaryFileResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -1340,11 +1324,6 @@ class WebsiteDocumentSyncApi(DocumentResource):
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/pipeline-execution-log")
class DocumentPipelineExecutionLogApi(DocumentResource):
@console_ns.response(
200,
"Document pipeline execution log retrieved successfully",
console_ns.models[OpaqueObjectResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -1485,7 +1464,7 @@ class DocumentSummaryStatusApi(DocumentResource):
@console_ns.doc("get_document_summary_status")
@console_ns.doc(description="Get summary index generation status for a document")
@console_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@console_ns.response(200, "Summary status retrieved successfully", console_ns.models[OpaqueObjectResponse.__name__])
@console_ns.response(200, "Summary status retrieved successfully")
@console_ns.response(404, "Document not found")
@setup_required
@login_required
+15 -78
View File
@@ -1,19 +1,13 @@
from typing import Any
from uuid import UUID
from flask import request
from flask_restx import Resource, fields, marshal
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
from controllers.common.fields import UsageCountResponse
from controllers.common.schema import (
get_or_create_model,
query_params_from_model,
register_response_schema_models,
register_schema_models,
)
from controllers.common.schema import get_or_create_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.datasets.error import DatasetNameDuplicateError
from controllers.console.wraps import (
@@ -23,7 +17,6 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from fields.base import ResponseModel
from fields.dataset_fields import (
dataset_detail_fields,
dataset_retrieval_model_fields,
@@ -95,15 +88,13 @@ class ExternalDatasetCreatePayload(BaseModel):
external_knowledge_id: str
name: str = Field(..., min_length=1, max_length=100)
description: str | None = Field(None, max_length=400)
external_retrieval_model: dict[str, object] | None = Field(default=None)
external_retrieval_model: dict[str, object] | None = None
class ExternalHitTestingPayload(BaseModel):
query: str
external_retrieval_model: dict[str, object] | None = Field(default=None)
metadata_filtering_conditions: dict[str, object] | None = Field(
default=None,
)
external_retrieval_model: dict[str, object] | None = None
metadata_filtering_conditions: dict[str, object] | None = None
class BedrockRetrievalPayload(BaseModel):
@@ -118,34 +109,6 @@ class ExternalApiTemplateListQuery(BaseModel):
keyword: str | None = Field(default=None, description="Search keyword")
class ExternalKnowledgeDatasetBindingResponse(ResponseModel):
id: str
name: str
class ExternalKnowledgeApiResponse(ResponseModel):
id: str
tenant_id: str
name: str
description: str
settings: dict[str, Any] | None = Field(default=None)
dataset_bindings: list[ExternalKnowledgeDatasetBindingResponse] = Field(default_factory=list)
created_by: str
created_at: str
class ExternalKnowledgeApiListResponse(ResponseModel):
data: list[ExternalKnowledgeApiResponse]
has_more: bool
limit: int
total: int
page: int
class ExternalRetrievalTestResponse(RootModel[dict[str, Any] | list[dict[str, Any]]]):
root: dict[str, Any] | list[dict[str, Any]]
register_schema_models(
console_ns,
ExternalKnowledgeApiPayload,
@@ -154,24 +117,20 @@ register_schema_models(
BedrockRetrievalPayload,
ExternalApiTemplateListQuery,
)
register_response_schema_models(
console_ns,
ExternalKnowledgeApiResponse,
ExternalKnowledgeApiListResponse,
ExternalRetrievalTestResponse,
)
@console_ns.route("/datasets/external-knowledge-api")
class ExternalApiTemplateListApi(Resource):
@console_ns.doc("get_external_api_templates")
@console_ns.doc(description="Get external knowledge API templates")
@console_ns.doc(params=query_params_from_model(ExternalApiTemplateListQuery))
@console_ns.response(
200,
"External API templates retrieved successfully",
console_ns.models[ExternalKnowledgeApiListResponse.__name__],
@console_ns.doc(
params={
"page": "Page number (default: 1)",
"limit": "Number of items per page (default: 20)",
"keyword": "Search keyword",
}
)
@console_ns.response(200, "External API templates retrieved successfully")
@setup_required
@login_required
@with_current_tenant_id
@@ -195,11 +154,6 @@ class ExternalApiTemplateListApi(Resource):
@login_required
@account_initialization_required
@console_ns.expect(console_ns.models[ExternalKnowledgeApiPayload.__name__])
@console_ns.response(
201,
"External API template created successfully",
console_ns.models[ExternalKnowledgeApiResponse.__name__],
)
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
@@ -226,11 +180,7 @@ class ExternalApiTemplateApi(Resource):
@console_ns.doc("get_external_api_template")
@console_ns.doc(description="Get external knowledge API template details")
@console_ns.doc(params={"external_knowledge_api_id": "External knowledge API ID"})
@console_ns.response(
200,
"External API template retrieved successfully",
console_ns.models[ExternalKnowledgeApiResponse.__name__],
)
@console_ns.response(200, "External API template retrieved successfully")
@console_ns.response(404, "Template not found")
@setup_required
@login_required
@@ -246,11 +196,6 @@ class ExternalApiTemplateApi(Resource):
return external_knowledge_api.to_dict(), 200
@console_ns.response(
200,
"External API template updated successfully",
console_ns.models[ExternalKnowledgeApiResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -348,11 +293,7 @@ class ExternalKnowledgeHitTestingApi(Resource):
@console_ns.doc(description="Test external knowledge retrieval for dataset")
@console_ns.doc(params={"dataset_id": "Dataset ID"})
@console_ns.expect(console_ns.models[ExternalHitTestingPayload.__name__])
@console_ns.response(
200,
"External hit testing completed successfully",
console_ns.models[ExternalRetrievalTestResponse.__name__],
)
@console_ns.response(200, "External hit testing completed successfully")
@console_ns.response(404, "Dataset not found")
@console_ns.response(400, "Invalid parameters")
@setup_required
@@ -393,11 +334,7 @@ class BedrockRetrievalApi(Resource):
@console_ns.doc("bedrock_retrieval_test")
@console_ns.doc(description="Bedrock retrieval test (internal use only)")
@console_ns.expect(console_ns.models[BedrockRetrievalPayload.__name__])
@console_ns.response(
200,
"Bedrock retrieval test completed",
console_ns.models[ExternalRetrievalTestResponse.__name__],
)
@console_ns.response(200, "Bedrock retrieval test completed")
def post(self):
payload = BedrockRetrievalPayload.model_validate(console_ns.payload or {})
@@ -8,7 +8,6 @@ from controllers.common.schema import register_response_schema_models, register_
from fields.hit_testing_fields import HitTestingResponse
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from .. import console_ns
from ..datasets.hit_testing_base import DatasetsHitTestingBase, HitTestingPayload
@@ -16,8 +15,6 @@ from ..wraps import (
account_initialization_required,
cloud_edition_billing_rate_limit_check,
setup_required,
with_current_tenant_id,
with_current_user,
)
register_schema_models(console_ns, HitTestingPayload)
@@ -41,16 +38,11 @@ class HitTestingApi(Resource, DatasetsHitTestingBase):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@with_current_tenant_id
@with_current_user
def post(self, current_user: Account, current_tenant_id: str, dataset_id: UUID) -> dict[str, object]:
def post(self, dataset_id: UUID) -> dict[str, object]:
dataset_id_str = str(dataset_id)
dataset = self.get_and_validate_dataset(dataset_id_str, current_user, current_tenant_id)
dataset = self.get_and_validate_dataset(dataset_id_str)
args = self.parse_args(console_ns.payload)
self.hit_testing_args_check(args)
return dump_response(
HitTestingResponse,
self.perform_hit_testing(dataset, args, current_user, current_tenant_id),
)
return dump_response(HitTestingResponse, self.perform_hit_testing(dataset, args))
@@ -19,7 +19,7 @@ from core.errors.error import (
QuotaExceededError,
)
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import resolve_account_fallback
from libs.login import current_user
from models.account import Account
from models.dataset import Dataset
from services.dataset_service import DatasetService
@@ -32,7 +32,7 @@ logger = logging.getLogger(__name__)
class HitTestingPayload(BaseModel):
query: str = Field(max_length=250)
retrieval_model: RetrievalModel | None = None
external_retrieval_model: dict[str, Any] | None = Field(default=None)
external_retrieval_model: dict[str, Any] | None = None
attachment_ids: list[str] | None = None
@@ -71,10 +71,8 @@ class DatasetsHitTestingBase:
return normalized_records
@staticmethod
def get_and_validate_dataset(
dataset_id: str, current_user: Account | None = None, current_tenant_id: str | None = None
) -> Dataset:
current_user, _ = resolve_account_fallback(current_user, current_tenant_id)
def get_and_validate_dataset(dataset_id: str) -> Dataset:
assert isinstance(current_user, Account)
dataset = DatasetService.get_dataset(dataset_id)
if dataset is None:
raise NotFound("Dataset not found.")
@@ -97,14 +95,9 @@ class DatasetsHitTestingBase:
return hit_testing_payload.model_dump(exclude_none=True)
@staticmethod
def perform_hit_testing(
dataset: Dataset,
args: dict[str, Any],
current_user: Account | None = None,
current_tenant_id: str | None = None,
) -> dict[str, Any]:
def perform_hit_testing(dataset: Dataset, args: dict[str, Any]) -> dict[str, Any]:
assert isinstance(current_user, Account)
try:
current_user, _ = resolve_account_fallback(current_user, current_tenant_id)
response = HitTestingService.retrieve(
dataset=dataset,
query=cast(str, args.get("query")),
+5 -10
View File
@@ -11,7 +11,6 @@ from controllers.console.wraps import (
account_initialization_required,
enterprise_license_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from fields.dataset_fields import (
@@ -51,8 +50,7 @@ class DatasetMetadataCreateApi(Resource):
@console_ns.response(201, "Metadata created successfully", console_ns.models[DatasetMetadataResponse.__name__])
@console_ns.expect(console_ns.models[MetadataArgs.__name__])
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID):
def post(self, current_user: Account, dataset_id: UUID):
metadata_args = MetadataArgs.model_validate(console_ns.payload or {})
dataset_id_str = str(dataset_id)
@@ -61,7 +59,7 @@ class DatasetMetadataCreateApi(Resource):
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
metadata = MetadataService.create_metadata(dataset_id_str, metadata_args, current_user, current_tenant_id)
metadata = MetadataService.create_metadata(dataset_id_str, metadata_args)
return dump_response(DatasetMetadataResponse, metadata), 201
@setup_required
@@ -89,8 +87,7 @@ class DatasetMetadataApi(Resource):
@console_ns.response(200, "Metadata updated successfully", console_ns.models[DatasetMetadataResponse.__name__])
@console_ns.expect(console_ns.models[MetadataUpdatePayload.__name__])
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, metadata_id: UUID):
def patch(self, current_user: Account, dataset_id: UUID, metadata_id: UUID):
payload = MetadataUpdatePayload.model_validate(console_ns.payload or {})
name = payload.name
@@ -101,9 +98,7 @@ class DatasetMetadataApi(Resource):
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
metadata = MetadataService.update_metadata_name(
dataset_id_str, metadata_id_str, name, current_user, current_tenant_id
)
metadata = MetadataService.update_metadata_name(dataset_id_str, metadata_id_str, name)
return dump_response(DatasetMetadataResponse, metadata), 200
@setup_required
@@ -186,7 +181,7 @@ class DocumentMetadataEditApi(Resource):
metadata_args = MetadataOperationData.model_validate(console_ns.payload or {})
MetadataService.update_documents_metadata(dataset, metadata_args, current_user)
MetadataService.update_documents_metadata(dataset, metadata_args)
# Frontend callers only await success and invalidate caches; no response body is consumed.
return "", 204
@@ -6,8 +6,8 @@ from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.common.fields import RedirectResponse, SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
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.wraps import (
account_initialization_required,
@@ -16,9 +16,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from core.plugin.entities.plugin_daemon import PluginOAuthAuthorizationUrlResponse
from core.plugin.impl.oauth import OAuthHandler
from fields.base import ResponseModel
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.login import login_required
@@ -40,11 +38,11 @@ class DatasourceCredentialDeletePayload(BaseModel):
class DatasourceCredentialUpdatePayload(BaseModel):
credential_id: str
name: str | None = Field(default=None, max_length=100)
credentials: dict[str, Any] | None = Field(default=None)
credentials: dict[str, Any] | None = None
class DatasourceCustomClientPayload(BaseModel):
client_params: dict[str, Any] | None = Field(default=None)
client_params: dict[str, Any] | None = None
enable_oauth_custom_client: bool | None = None
@@ -57,25 +55,8 @@ class DatasourceUpdateNamePayload(BaseModel):
name: str = Field(max_length=100)
class DatasourceOAuthAuthorizationQuery(BaseModel):
credential_id: str | None = Field(default=None, description="Credential ID to reauthorize")
class DatasourceOAuthCallbackQuery(BaseModel):
code: str | None = Field(default=None, description="Authorization code from OAuth provider")
state: str | None = Field(default=None, description="OAuth state parameter")
error: str | None = Field(default=None, description="Error message from OAuth provider")
context_id: str | None = Field(default=None, description="OAuth proxy context ID")
class DatasourceCredentialsResponse(ResponseModel):
result: Any
register_schema_models(
console_ns,
DatasourceOAuthAuthorizationQuery,
DatasourceOAuthCallbackQuery,
DatasourceCredentialPayload,
DatasourceCredentialDeletePayload,
DatasourceCredentialUpdatePayload,
@@ -83,23 +64,11 @@ register_schema_models(
DatasourceDefaultPayload,
DatasourceUpdateNamePayload,
)
register_response_schema_models(
console_ns,
DatasourceCredentialsResponse,
PluginOAuthAuthorizationUrlResponse,
RedirectResponse,
SimpleResultResponse,
)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/oauth/plugin/<path:provider_id>/datasource/get-authorization-url")
class DatasourcePluginOAuthAuthorizationUrl(Resource):
@console_ns.doc(params=query_params_from_model(DatasourceOAuthAuthorizationQuery))
@console_ns.response(
200,
"Authorization URL retrieved successfully",
console_ns.models[PluginOAuthAuthorizationUrlResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -149,12 +118,6 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
@console_ns.route("/oauth/plugin/<path:provider_id>/datasource/callback")
class DatasourceOAuthCallback(Resource):
@console_ns.doc(params=query_params_from_model(DatasourceOAuthCallbackQuery))
@console_ns.response(
302,
"Redirect to console OAuth callback page",
console_ns.models[RedirectResponse.__name__],
)
@setup_required
def get(self, provider_id: str):
context_id = request.cookies.get("context_id") or request.args.get("context_id")
@@ -213,7 +176,6 @@ class DatasourceOAuthCallback(Resource):
@console_ns.route("/auth/plugin/datasource/<path:provider_id>")
class DatasourceAuth(Resource):
@console_ns.expect(console_ns.models[DatasourceCredentialPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -238,7 +200,6 @@ class DatasourceAuth(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account, provider_id: str):
@@ -282,7 +243,6 @@ class DatasourceAuthDeleteApi(Resource):
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/update")
class DatasourceAuthUpdateApi(Resource):
@console_ns.expect(console_ns.models[DatasourceCredentialUpdatePayload.__name__])
@console_ns.response(201, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -306,7 +266,6 @@ class DatasourceAuthUpdateApi(Resource):
@console_ns.route("/auth/plugin/datasource/list")
class DatasourceAuthListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -319,7 +278,6 @@ class DatasourceAuthListApi(Resource):
@console_ns.route("/auth/plugin/datasource/default-list")
class DatasourceHardCodeAuthListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -333,7 +291,6 @@ class DatasourceHardCodeAuthListApi(Resource):
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/custom-client")
class DatasourceAuthOauthCustomClient(Resource):
@console_ns.expect(console_ns.models[DatasourceCustomClientPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -1,11 +1,9 @@
from typing import Any
from flask_restx import ( # type: ignore
Resource, # type: ignore
)
from pydantic import BaseModel, RootModel
from pydantic import BaseModel
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.datasets.wraps import get_rag_pipeline
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
@@ -16,23 +14,17 @@ from services.rag_pipeline.rag_pipeline import RagPipelineService
class Parser(BaseModel):
inputs: dict[str, Any]
inputs: dict
datasource_type: str
credential_id: str | None = None
class DataSourceContentPreviewResponse(RootModel[Any]):
root: Any
register_schema_models(console_ns, Parser)
register_response_schema_models(console_ns, DataSourceContentPreviewResponse)
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/published/datasource/nodes/<string:node_id>/preview")
class DataSourceContentPreviewApi(Resource):
@console_ns.expect(console_ns.models[Parser.__name__])
@console_ns.response(200, "Success", console_ns.models[DataSourceContentPreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -21,14 +21,11 @@ from controllers.console.wraps import (
enterprise_license_required,
knowledge_pipeline_publish_enabled,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from models.dataset import PipelineCustomizedTemplate
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity
from services.rag_pipeline.rag_pipeline import RagPipelineService
@@ -74,9 +71,7 @@ class PipelineTemplateDetailResponse(ResponseModel):
class CustomizedPipelineTemplatePayload(BaseModel):
name: str = Field(..., min_length=1, max_length=40)
description: str = Field(default="", max_length=400)
icon_info: dict[str, object] = Field(
default_factory=lambda: IconInfo(icon="").model_dump(),
)
icon_info: dict[str, object] = Field(default_factory=lambda: IconInfo(icon="").model_dump())
register_schema_models(
@@ -101,11 +96,10 @@ class PipelineTemplateListApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_tenant_id
def get(self, current_tenant_id: str) -> JsonResponseWithStatus:
def get(self) -> JsonResponseWithStatus:
query = PipelineTemplateListQuery.model_validate(request.args.to_dict(flat=True))
# get pipeline templates
pipeline_templates = RagPipelineService.get_pipeline_templates(query.type, query.language, current_tenant_id)
pipeline_templates = RagPipelineService.get_pipeline_templates(query.type, query.language)
return dump_response(PipelineTemplateListResponse, pipeline_templates), 200
@@ -134,14 +128,10 @@ class CustomizedPipelineTemplateApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account, template_id: str) -> tuple[str, int]:
def patch(self, template_id: str) -> tuple[str, int]:
payload = CustomizedPipelineTemplatePayload.model_validate(console_ns.payload or {})
pipeline_template_info = PipelineTemplateInfoEntity.model_validate(payload.model_dump())
RagPipelineService.update_customized_pipeline_template(
template_id, pipeline_template_info, current_user, current_tenant_id
)
RagPipelineService.update_customized_pipeline_template(template_id, pipeline_template_info)
return "", 204
@console_ns.response(204, "Pipeline template deleted")
@@ -149,9 +139,8 @@ class CustomizedPipelineTemplateApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_tenant_id
def delete(self, current_tenant_id: str, template_id: str) -> tuple[str, int]:
RagPipelineService.delete_customized_pipeline_template(template_id, current_tenant_id)
def delete(self, template_id: str) -> tuple[str, int]:
RagPipelineService.delete_customized_pipeline_template(template_id)
return "", 204
@setup_required
@@ -179,12 +168,8 @@ class PublishCustomizedPipelineTemplateApi(Resource):
@account_initialization_required
@enterprise_license_required
@knowledge_pipeline_publish_enabled
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, pipeline_id: str) -> tuple[str, int]:
def post(self, pipeline_id: str) -> tuple[str, int]:
payload = CustomizedPipelineTemplatePayload.model_validate(console_ns.payload or {})
rag_pipeline_service = RagPipelineService()
rag_pipeline_service.publish_customized_pipeline_template(
pipeline_id, payload.model_dump(), current_user, current_tenant_id
)
rag_pipeline_service.publish_customized_pipeline_template(pipeline_id, payload.model_dump())
return "", 204
@@ -10,21 +10,20 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
DraftWorkflowNotExist,
)
from controllers.console.app.workflow_draft_variable import (
_WORKFLOW_DRAFT_VARIABLE_FIELDS, # type: ignore[private-usage]
EnvironmentVariableListResponse,
workflow_draft_variable_list_model,
workflow_draft_variable_list_without_value_model,
workflow_draft_variable_model,
)
from controllers.console.datasets.wraps import get_rag_pipeline
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
from controllers.web.error import InvalidArgumentError, NotFoundError
from core.app.file_access import DatabaseFileAccessController
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db
@@ -41,9 +40,14 @@ logger = logging.getLogger(__name__)
_file_access_controller = DatabaseFileAccessController()
class PaginationQuery(BaseModel):
page: int = Field(default=1, ge=1, le=100_000)
limit: int = Field(default=20, ge=1, le=100)
def _create_pagination_parser():
class PaginationQuery(BaseModel):
page: int = Field(default=1, ge=1, le=100_000)
limit: int = Field(default=20, ge=1, le=100)
register_schema_models(console_ns, PaginationQuery)
return PaginationQuery
class WorkflowDraftVariablePatchPayload(BaseModel):
@@ -51,7 +55,7 @@ class WorkflowDraftVariablePatchPayload(BaseModel):
value: Any | None = None
register_schema_models(console_ns, PaginationQuery, WorkflowDraftVariablePatchPayload)
register_schema_models(console_ns, WorkflowDraftVariablePatchPayload)
def _api_prerequisite[T, **P, R](
@@ -83,19 +87,14 @@ def _api_prerequisite[T, **P, R](
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/variables")
class RagPipelineVariableCollectionApi(Resource):
@console_ns.doc(params=query_params_from_model(PaginationQuery))
@console_ns.response(
200,
"Workflow variables retrieved successfully",
workflow_draft_variable_list_without_value_model,
)
@_api_prerequisite
@marshal_with(workflow_draft_variable_list_without_value_model)
def get(self, current_user: Account, pipeline: Pipeline):
"""
Get draft workflow
"""
query = PaginationQuery.model_validate(request.args.to_dict())
pagination = _create_pagination_parser()
query = pagination.model_validate(request.args.to_dict())
# fetch draft workflow by app_model
rag_pipeline_service = RagPipelineService()
@@ -117,7 +116,6 @@ class RagPipelineVariableCollectionApi(Resource):
return workflow_vars
@console_ns.response(204, "Workflow variables deleted successfully")
@_api_prerequisite
def delete(self, current_user: Account, pipeline: Pipeline):
draft_var_srv = WorkflowDraftVariableService(
@@ -148,7 +146,6 @@ def validate_node_id(node_id: str) -> NoReturn | None:
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/nodes/<string:node_id>/variables")
class RagPipelineNodeVariableCollectionApi(Resource):
@console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model)
@_api_prerequisite
@marshal_with(workflow_draft_variable_list_model)
def get(self, current_user: Account, pipeline: Pipeline, node_id: str):
@@ -161,7 +158,6 @@ class RagPipelineNodeVariableCollectionApi(Resource):
return node_vars
@console_ns.response(204, "Node variables deleted successfully")
@_api_prerequisite
def delete(self, current_user: Account, pipeline: Pipeline, node_id: str):
validate_node_id(node_id)
@@ -176,7 +172,6 @@ class RagPipelineVariableApi(Resource):
_PATCH_NAME_FIELD = "name"
_PATCH_VALUE_FIELD = "value"
@console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model)
@_api_prerequisite
@marshal_with(workflow_draft_variable_model)
def get(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID):
@@ -191,7 +186,6 @@ class RagPipelineVariableApi(Resource):
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
return variable
@console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model)
@_api_prerequisite
@marshal_with(workflow_draft_variable_model)
@console_ns.expect(console_ns.models[WorkflowDraftVariablePatchPayload.__name__])
@@ -263,7 +257,6 @@ class RagPipelineVariableApi(Resource):
db.session.commit()
return variable
@console_ns.response(204, "Variable deleted successfully")
@_api_prerequisite
def delete(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID):
draft_var_srv = WorkflowDraftVariableService(
@@ -282,8 +275,6 @@ class RagPipelineVariableApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/variables/<uuid:variable_id>/reset")
class RagPipelineVariableResetApi(Resource):
@console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model)
@console_ns.response(204, "Variable reset (no content)")
@_api_prerequisite
def put(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID):
draft_var_srv = WorkflowDraftVariableService(
@@ -327,7 +318,6 @@ def _get_variable_list(pipeline: Pipeline, node_id: str, current_user_id: str) -
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/system-variables")
class RagPipelineSystemVariableCollectionApi(Resource):
@console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model)
@_api_prerequisite
@marshal_with(workflow_draft_variable_list_model)
def get(self, current_user: Account, pipeline: Pipeline):
@@ -336,11 +326,6 @@ class RagPipelineSystemVariableCollectionApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/environment-variables")
class RagPipelineEnvironmentVariableCollectionApi(Resource):
@console_ns.response(
200,
"Environment variables retrieved successfully",
console_ns.models[EnvironmentVariableListResponse.__name__],
)
@_api_prerequisite
def get(self, _current_user: Account, pipeline: Pipeline):
"""
@@ -5,14 +5,14 @@ from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, ValidationError
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
ConversationCompletedError,
@@ -21,8 +21,6 @@ from controllers.console.app.error import (
)
from controllers.console.app.workflow import (
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE,
DefaultBlockConfigResponse,
DefaultBlockConfigsResponse,
WorkflowPaginationResponse,
WorkflowResponse,
)
@@ -31,8 +29,6 @@ from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.base_app_queue_manager import AppQueueManager
@@ -50,7 +46,7 @@ from fields.workflow_run_fields import (
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs import helper
from libs.helper import TimestampField, UUIDStrOrEmpty, dump_response
from libs.login import login_required
from libs.login import current_account_with_tenant, current_user, login_required
from models import Account
from models.dataset import Pipeline
from models.model import EndUser
@@ -69,14 +65,14 @@ logger = logging.getLogger(__name__)
class DraftWorkflowSyncPayload(BaseModel):
graph: dict[str, Any]
hash: str | None = None
environment_variables: list[dict[str, Any]] | None = Field(default=None)
conversation_variables: list[dict[str, Any]] | None = Field(default=None)
rag_pipeline_variables: list[dict[str, Any]] | None = Field(default=None)
features: dict[str, Any] | None = Field(default=None)
environment_variables: list[dict[str, Any]] | None = None
conversation_variables: list[dict[str, Any]] | None = None
rag_pipeline_variables: list[dict[str, Any]] | None = None
features: dict[str, Any] | None = None
class NodeRunPayload(BaseModel):
inputs: dict[str, Any] | None = Field(default=None)
inputs: dict[str, Any] | None = None
class NodeRunRequiredPayload(BaseModel):
@@ -133,14 +129,6 @@ class RagPipelineWorkflowPublishResponse(ResponseModel):
created_at: int
class RagPipelineOpaqueResponse(RootModel[Any]):
root: Any
class RagPipelineStepParametersResponse(ResponseModel):
variables: Any
register_schema_models(
console_ns,
DraftWorkflowSyncPayload,
@@ -159,10 +147,6 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
DefaultBlockConfigResponse,
DefaultBlockConfigsResponse,
RagPipelineOpaqueResponse,
RagPipelineStepParametersResponse,
RagPipelineWorkflowPublishResponse,
RagPipelineWorkflowSyncResponse,
SimpleResultResponse,
@@ -203,15 +187,16 @@ class DraftRagPipelineApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_rag_pipeline
@edit_permission_required
@console_ns.expect(console_ns.models[DraftWorkflowSyncPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[RagPipelineWorkflowSyncResponse.__name__])
def post(self, current_user: Account, pipeline: Pipeline):
def post(self, pipeline: Pipeline):
"""
Sync draft workflow
"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
content_type = request.headers.get("Content-Type", "")
if "application/json" in content_type:
@@ -259,17 +244,18 @@ class DraftRagPipelineApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/iteration/nodes/<string:node_id>/run")
class RagPipelineDraftRunIterationNodeApi(Resource):
@console_ns.expect(console_ns.models[NodeRunPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_rag_pipeline
@edit_permission_required
def post(self, current_user: Account, pipeline: Pipeline, node_id: str):
def post(self, pipeline: Pipeline, node_id: str):
"""
Run draft workflow iteration node
"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
payload = NodeRunPayload.model_validate(console_ns.payload or {})
args = payload.model_dump(exclude_none=True)
@@ -293,17 +279,18 @@ class RagPipelineDraftRunIterationNodeApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/loop/nodes/<string:node_id>/run")
class RagPipelineDraftRunLoopNodeApi(Resource):
@console_ns.expect(console_ns.models[NodeRunPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline, node_id: str):
def post(self, pipeline: Pipeline, node_id: str):
"""
Run draft workflow loop node
"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
payload = NodeRunPayload.model_validate(console_ns.payload or {})
args = payload.model_dump(exclude_none=True)
@@ -327,17 +314,18 @@ class RagPipelineDraftRunLoopNodeApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/run")
class DraftRagPipelineRunApi(Resource):
@console_ns.expect(console_ns.models[DraftWorkflowRunPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline):
def post(self, pipeline: Pipeline):
"""
Run draft workflow
"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
payload = DraftWorkflowRunPayload.model_validate(console_ns.payload or {})
args = payload.model_dump()
@@ -358,17 +346,18 @@ class DraftRagPipelineRunApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/published/run")
class PublishedRagPipelineRunApi(Resource):
@console_ns.expect(console_ns.models[PublishedWorkflowRunPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline):
def post(self, pipeline: Pipeline):
"""
Run published workflow
"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
payload = PublishedWorkflowRunPayload.model_validate(console_ns.payload or {})
args = payload.model_dump(exclude_none=True)
streaming = payload.response_mode == "streaming"
@@ -390,17 +379,18 @@ class PublishedRagPipelineRunApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/published/datasource/nodes/<string:node_id>/run")
class RagPipelinePublishedDatasourceNodeRunApi(Resource):
@console_ns.expect(console_ns.models[DatasourceNodeRunPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline, node_id: str):
def post(self, pipeline: Pipeline, node_id: str):
"""
Run rag pipeline datasource
"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
payload = DatasourceNodeRunPayload.model_validate(console_ns.payload or {})
rag_pipeline_service = RagPipelineService()
@@ -422,17 +412,18 @@ class RagPipelinePublishedDatasourceNodeRunApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/datasource/nodes/<string:node_id>/run")
class RagPipelineDraftDatasourceNodeRunApi(Resource):
@console_ns.expect(console_ns.models[DatasourceNodeRunPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@setup_required
@login_required
@edit_permission_required
@account_initialization_required
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline, node_id: str):
def post(self, pipeline: Pipeline, node_id: str):
"""
Run rag pipeline datasource
"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
payload = DatasourceNodeRunPayload.model_validate(console_ns.payload or {})
rag_pipeline_service = RagPipelineService()
@@ -463,12 +454,14 @@ class RagPipelineDraftNodeRunApi(Resource):
@login_required
@edit_permission_required
@account_initialization_required
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline, node_id: str):
def post(self, pipeline: Pipeline, node_id: str):
"""
Run draft workflow node
"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
payload = NodeRunRequiredPayload.model_validate(console_ns.payload or {})
inputs = payload.inputs
@@ -492,12 +485,14 @@ class RagPipelineTaskStopApi(Resource):
@login_required
@edit_permission_required
@account_initialization_required
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline, task_id: str):
def post(self, pipeline: Pipeline, task_id: str):
"""
Stop workflow task
"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
AppQueueManager.set_stop_flag(task_id, InvokeFrom.DEBUGGER, current_user.id)
return {"result": "success"}
@@ -537,12 +532,13 @@ class PublishedRagPipelineApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline):
def post(self, pipeline: Pipeline):
"""
Publish workflow
"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
rag_pipeline_service = RagPipelineService()
workflow = rag_pipeline_service.publish_workflow(
session=db.session, # type: ignore[reportArgumentType,arg-type]
@@ -562,11 +558,6 @@ class PublishedRagPipelineApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/default-workflow-block-configs")
class DefaultRagPipelineBlockConfigsApi(Resource):
@console_ns.response(
200,
"Default block configs retrieved successfully",
console_ns.models[DefaultBlockConfigsResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -583,12 +574,6 @@ class DefaultRagPipelineBlockConfigsApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/default-workflow-block-configs/<string:block_type>")
class DefaultRagPipelineBlockConfigApi(Resource):
@console_ns.doc(params=query_params_from_model(DefaultBlockConfigQuery))
@console_ns.response(
200,
"Default block config retrieved successfully",
console_ns.models[DefaultBlockConfigResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -614,7 +599,6 @@ class DefaultRagPipelineBlockConfigApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows")
class PublishedAllRagPipelineApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkflowListQuery))
@console_ns.response(
200,
"Published workflows retrieved successfully",
@@ -625,12 +609,13 @@ class PublishedAllRagPipelineApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@get_rag_pipeline
def get(self, current_user: Account, pipeline: Pipeline):
def get(self, pipeline: Pipeline):
"""
Get published workflows
"""
current_user, _ = current_account_with_tenant()
query = WorkflowListQuery.model_validate(request.args.to_dict())
page = query.page
@@ -670,9 +655,9 @@ class RagPipelineDraftWorkflowRestoreApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline, workflow_id: str):
def post(self, pipeline: Pipeline, workflow_id: str):
current_user, _ = current_account_with_tenant()
rag_pipeline_service = RagPipelineService()
try:
@@ -704,13 +689,14 @@ class RagPipelineByIdApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@get_rag_pipeline
@console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__])
def patch(self, current_user: Account, pipeline: Pipeline, workflow_id: str):
def patch(self, pipeline: Pipeline, workflow_id: str):
"""
Update workflow attributes
"""
# Check permission
current_user, _ = current_account_with_tenant()
payload = WorkflowUpdatePayload.model_validate(console_ns.payload or {})
update_data = payload.model_dump(exclude_unset=True)
@@ -768,8 +754,6 @@ class RagPipelineByIdApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/published/processing/parameters")
class PublishedRagPipelineSecondStepApi(Resource):
@console_ns.doc(params=query_params_from_model(NodeIdQuery))
@console_ns.response(200, "Success", console_ns.models[RagPipelineStepParametersResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -790,8 +774,6 @@ class PublishedRagPipelineSecondStepApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/published/pre-processing/parameters")
class PublishedRagPipelineFirstStepApi(Resource):
@console_ns.doc(params=query_params_from_model(NodeIdQuery))
@console_ns.response(200, "Success", console_ns.models[RagPipelineStepParametersResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -812,8 +794,6 @@ class PublishedRagPipelineFirstStepApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/pre-processing/parameters")
class DraftRagPipelineFirstStepApi(Resource):
@console_ns.doc(params=query_params_from_model(NodeIdQuery))
@console_ns.response(200, "Success", console_ns.models[RagPipelineStepParametersResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -834,8 +814,6 @@ class DraftRagPipelineFirstStepApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/processing/parameters")
class DraftRagPipelineSecondStepApi(Resource):
@console_ns.doc(params=query_params_from_model(NodeIdQuery))
@console_ns.response(200, "Success", console_ns.models[RagPipelineStepParametersResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -857,7 +835,6 @@ class DraftRagPipelineSecondStepApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflow-runs")
class RagPipelineWorkflowRunListApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkflowRunQuery))
@console_ns.response(
200,
"Workflow runs retrieved successfully",
@@ -878,7 +855,7 @@ class RagPipelineWorkflowRunListApi(Resource):
}
)
args = {
"last_id": query.last_id or None,
"last_id": str(query.last_id) if query.last_id else None,
"limit": query.limit,
}
@@ -924,8 +901,7 @@ class RagPipelineWorkflowRunNodeExecutionListApi(Resource):
@login_required
@account_initialization_required
@get_rag_pipeline
@with_current_user
def get(self, current_user: Account, pipeline: Pipeline, run_id: UUID):
def get(self, pipeline: Pipeline, run_id: UUID):
"""
Get workflow run node execution list
"""
@@ -946,12 +922,11 @@ class RagPipelineWorkflowRunNodeExecutionListApi(Resource):
@console_ns.route("/rag/pipelines/datasource-plugins")
class DatasourceListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
def get(self):
_, current_tenant_id = current_account_with_tenant()
return jsonable_encoder(RagPipelineManageService.list_rag_pipeline_datasources(current_tenant_id))
@@ -983,12 +958,12 @@ class RagPipelineWorkflowLastRunApi(Resource):
@console_ns.route("/rag/pipelines/transform/datasets/<uuid:dataset_id>")
class RagPipelineTransformApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
def post(self, current_user: Account, dataset_id: UUID):
def post(self, dataset_id: UUID):
current_user, _ = current_account_with_tenant()
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
raise Forbidden()
@@ -1009,13 +984,13 @@ class RagPipelineDatasourceVariableApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_rag_pipeline
@edit_permission_required
def post(self, current_user: Account, pipeline: Pipeline):
def post(self, pipeline: Pipeline):
"""
Set datasource variables
"""
current_user, _ = current_account_with_tenant()
args = DatasourceVariablesPayload.model_validate(console_ns.payload or {}).model_dump()
rag_pipeline_service = RagPipelineService()
@@ -1031,16 +1006,12 @@ class RagPipelineDatasourceVariableApi(Resource):
@console_ns.route("/rag/pipelines/recommended-plugins")
class RagPipelineRecommendedPluginApi(Resource):
@console_ns.doc(params=query_params_from_model(RagPipelineRecommendedPluginQuery))
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
def get(self):
query = RagPipelineRecommendedPluginQuery.model_validate(request.args.to_dict())
rag_pipeline_service = RagPipelineService()
recommended_plugins = rag_pipeline_service.get_recommended_plugins(query.type, current_user, current_tenant_id)
recommended_plugins = rag_pipeline_service.get_recommended_plugins(query.type)
return recommended_plugins
+6 -11
View File
@@ -1,10 +1,10 @@
from typing import Any, Literal
from typing import Literal
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, RootModel
from pydantic import BaseModel
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.datasets.error import WebsiteCrawlError
from controllers.console.wraps import account_initialization_required, setup_required
@@ -22,12 +22,7 @@ class WebsiteCrawlStatusQuery(BaseModel):
provider: Literal["firecrawl", "watercrawl", "jinareader"]
class WebsiteCrawlResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
register_schema_models(console_ns, WebsiteCrawlPayload, WebsiteCrawlStatusQuery)
register_response_schema_models(console_ns, WebsiteCrawlResponse)
@console_ns.route("/website/crawl")
@@ -35,7 +30,7 @@ class WebsiteCrawlApi(Resource):
@console_ns.doc("crawl_website")
@console_ns.doc(description="Crawl website content")
@console_ns.expect(console_ns.models[WebsiteCrawlPayload.__name__])
@console_ns.response(200, "Website crawl initiated successfully", console_ns.models[WebsiteCrawlResponse.__name__])
@console_ns.response(200, "Website crawl initiated successfully")
@console_ns.response(400, "Invalid crawl parameters")
@setup_required
@login_required
@@ -62,8 +57,8 @@ class WebsiteCrawlStatusApi(Resource):
@console_ns.doc("get_crawl_status")
@console_ns.doc(description="Get website crawl status")
@console_ns.doc(params={"job_id": "Crawl job ID", "provider": "Crawl provider (firecrawl/watercrawl/jinareader)"})
@console_ns.doc(params=query_params_from_model(WebsiteCrawlStatusQuery))
@console_ns.response(200, "Crawl status retrieved successfully", console_ns.models[WebsiteCrawlResponse.__name__])
@console_ns.expect(console_ns.models[WebsiteCrawlStatusQuery.__name__])
@console_ns.response(200, "Crawl status retrieved successfully")
@console_ns.response(404, "Crawl job not found")
@console_ns.response(400, "Invalid provider")
@setup_required
+1 -5
View File
@@ -5,8 +5,7 @@ from werkzeug.exceptions import InternalServerError
import services
from controllers.common.controller_schemas import TextToAudioPayload
from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse
from controllers.common.schema import register_response_schema_models, register_schema_model
from controllers.common.schema import register_schema_model
from controllers.console.app.error import (
AppUnavailableError,
AudioTooLargeError,
@@ -35,7 +34,6 @@ from .. import console_ns
logger = logging.getLogger(__name__)
register_schema_model(console_ns, TextToAudioPayload)
register_response_schema_models(console_ns, AudioBinaryResponse, AudioTranscriptResponse)
@console_ns.route(
@@ -43,7 +41,6 @@ register_response_schema_models(console_ns, AudioBinaryResponse, AudioTranscript
endpoint="installed_app_audio",
)
class ChatAudioApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
def post(self, installed_app: InstalledApp):
app_model = installed_app.app
if app_model is None:
@@ -87,7 +84,6 @@ class ChatAudioApi(InstalledAppResource):
)
class ChatTextApi(InstalledAppResource):
@console_ns.expect(console_ns.models[TextToAudioPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
def post(self, installed_app: InstalledApp):
app_model = installed_app.app
if app_model is None:
+1 -28
View File
@@ -1,44 +1,17 @@
from typing import Any, cast
from flask import request
from flask_restx import Namespace, Resource
from pydantic import BaseModel, Field, RootModel
from flask_restx import Resource
from sqlalchemy import select
from controllers.common.schema import query_params_from_model, register_response_schema_models
from controllers.console import api
from controllers.console.explore.wraps import explore_banner_enabled
from extensions.ext_database import db
from fields.base import ResponseModel
from models.enums import BannerStatus
from models.model import ExporleBanner
class BannerListQuery(BaseModel):
language: str = Field(default="en-US", description="Banner language")
class BannerResponse(ResponseModel):
id: str
content: Any
link: str | None = None
sort: int
status: str
created_at: str | None = None
class BannerListResponse(RootModel[list[BannerResponse]]):
root: list[BannerResponse]
register_response_schema_models(cast(Namespace, api), BannerListResponse)
class BannerApi(Resource):
"""Resource for banner list."""
@api.doc(params=query_params_from_model(BannerListQuery))
@api.response(200, "Success", api.models[BannerListResponse.__name__])
@explore_banner_enabled
def get(self):
"""Get banner list."""
+12 -11
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import InternalServerError, NotFound
import services
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.app.error import (
AppUnavailableError,
@@ -18,7 +18,7 @@ from controllers.console.app.error import (
)
from controllers.console.explore.error import NotChatAppError, NotCompletionAppError
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import with_current_user, with_current_user_id
from controllers.console.wraps import with_current_user_id
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
@@ -30,6 +30,7 @@ from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.datetime_utils import naive_utc_now
from libs.login import current_user
from models import Account
from models.model import AppMode, InstalledApp
from services.app_generate_service import AppGenerateService
@@ -44,7 +45,7 @@ logger = logging.getLogger(__name__)
class CompletionMessageExplorePayload(BaseModel):
inputs: dict[str, Any]
query: str = ""
files: list[dict[str, Any]] | None = Field(default=None)
files: list[dict[str, Any]] | None = None
response_mode: Literal["blocking", "streaming"] | None = None
retriever_from: str = Field(default="explore_app")
@@ -52,7 +53,7 @@ class CompletionMessageExplorePayload(BaseModel):
class ChatMessagePayload(BaseModel):
inputs: dict[str, Any]
query: str
files: list[dict[str, Any]] | None = Field(default=None)
files: list[dict[str, Any]] | None = None
conversation_id: str | None = None
parent_message_id: str | None = None
retriever_from: str = Field(default="explore_app")
@@ -73,7 +74,7 @@ class ChatMessagePayload(BaseModel):
register_schema_models(console_ns, CompletionMessageExplorePayload, ChatMessagePayload)
register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse)
register_response_schema_models(console_ns, SimpleResultResponse)
# define completion api for user
@@ -83,9 +84,7 @@ register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultRe
)
class CompletionApi(InstalledAppResource):
@console_ns.expect(console_ns.models[CompletionMessageExplorePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@with_current_user
def post(self, current_user: Account, installed_app: InstalledApp):
def post(self, installed_app: InstalledApp):
app_model = installed_app.app
if app_model is None:
raise AppUnavailableError()
@@ -102,6 +101,8 @@ class CompletionApi(InstalledAppResource):
db.session.commit()
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=streaming
)
@@ -159,9 +160,7 @@ class CompletionStopApi(InstalledAppResource):
)
class ChatApi(InstalledAppResource):
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@with_current_user
def post(self, current_user: Account, installed_app: InstalledApp):
def post(self, installed_app: InstalledApp):
app_model = installed_app.app
if app_model is None:
raise AppUnavailableError()
@@ -178,6 +177,8 @@ class ChatApi(InstalledAppResource):
db.session.commit()
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True
)
+20 -22
View File
@@ -7,11 +7,10 @@ from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import ConversationRenamePayload
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.app.error import AppUnavailableError
from controllers.console.explore.error import NotChatAppError
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import with_current_user
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from fields.conversation_fields import (
@@ -20,6 +19,7 @@ from fields.conversation_fields import (
SimpleConversation,
)
from libs.helper import UUIDStrOrEmpty
from libs.login import current_user
from models import Account
from models.model import AppMode, InstalledApp
from services.conversation_service import ConversationService
@@ -36,12 +36,7 @@ class ConversationListQuery(BaseModel):
register_schema_models(console_ns, ConversationListQuery, ConversationRenamePayload)
register_response_schema_models(
console_ns,
ConversationInfiniteScrollPagination,
ResultResponse,
SimpleConversation,
)
register_response_schema_models(console_ns, ResultResponse)
@console_ns.route(
@@ -49,10 +44,8 @@ register_response_schema_models(
endpoint="installed_app_conversations",
)
class ConversationListApi(InstalledAppResource):
@console_ns.doc(params=query_params_from_model(ConversationListQuery))
@console_ns.response(200, "Success", console_ns.models[ConversationInfiniteScrollPagination.__name__])
@with_current_user
def get(self, current_user: Account, installed_app: InstalledApp):
@console_ns.expect(console_ns.models[ConversationListQuery.__name__])
def get(self, installed_app: InstalledApp):
app_model = installed_app.app
if app_model is None:
raise AppUnavailableError()
@@ -73,12 +66,14 @@ class ConversationListApi(InstalledAppResource):
args = ConversationListQuery.model_validate(raw_args)
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
with sessionmaker(db.engine).begin() as session:
pagination = WebConversationService.pagination_by_last_id(
session=session,
app_model=app_model,
user=current_user,
last_id=args.last_id or None,
last_id=str(args.last_id) if args.last_id else None,
limit=args.limit,
invoke_from=InvokeFrom.EXPLORE,
pinned=args.pinned,
@@ -100,8 +95,7 @@ class ConversationListApi(InstalledAppResource):
)
class ConversationApi(InstalledAppResource):
@console_ns.response(204, "Conversation deleted successfully")
@with_current_user
def delete(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
def delete(self, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app
if app_model is None:
raise AppUnavailableError()
@@ -111,6 +105,8 @@ class ConversationApi(InstalledAppResource):
conversation_id = str(c_id)
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
ConversationService.delete(app_model, conversation_id, current_user)
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -124,9 +120,7 @@ class ConversationApi(InstalledAppResource):
)
class ConversationRenameApi(InstalledAppResource):
@console_ns.expect(console_ns.models[ConversationRenamePayload.__name__])
@console_ns.response(200, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__])
@with_current_user
def post(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
def post(self, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app
if app_model is None:
raise AppUnavailableError()
@@ -139,6 +133,8 @@ class ConversationRenameApi(InstalledAppResource):
payload = ConversationRenamePayload.model_validate(console_ns.payload or {})
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
conversation = ConversationService.rename(
app_model, conversation_id, current_user, payload.name, payload.auto_generate
)
@@ -157,8 +153,7 @@ class ConversationRenameApi(InstalledAppResource):
)
class ConversationPinApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
@with_current_user
def patch(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
def patch(self, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app
if app_model is None:
raise AppUnavailableError()
@@ -169,6 +164,8 @@ class ConversationPinApi(InstalledAppResource):
conversation_id = str(c_id)
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
WebConversationService.pin(app_model, conversation_id, current_user)
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -182,8 +179,7 @@ class ConversationPinApi(InstalledAppResource):
)
class ConversationUnPinApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
@with_current_user
def patch(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
def patch(self, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app
if app_model is None:
raise AppUnavailableError()
@@ -192,6 +188,8 @@ class ConversationUnPinApi(InstalledAppResource):
raise NotChatAppError()
conversation_id = str(c_id)
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
WebConversationService.unpin(app_model, conversation_id, current_user)
return ResultResponse(result="success").model_dump(mode="json")
@@ -5,11 +5,11 @@ from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field, field_validator
from sqlalchemy import and_, exists, or_, select
from sqlalchemy import and_, select
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from controllers.common.fields import SimpleMessageResponse, SimpleResultMessageResponse
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.explore.wraps import InstalledAppResource
from controllers.console.wraps import (
@@ -24,8 +24,8 @@ from graphon.file import helpers as file_helpers
from libs.datetime_utils import naive_utc_now
from libs.helper import to_timestamp
from libs.login import login_required
from models import Account, App, AppModelConfig, InstalledApp, RecommendedApp, Workflow
from models.model import AppMode, IconType
from models import Account, App, InstalledApp, RecommendedApp
from models.model import IconType
from services.account_service import TenantService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
@@ -61,24 +61,6 @@ def _safe_primitive(value: Any) -> Any:
return None
def _published_app_filter():
"""Return the SQL predicate for installed-app web API availability.
The installed-app parameters endpoint reads the published workflow for
workflow-style apps and the published app model config for easy UI apps.
Keep the list endpoint aligned in SQL so it does not return entries that
will immediately fail with app_unavailable when opened.
"""
workflow_app_modes = (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW)
has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id))
has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id))
return or_(
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
)
class InstalledAppInfoResponse(ResponseModel):
id: str
name: str | None = None
@@ -153,28 +135,37 @@ register_response_schema_models(console_ns, SimpleMessageResponse, SimpleResultM
class InstalledAppsListApi(Resource):
@login_required
@account_initialization_required
@console_ns.doc(params=query_params_from_model(InstalledAppsListQuery))
@console_ns.response(200, "Success", console_ns.models[InstalledAppListResponse.__name__])
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
query = InstalledAppsListQuery.model_validate(request.args.to_dict())
stmt = (
select(InstalledApp, App)
.join(App, App.id == InstalledApp.app_id)
.where(InstalledApp.tenant_id == current_tenant_id, _published_app_filter())
)
if query.app_id:
stmt = stmt.where(InstalledApp.app_id == query.app_id)
installed_apps = db.session.execute(stmt).all()
installed_apps = db.session.scalars(
select(InstalledApp).where(
and_(InstalledApp.tenant_id == current_tenant_id, InstalledApp.app_id == query.app_id)
)
).all()
else:
installed_apps = db.session.scalars(
select(InstalledApp).where(InstalledApp.tenant_id == current_tenant_id)
).all()
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant)
app_ids = [installed_app.app_id for installed_app in installed_apps]
apps = db.session.scalars(select(App).where(App.id.in_(app_ids))).all() if app_ids else []
apps_by_id = {app.id: app for app in apps}
installed_app_list: list[dict[str, Any]] = []
for installed_app, app_model in installed_apps:
for installed_app in installed_apps:
app_model = apps_by_id.get(installed_app.app_id)
if app_model is None:
continue
installed_app_list.append(
{
"id": installed_app.id,
@@ -235,7 +226,6 @@ class InstalledAppsListApi(Resource):
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("apps")
@console_ns.expect(console_ns.models[InstalledAppCreatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleMessageResponse.__name__])
@with_current_tenant_id
def post(self, current_tenant_id: str):
@@ -297,7 +287,6 @@ class InstalledAppApi(InstalledAppResource):
return "", 204
@console_ns.response(200, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
@console_ns.expect(console_ns.models[InstalledAppUpdatePayload.__name__])
def patch(self, installed_app: InstalledApp):
payload = InstalledAppUpdatePayload.model_validate(console_ns.payload or {})
+4 -13
View File
@@ -7,8 +7,7 @@ from pydantic import BaseModel, TypeAdapter
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
from controllers.common.fields import GeneratedAppResponse
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.app.error import (
AppMoreLikeThisDisabledError,
AppUnavailableError,
@@ -53,13 +52,7 @@ class MoreLikeThisQuery(BaseModel):
register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, MoreLikeThisQuery)
register_response_schema_models(
console_ns,
GeneratedAppResponse,
MessageInfiniteScrollPagination,
ResultResponse,
SuggestedQuestionsResponse,
)
register_response_schema_models(console_ns, ResultResponse, SuggestedQuestionsResponse)
@console_ns.route(
@@ -67,8 +60,7 @@ register_response_schema_models(
endpoint="installed_app_messages",
)
class MessageListApi(InstalledAppResource):
@console_ns.doc(params=query_params_from_model(MessageListQuery))
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPagination.__name__])
@console_ns.expect(console_ns.models[MessageListQuery.__name__])
@with_current_user
def get(self, current_user: Account, installed_app: InstalledApp):
app_model = installed_app.app
@@ -137,8 +129,7 @@ class MessageFeedbackApi(InstalledAppResource):
endpoint="installed_app_more_like_this",
)
class MessageMoreLikeThisApi(InstalledAppResource):
@console_ns.doc(params=query_params_from_model(MoreLikeThisQuery))
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.expect(console_ns.models[MoreLikeThisQuery.__name__])
@with_current_user
def get(self, current_user: Account, installed_app: InstalledApp, message_id: UUID):
app_model = installed_app.app
@@ -1,9 +1,6 @@
from typing import Any, cast
from pydantic import BaseModel, Field
from controllers.common import fields
from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.app.error import AppUnavailableError
from controllers.console.explore.wraps import InstalledAppResource
@@ -12,18 +9,10 @@ from models.model import AppMode, InstalledApp
from services.app_service import AppService
class ExploreAppMetaResponse(BaseModel):
tool_icons: dict[str, Any] = Field(default_factory=dict)
register_response_schema_models(console_ns, fields.Parameters, ExploreAppMetaResponse)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/parameters", endpoint="installed_app_parameters")
class AppParameterApi(InstalledAppResource):
"""Resource for app variables."""
@console_ns.response(200, "Success", console_ns.models[fields.Parameters.__name__])
def get(self, installed_app: InstalledApp):
"""Retrieve app parameters."""
app_model = installed_app.app
@@ -53,7 +42,6 @@ class AppParameterApi(InstalledAppResource):
@console_ns.route("/installed-apps/<uuid:installed_app_id>/meta", endpoint="installed_app_meta")
class ExploreAppMetaApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ExploreAppMetaResponse.__name__])
def get(self, installed_app: InstalledApp):
"""Get app meta"""
app_model = installed_app.app
@@ -3,10 +3,10 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, computed_field, field_validator
from pydantic import BaseModel, Field, computed_field, field_validator
from constants.languages import languages
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, with_current_user
from fields.base import ResponseModel
@@ -65,8 +65,8 @@ class RecommendedAppListResponse(ResponseModel):
categories: list[str]
class RecommendedAppDetailResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class LearnDifyAppListResponse(ResponseModel):
recommended_apps: list[RecommendedAppResponse]
register_schema_models(
@@ -75,8 +75,16 @@ register_schema_models(
RecommendedAppInfoResponse,
RecommendedAppResponse,
RecommendedAppListResponse,
LearnDifyAppListResponse,
)
register_response_schema_models(console_ns, RecommendedAppDetailResponse)
def _resolve_language(language: str | None, user: Account) -> str:
if language and language in languages:
return language
if user.interface_language:
return user.interface_language
return languages[0]
@console_ns.route("/explore/apps")
@@ -89,13 +97,7 @@ class RecommendedAppListApi(Resource):
def get(self, current_user: Account):
# language args
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language = args.language
if language and language in languages:
language_prefix = language
elif current_user.interface_language:
language_prefix = current_user.interface_language
else:
language_prefix = languages[0]
language_prefix = _resolve_language(args.language, current_user)
return RecommendedAppListResponse.model_validate(
RecommendedAppService.get_recommended_apps_and_categories(language_prefix),
@@ -103,9 +105,25 @@ class RecommendedAppListApi(Resource):
).model_dump(mode="json")
@console_ns.route("/explore/apps/learn-dify")
class LearnDifyAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
@login_required
@account_initialization_required
@with_current_user
def get(self, current_user: Account):
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
return LearnDifyAppListResponse.model_validate(
RecommendedAppService.get_learn_dify_apps(language_prefix),
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/explore/apps/<uuid:app_id>")
class RecommendedAppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
@login_required
@account_initialization_required
def get(self, app_id: UUID):
@@ -5,7 +5,7 @@ from pydantic import TypeAdapter
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery
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.error import AppUnavailableError
from controllers.console.explore.error import NotCompletionAppError
@@ -19,13 +19,12 @@ from services.errors.message import MessageNotExistsError
from services.saved_message_service import SavedMessageService
register_schema_models(console_ns, SavedMessageListQuery, SavedMessageCreatePayload)
register_response_schema_models(console_ns, ResultResponse, SavedMessageInfiniteScrollPagination)
register_response_schema_models(console_ns, ResultResponse)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/saved-messages", endpoint="installed_app_saved_messages")
class SavedMessageListApi(InstalledAppResource):
@console_ns.doc(params=query_params_from_model(SavedMessageListQuery))
@console_ns.response(200, "Success", console_ns.models[SavedMessageInfiniteScrollPagination.__name__])
@console_ns.expect(console_ns.models[SavedMessageListQuery.__name__])
@with_current_user
def get(self, current_user: Account, installed_app: InstalledApp):
app_model = installed_app.app
+28 -79
View File
@@ -3,25 +3,14 @@ from typing import Any, Literal, cast
from flask import request
from flask_restx import Resource, fields, marshal, marshal_with
from pydantic import BaseModel, Field
from pydantic import BaseModel
from sqlalchemy import select
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
from controllers.common.fields import (
AudioBinaryResponse,
AudioTranscriptResponse,
GeneratedAppResponse,
SimpleResultResponse,
)
from controllers.common.fields import Parameters as ParametersResponse
from controllers.common.fields import Site as SiteResponse
from controllers.common.schema import (
get_or_create_model,
query_params_from_model,
register_response_schema_models,
register_schema_models,
)
from controllers.common.schema import get_or_create_model, register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
AppUnavailableError,
@@ -44,7 +33,6 @@ from controllers.console.explore.error import (
NotWorkflowAppError,
)
from controllers.console.explore.wraps import TrialAppResource, trial_feature_enable
from controllers.console.wraps import with_current_user
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.base_app_queue_manager import AppQueueManager
@@ -65,7 +53,6 @@ from fields.app_fields import (
)
from fields.dataset_fields import dataset_fields
from fields.member_fields import simple_account_fields
from fields.message_fields import SuggestedQuestionsResponse
from fields.workflow_fields import (
conversation_variable_fields,
pipeline_variable_fields,
@@ -76,6 +63,7 @@ from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.helper import uuid_value
from libs.login import current_user
from models import Account
from models.account import TenantStatus
from models.model import AppMode, Site
@@ -131,28 +119,16 @@ workflow_fields_copy["conversation_variables"] = fields.List(fields.Nested(conve
workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipeline_variable_model))
workflow_model = get_or_create_model("TrialWorkflow", workflow_fields_copy)
dataset_model = get_or_create_model("TrialDataset", dataset_fields)
dataset_list_model = get_or_create_model(
"TrialDatasetList",
{
"data": fields.List(fields.Nested(dataset_model)),
"has_more": fields.Boolean,
"limit": fields.Integer,
"total": fields.Integer,
"page": fields.Integer,
},
)
class WorkflowRunRequest(BaseModel):
inputs: dict
files: list | None = Field(default=None)
files: list | None = None
class ChatRequest(BaseModel):
inputs: dict
query: str
files: list | None = Field(default=None)
files: list | None = None
conversation_id: str | None = None
parent_message_id: str | None = None
retriever_from: str = "explore_app"
@@ -168,43 +144,18 @@ class TextToSpeechRequest(BaseModel):
class CompletionRequest(BaseModel):
inputs: dict
query: str = ""
files: list | None = Field(default=None)
files: list | None = None
response_mode: Literal["blocking", "streaming"] | None = None
retriever_from: str = "explore_app"
class TrialDatasetListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
limit: int = Field(default=20, ge=1, description="Number of items per page")
ids: list[str] = Field(default_factory=list, description="Dataset IDs")
register_schema_models(
console_ns,
WorkflowRunRequest,
ChatRequest,
TextToSpeechRequest,
CompletionRequest,
TrialDatasetListQuery,
)
register_response_schema_models(
console_ns,
ParametersResponse,
AudioBinaryResponse,
AudioTranscriptResponse,
GeneratedAppResponse,
SimpleResultResponse,
SiteResponse,
SuggestedQuestionsResponse,
)
register_schema_models(console_ns, WorkflowRunRequest, ChatRequest, TextToSpeechRequest, CompletionRequest)
class TrialAppWorkflowRunApi(TrialAppResource):
@trial_feature_enable
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@with_current_user
def post(self, current_user: Account, trial_app):
def post(self, trial_app):
"""
Run workflow
"""
@@ -217,6 +168,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
request_data = WorkflowRunRequest.model_validate(console_ns.payload)
args = request_data.model_dump()
assert current_user is not None
try:
app_id = app_model.id
user_id = current_user.id
@@ -243,7 +195,6 @@ class TrialAppWorkflowRunApi(TrialAppResource):
class TrialAppWorkflowTaskStopApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@trial_feature_enable
def post(self, trial_app, task_id: str):
"""
@@ -255,6 +206,7 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource):
app_mode = AppMode.value_of(app_model.mode)
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
assert current_user is not None
# Stop using both mechanisms for backward compatibility
# Legacy stop flag mechanism (without user check)
@@ -268,10 +220,8 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource):
class TrialChatApi(TrialAppResource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@trial_feature_enable
@with_current_user
def post(self, current_user: Account, trial_app):
def post(self, trial_app):
app_model = trial_app
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@@ -289,6 +239,9 @@ class TrialChatApi(TrialAppResource):
args["auto_generate_name"] = False
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
@@ -323,9 +276,7 @@ class TrialChatApi(TrialAppResource):
class TrialMessageSuggestedQuestionApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
@with_current_user
def get(self, current_user: Account, trial_app, message_id):
def get(self, trial_app, message_id):
app_model = trial_app
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@@ -334,6 +285,8 @@ class TrialMessageSuggestedQuestionApi(TrialAppResource):
message_id = str(message_id)
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, user=current_user, message_id=message_id, invoke_from=InvokeFrom.EXPLORE
)
@@ -359,15 +312,16 @@ class TrialMessageSuggestedQuestionApi(TrialAppResource):
class TrialChatAudioApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@trial_feature_enable
@with_current_user
def post(self, current_user: Account, trial_app):
def post(self, trial_app):
app_model = trial_app
file = request.files["file"]
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
@@ -403,10 +357,8 @@ class TrialChatAudioApi(TrialAppResource):
class TrialChatTextApi(TrialAppResource):
@console_ns.expect(console_ns.models[TextToSpeechRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
@trial_feature_enable
@with_current_user
def post(self, current_user: Account, trial_app):
def post(self, trial_app):
app_model = trial_app
try:
request_data = TextToSpeechRequest.model_validate(console_ns.payload)
@@ -414,6 +366,8 @@ class TrialChatTextApi(TrialAppResource):
message_id = request_data.message_id
text = request_data.text
voice = request_data.voice
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
# Get IDs before they might be detached from session
app_id = app_model.id
@@ -450,10 +404,8 @@ class TrialChatTextApi(TrialAppResource):
class TrialCompletionApi(TrialAppResource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@trial_feature_enable
@with_current_user
def post(self, current_user: Account, trial_app):
def post(self, trial_app):
app_model = trial_app
if app_model.mode != "completion":
raise NotCompletionAppError()
@@ -465,6 +417,9 @@ class TrialCompletionApi(TrialAppResource):
args["auto_generate_name"] = False
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
@@ -500,7 +455,6 @@ class TrialCompletionApi(TrialAppResource):
class TrialSitApi(Resource):
"""Resource for trial app sites."""
@console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__])
@get_app_model_with_trial(None)
def get(self, app_model):
"""Retrieve app site info.
@@ -522,7 +476,6 @@ class TrialSitApi(Resource):
class TrialAppParameterApi(Resource):
"""Resource for app variables."""
@console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__])
@get_app_model_with_trial(None)
def get(self, app_model):
"""Retrieve app parameters."""
@@ -551,7 +504,6 @@ class TrialAppParameterApi(Resource):
class AppApi(Resource):
@console_ns.response(200, "Success", app_detail_with_site_model)
@get_app_model_with_trial(None)
@marshal_with(app_detail_with_site_model)
def get(self, app_model):
@@ -564,7 +516,6 @@ class AppApi(Resource):
class AppWorkflowApi(Resource):
@console_ns.response(200, "Success", workflow_model)
@get_app_model_with_trial(None)
@marshal_with(workflow_model)
def get(self, app_model):
@@ -577,8 +528,6 @@ class AppWorkflowApi(Resource):
class DatasetListApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialDatasetListQuery))
@console_ns.response(200, "Success", dataset_list_model)
@get_app_model_with_trial(None)
def get(self, app_model):
page = request.args.get("page", default=1, type=int)
+2 -3
View File
@@ -3,7 +3,7 @@ import logging
from werkzeug.exceptions import InternalServerError
from controllers.common.controller_schemas import WorkflowRunPayload
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_model
from controllers.console.app.error import (
CompletionRequestError,
@@ -36,13 +36,12 @@ from .. import console_ns
logger = logging.getLogger(__name__)
register_schema_model(console_ns, WorkflowRunPayload)
register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/workflows/run")
class InstalledAppWorkflowRunApi(InstalledAppResource):
@console_ns.expect(console_ns.models[WorkflowRunPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@with_current_user
def post(self, current_user: Account, installed_app: InstalledApp):
"""
+4 -10
View File
@@ -14,7 +14,7 @@ from models.api_based_extension import APIBasedExtension
from services.api_based_extension_service import APIBasedExtensionService
from services.code_based_extension_service import CodeBasedExtensionService
from ..common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, query_params_from_model, register_schema_models
from ..common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_models
from . import console_ns
from .wraps import account_initialization_required, setup_required, with_current_tenant_id
@@ -60,16 +60,10 @@ class APIBasedExtensionResponse(ResponseModel):
return to_timestamp(value)
register_schema_models(
console_ns,
CodeBasedExtensionQuery,
APIBasedExtensionPayload,
CodeBasedExtensionResponse,
APIBasedExtensionResponse,
)
register_schema_models(console_ns, APIBasedExtensionPayload, CodeBasedExtensionResponse, APIBasedExtensionResponse)
console_ns.schema_model(
"APIBasedExtensionListResponse",
TypeAdapter(list[APIBasedExtensionResponse]).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
TypeAdapter(list[APIBasedExtensionResponse]).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
@@ -96,7 +90,7 @@ def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key:
class CodeBasedExtensionAPI(Resource):
@console_ns.doc("get_code_based_extension")
@console_ns.doc(description="Get code-based extension data by module name")
@console_ns.doc(params=query_params_from_model(CodeBasedExtensionQuery))
@console_ns.doc(params={"module": "Extension module name"})
@console_ns.response(
200,
"Success",
+11 -9
View File
@@ -1,9 +1,10 @@
from flask_restx import Resource
from werkzeug.exceptions import Unauthorized
from controllers.common.schema import register_response_schema_models
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import current_account_with_tenant_optional, login_required
from libs.login import current_user, login_required
from services.feature_service import (
FeatureModel,
FeatureService,
@@ -12,12 +13,7 @@ from services.feature_service import (
)
from . import console_ns
from .wraps import (
account_initialization_required,
cloud_utm_record,
setup_required,
with_current_tenant_id,
)
from .wraps import account_initialization_required, cloud_utm_record, setup_required, with_current_tenant_id
class TrialModelsResponse(ResponseModel):
@@ -137,6 +133,12 @@ class SystemFeatureApi(Resource):
Only non-sensitive configuration data should be returned by this endpoint.
"""
current_user, _ = current_account_with_tenant_optional()
is_authenticated = current_user is not None
# NOTE(QuantumGhost): ideally we should access `current_user.is_authenticated`
# without a try-catch. However, due to the implementation of user loader (the `load_user_from_request`
# in api/extensions/ext_login.py), accessing `current_user.is_authenticated` will
# raise `Unauthorized` exception if authentication token is not provided.
try:
is_authenticated = current_user.is_authenticated
except Unauthorized:
is_authenticated = False
return FeatureService.get_system_features(is_authenticated=is_authenticated).model_dump()
+2 -23
View File
@@ -5,18 +5,14 @@ Console/Studio Human Input Form APIs.
import json
import logging
from collections.abc import Generator
from typing import Any
from flask import Response, jsonify, request
from flask_restx import Resource
from pydantic import RootModel
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.common.fields import EventStreamResponse
from controllers.common.human_input import HumanInputFormSubmitPayload
from controllers.common.schema import 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 (
account_initialization_required,
@@ -25,6 +21,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from controllers.web.error import InvalidArgumentError, NotFoundError
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
@@ -43,22 +40,7 @@ from services.workflow_event_snapshot_service import build_workflow_event_stream
logger = logging.getLogger(__name__)
class ConsoleHumanInputFormDefinitionResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class ConsoleHumanInputFormSubmitResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
register_schema_models(console_ns, HumanInputFormSubmitPayload)
register_response_schema_models(
console_ns,
ConsoleHumanInputFormDefinitionResponse,
ConsoleHumanInputFormSubmitResponse,
EventStreamResponse,
)
def _jsonify_form_definition(form: Form) -> Response:
@@ -85,7 +67,6 @@ class ConsoleHumanInputFormApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[ConsoleHumanInputFormDefinitionResponse.__name__])
@with_current_tenant_id
def get(self, current_tenant_id: str, form_token: str):
"""
@@ -108,7 +89,6 @@ class ConsoleHumanInputFormApi(Resource):
@with_current_tenant_id
@model_validate(HumanInputFormSubmitPayload)
@console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[ConsoleHumanInputFormSubmitResponse.__name__])
def post(
self,
payload: HumanInputFormSubmitPayload,
@@ -156,7 +136,6 @@ class ConsoleHumanInputFormApi(Resource):
class ConsoleWorkflowEventsApi(Resource):
"""Console API for getting workflow execution events after resume."""
@console_ns.response(200, "SSE event stream", console_ns.models[EventStreamResponse.__name__])
@account_initialization_required
@login_required
@with_current_user
+2 -21
View File
@@ -6,7 +6,7 @@ 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.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
@@ -14,7 +14,6 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from fields.base import ResponseModel
from libs.login import login_required
from models import Account
from services.billing_service import BillingService
@@ -57,23 +56,7 @@ class DismissNotificationPayload(BaseModel):
notification_id: str = Field(...)
class NotificationItemResponse(ResponseModel):
notification_id: str | None = None
frequency: str | None = None
lang: str
title: str
subtitle: str
body: str
title_pic_url: str
class NotificationResponse(ResponseModel):
should_show: bool
notifications: list[NotificationItemResponse]
register_schema_models(console_ns, DismissNotificationPayload)
register_response_schema_models(console_ns, SimpleResultResponse, NotificationResponse)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/notification")
@@ -91,7 +74,6 @@ class NotificationApi(Resource):
401: "Unauthorized",
},
)
@console_ns.response(200, "Success", console_ns.models[NotificationResponse.__name__])
@setup_required
@login_required
@with_current_user
@@ -139,7 +121,6 @@ class NotificationDismissApi(Resource):
@with_current_user
@account_initialization_required
@only_edition_cloud
@console_ns.expect(console_ns.models[DismissNotificationPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self, current_user: Account):
payload = DismissNotificationPayload.model_validate(request.get_json())
+7 -7
View File
@@ -76,7 +76,7 @@ class CreateSnippetPayload(BaseModel):
description: str | None = Field(default=None, max_length=2000)
type: Literal["node", "group"] = "node"
icon_info: IconInfo | None = None
graph: dict[str, Any] | None = Field(default=None)
graph: dict[str, Any] | None = None
input_fields: list[InputFieldDefinition] | None = Field(default_factory=list)
@@ -97,7 +97,7 @@ class SnippetDraftSyncPayload(BaseModel):
default=None,
description="Ignored. Snippet workflows do not persist conversation variables.",
)
input_fields: list[dict[str, Any]] | None = Field(default=None)
input_fields: list[dict[str, Any]] | None = None
class SnippetWorkflowListQuery(BaseModel):
@@ -118,7 +118,7 @@ class SnippetDraftRunPayload(BaseModel):
"""Payload for running snippet draft workflow."""
inputs: dict[str, Any]
files: list[dict[str, Any]] | None = Field(default=None)
files: list[dict[str, Any]] | None = None
class SnippetDraftNodeRunPayload(BaseModel):
@@ -126,25 +126,25 @@ class SnippetDraftNodeRunPayload(BaseModel):
inputs: dict[str, Any]
query: str = ""
files: list[dict[str, Any]] | None = Field(default=None)
files: list[dict[str, Any]] | None = None
class SnippetIterationNodeRunPayload(BaseModel):
"""Payload for running an iteration node in snippet draft workflow."""
inputs: dict[str, Any] | None = Field(default=None)
inputs: dict[str, Any] | None = None
class SnippetLoopNodeRunPayload(BaseModel):
"""Payload for running a loop node in snippet draft workflow."""
inputs: dict[str, Any] | None = Field(default=None)
inputs: dict[str, Any] | None = None
class PublishWorkflowPayload(BaseModel):
"""Payload for publishing snippet workflow."""
knowledge_base_setting: dict[str, Any] | None = Field(default=None)
knowledge_base_setting: dict[str, Any] | None = None
class SnippetImportPayload(BaseModel):
@@ -4,21 +4,17 @@ from functools import wraps
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import Field
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
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.error import DraftWorkflowNotExist, DraftWorkflowNotSync
from controllers.console.app.workflow import (
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE,
DefaultBlockConfigsResponse,
WorkflowPaginationResponse,
WorkflowPublishResponse,
WorkflowResponse,
WorkflowRestoreResponse,
)
from controllers.console.snippets.payloads import (
PublishWorkflowPayload,
@@ -34,7 +30,6 @@ from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_user,
)
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
@@ -50,7 +45,6 @@ from graphon.graph_engine.manager import GraphEngineManager
from libs import helper
from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required
from models import Account
from models.snippet import CustomizedSnippet
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.snippet_generate_service import SnippetGenerateService
@@ -73,10 +67,6 @@ class SnippetWorkflowResponse(WorkflowResponse):
input_fields: list[dict] = Field(default_factory=list)
class SnippetDraftConfigResponse(BaseModel):
parallel_depth_limit: int
register_schema_models(
console_ns,
SnippetDraftSyncPayload,
@@ -90,14 +80,8 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
DefaultBlockConfigsResponse,
GeneratedAppResponse,
SimpleResultResponse,
SnippetDraftConfigResponse,
SnippetWorkflowResponse,
WorkflowPublishResponse,
WorkflowPaginationResponse,
WorkflowRestoreResponse,
WorkflowRunPaginationResponse,
WorkflowRunDetailResponse,
WorkflowRunNodeExecutionListResponse,
@@ -169,20 +153,17 @@ class SnippetDraftWorkflowApi(Resource):
@console_ns.doc("sync_snippet_draft_workflow")
@console_ns.expect(console_ns.models.get(SnippetDraftSyncPayload.__name__))
@console_ns.response(
200,
"Draft workflow synced successfully",
console_ns.models[WorkflowRestoreResponse.__name__],
)
@console_ns.response(200, "Draft workflow synced successfully")
@console_ns.response(400, "Hash mismatch")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet):
def post(self, snippet: CustomizedSnippet):
"""Sync draft workflow for snippet."""
current_user, _ = current_account_with_tenant()
payload = SnippetDraftSyncPayload.model_validate(console_ns.payload or {})
try:
@@ -209,11 +190,7 @@ class SnippetDraftWorkflowApi(Resource):
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/config")
class SnippetDraftConfigApi(Resource):
@console_ns.doc("get_snippet_draft_config")
@console_ns.response(
200,
"Draft config retrieved successfully",
console_ns.models[SnippetDraftConfigResponse.__name__],
)
@console_ns.response(200, "Draft config retrieved successfully")
@setup_required
@login_required
@account_initialization_required
@@ -257,16 +234,16 @@ class SnippetPublishedWorkflowApi(Resource):
@console_ns.doc("publish_snippet_workflow")
@console_ns.expect(console_ns.models.get(PublishWorkflowPayload.__name__))
@console_ns.response(200, "Workflow published successfully", console_ns.models[WorkflowPublishResponse.__name__])
@console_ns.response(200, "Workflow published successfully")
@console_ns.response(400, "No draft workflow found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet):
def post(self, snippet: CustomizedSnippet):
"""Publish snippet workflow."""
current_user, _ = current_account_with_tenant()
snippet_service = _snippet_service()
with Session(db.engine) as session:
@@ -291,11 +268,7 @@ class SnippetPublishedWorkflowApi(Resource):
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/default-workflow-block-configs")
class SnippetDefaultBlockConfigsApi(Resource):
@console_ns.doc("get_snippet_default_block_configs")
@console_ns.response(
200,
"Default block configs retrieved successfully",
console_ns.models[DefaultBlockConfigsResponse.__name__],
)
@console_ns.response(200, "Default block configs retrieved successfully")
@setup_required
@login_required
@account_initialization_required
@@ -309,7 +282,7 @@ class SnippetDefaultBlockConfigsApi(Resource):
@console_ns.route("/snippets/<uuid:snippet_id>/workflows")
class SnippetPublishedAllWorkflowApi(Resource):
@console_ns.doc(params=query_params_from_model(SnippetWorkflowListQuery))
@console_ns.expect(console_ns.models[SnippetWorkflowListQuery.__name__])
@console_ns.doc("get_all_snippet_published_workflows")
@console_ns.doc(description="Get all published workflows for a snippet")
@console_ns.doc(params={"snippet_id": "Snippet ID"})
@@ -352,17 +325,17 @@ class SnippetDraftWorkflowRestoreApi(Resource):
@console_ns.doc("restore_snippet_workflow_to_draft")
@console_ns.doc(description="Restore a published snippet workflow version into the draft workflow")
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Published workflow ID"})
@console_ns.response(200, "Workflow restored successfully", console_ns.models[WorkflowRestoreResponse.__name__])
@console_ns.response(200, "Workflow restored successfully")
@console_ns.response(400, "Source workflow must be published")
@console_ns.response(404, "Workflow not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet, workflow_id: str):
def post(self, snippet: CustomizedSnippet, workflow_id: str):
"""Restore a published snippet workflow version into the draft workflow."""
current_user, _ = current_account_with_tenant()
snippet_service = _snippet_service()
try:
@@ -388,7 +361,6 @@ class SnippetDraftWorkflowRestoreApi(Resource):
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
class SnippetWorkflowRunsApi(Resource):
@console_ns.doc("list_snippet_workflow_runs")
@console_ns.doc(params=query_params_from_model(WorkflowRunQuery))
@console_ns.response(
200,
"Workflow runs retrieved successfully",
@@ -483,16 +455,16 @@ class SnippetDraftNodeRunApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
def post(self, snippet: CustomizedSnippet, node_id: str):
"""
Run a single node in snippet draft workflow.
Executes a specific node with provided inputs for single-step debugging.
Returns the node execution result including status, outputs, and timing.
"""
current_user, _ = current_account_with_tenant()
payload = SnippetDraftNodeRunPayload.model_validate(console_ns.payload or {})
user_inputs = payload.inputs
@@ -562,25 +534,21 @@ class SnippetDraftRunIterationNodeApi(Resource):
@console_ns.doc(description="Run draft workflow iteration node for snippet")
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models.get(SnippetIterationNodeRunPayload.__name__))
@console_ns.response(
200,
"Iteration node run started successfully (SSE stream)",
console_ns.models[GeneratedAppResponse.__name__],
)
@console_ns.response(200, "Iteration node run started successfully (SSE stream)")
@console_ns.response(404, "Snippet or draft workflow not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
def post(self, snippet: CustomizedSnippet, node_id: str):
"""
Run a draft workflow iteration node for snippet.
Iteration nodes execute their internal sub-graph multiple times over an input list.
Returns an SSE event stream with iteration progress and results.
"""
current_user, _ = current_account_with_tenant()
args = SnippetIterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
try:
@@ -607,25 +575,21 @@ class SnippetDraftRunLoopNodeApi(Resource):
@console_ns.doc(description="Run draft workflow loop node for snippet")
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models.get(SnippetLoopNodeRunPayload.__name__))
@console_ns.response(
200,
"Loop node run started successfully (SSE stream)",
console_ns.models[GeneratedAppResponse.__name__],
)
@console_ns.response(200, "Loop node run started successfully (SSE stream)")
@console_ns.response(404, "Snippet or draft workflow not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
def post(self, snippet: CustomizedSnippet, node_id: str):
"""
Run a draft workflow loop node for snippet.
Loop nodes execute their internal sub-graph repeatedly until a condition is met.
Returns an SSE event stream with loop progress and results.
"""
current_user, _ = current_account_with_tenant()
args = SnippetLoopNodeRunPayload.model_validate(console_ns.payload or {})
try:
@@ -650,25 +614,22 @@ class SnippetDraftRunLoopNodeApi(Resource):
class SnippetDraftWorkflowRunApi(Resource):
@console_ns.doc("run_snippet_draft_workflow")
@console_ns.expect(console_ns.models.get(SnippetDraftRunPayload.__name__))
@console_ns.response(
200,
"Draft workflow run started successfully (SSE stream)",
console_ns.models[GeneratedAppResponse.__name__],
)
@console_ns.response(200, "Draft workflow run started successfully (SSE stream)")
@console_ns.response(404, "Snippet or draft workflow not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet):
def post(self, snippet: CustomizedSnippet):
"""
Run draft workflow for snippet.
Executes the snippet's draft workflow with the provided inputs
and returns an SSE event stream with execution progress and results.
"""
current_user, _ = current_account_with_tenant()
payload = SnippetDraftRunPayload.model_validate(console_ns.payload or {})
args = payload.model_dump(exclude_none=True)
@@ -693,7 +654,7 @@ class SnippetDraftWorkflowRunApi(Resource):
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs/tasks/<string:task_id>/stop")
class SnippetWorkflowTaskStopApi(Resource):
@console_ns.doc("stop_snippet_workflow_task")
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(200, "Task stopped successfully")
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@@ -18,12 +18,9 @@ from flask import Response, request
from flask_restx import Resource, marshal, marshal_with
from sqlalchemy.orm import Session, sessionmaker
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.common.schema import query_params_from_model
from controllers.console import console_ns
from controllers.console.app.error import DraftWorkflowNotExist
from controllers.console.app.workflow_draft_variable import (
EnvironmentVariableListResponse,
WorkflowDraftVariableListQuery,
WorkflowDraftVariableUpdatePayload,
ensure_variable_access,
@@ -39,6 +36,7 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from controllers.web.error import InvalidArgumentError, NotFoundError
from core.app.file_access import DatabaseFileAccessController
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db
@@ -92,7 +90,7 @@ def _snippet_draft_var_prerequisite[T, **P, R](
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/variables")
class SnippetWorkflowVariableCollectionApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkflowDraftVariableListQuery))
@console_ns.expect(console_ns.models[WorkflowDraftVariableListQuery.__name__])
@console_ns.doc("get_snippet_workflow_variables")
@console_ns.doc(description="List draft workflow variables without values (paginated, snippet scope)")
@console_ns.response(
@@ -307,11 +305,7 @@ class SnippetSystemVariableCollectionApi(Resource):
class SnippetEnvironmentVariableCollectionApi(Resource):
@console_ns.doc("get_snippet_environment_variables")
@console_ns.doc(description="Get environment variables from snippet draft workflow graph")
@console_ns.response(
200,
"Environment variables retrieved successfully",
console_ns.models[EnvironmentVariableListResponse.__name__],
)
@console_ns.response(200, "Environment variables retrieved successfully")
@console_ns.response(404, "Draft workflow not found")
@_snippet_draft_var_prerequisite
def get(self, _current_user: Account, snippet: CustomizedSnippet) -> dict[str, list[dict[str, Any]]]:
-11
View File
@@ -1,10 +1,7 @@
import logging
from typing import Any
from flask_restx import Resource
from pydantic import RootModel
from controllers.common.schema import register_response_schema_models
from controllers.console.wraps import (
account_initialization_required,
setup_required,
@@ -17,16 +14,8 @@ from . import console_ns
logger = logging.getLogger(__name__)
class SchemaDefinitionsResponse(RootModel[Any]):
root: Any
register_response_schema_models(console_ns, SchemaDefinitionsResponse)
@console_ns.route("/spec/schema-definitions")
class SpecSchemaDefinitionsApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SchemaDefinitionsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
+7 -2
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import Forbidden
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
@@ -95,7 +95,12 @@ class TagListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.doc(params=query_params_from_model(TagListQueryParam))
@console_ns.doc(
params={
"type": 'Tag type filter. Can be "knowledge", "app", or "snippet".',
"keyword": "Search keyword for tag name.",
}
)
@console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__name__]])})
@with_current_tenant_id
def get(self, current_tenant_id: str):
+2 -8
View File
@@ -6,7 +6,7 @@ from typing import Any, Literal
import pytz
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, field_validator, model_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select
from werkzeug.exceptions import NotFound
@@ -236,10 +236,6 @@ class EducationAutocompleteResponse(ResponseModel):
has_next: bool | None = None
class EducationActivateResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
register_schema_models(
console_ns,
AccountIntegrateResponse,
@@ -252,7 +248,6 @@ register_response_schema_models(
console_ns,
AccountResponse,
AvatarUrlResponse,
EducationActivateResponse,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
@@ -561,7 +556,6 @@ class EducationVerifyApi(Resource):
@console_ns.route("/account/education")
class EducationApi(Resource):
@console_ns.expect(console_ns.models[EducationActivatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[EducationActivateResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -591,7 +585,7 @@ class EducationApi(Resource):
@console_ns.route("/account/education/autocomplete")
class EducationAutoCompleteApi(Resource):
@console_ns.doc(params=query_params_from_model(EducationAutocompleteQuery))
@console_ns.expect(console_ns.models[EducationAutocompleteQuery.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -1,9 +1,5 @@
from typing import Any
from flask_restx import Resource, fields
from flask_restx import Resource
from pydantic import RootModel
from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
@@ -17,17 +13,6 @@ from models import Account
from services.agent_service import AgentService
class AgentProviderListResponse(RootModel[list[dict[str, Any]]]):
root: list[dict[str, Any]]
class AgentProviderResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
register_response_schema_models(console_ns, AgentProviderListResponse, AgentProviderResponse)
@console_ns.route("/workspaces/current/agent-providers")
class AgentProviderListApi(Resource):
@console_ns.doc("list_agent_providers")
@@ -35,7 +20,7 @@ class AgentProviderListApi(Resource):
@console_ns.response(
200,
"Success",
console_ns.models[AgentProviderListResponse.__name__],
fields.List(fields.Raw(description="Agent provider information")),
)
@setup_required
@login_required
@@ -54,7 +39,7 @@ class AgentProviderApi(Resource):
@console_ns.response(
200,
"Success",
console_ns.models[AgentProviderResponse.__name__],
fields.Raw(description="Agent provider details"),
)
@setup_required
@login_required
@@ -12,7 +12,7 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
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.wraps import (
account_initialization_required,
@@ -61,15 +61,11 @@ class EndpointCreateResponse(BaseModel):
class EndpointListResponse(BaseModel):
endpoints: list[dict[str, Any]] = Field(
description="Endpoint information",
)
endpoints: list[dict[str, Any]] = Field(description="Endpoint information")
class PluginEndpointListResponse(BaseModel):
endpoints: list[dict[str, Any]] = Field(
description="Endpoint information",
)
endpoints: list[dict[str, Any]] = Field(description="Endpoint information")
class EndpointDeleteResponse(BaseModel):
@@ -205,7 +201,7 @@ class DeprecatedEndpointCreateApi(Resource):
class EndpointListApi(Resource):
@console_ns.doc("list_endpoints")
@console_ns.doc(description="List plugin endpoints with pagination")
@console_ns.doc(params=query_params_from_model(EndpointListQuery))
@console_ns.expect(console_ns.models[EndpointListQuery.__name__])
@console_ns.response(
200,
"Success",
@@ -238,7 +234,7 @@ class EndpointListApi(Resource):
class EndpointListForSinglePluginApi(Resource):
@console_ns.doc("list_plugin_endpoints")
@console_ns.doc(description="List endpoints for a specific plugin")
@console_ns.doc(params=query_params_from_model(EndpointListForPluginQuery))
@console_ns.expect(console_ns.models[EndpointListForPluginQuery.__name__])
@console_ns.response(
200,
"Success",
@@ -2,7 +2,7 @@ from flask_restx import Resource
from pydantic import BaseModel
from werkzeug.exceptions import Forbidden
from controllers.common.schema import 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 (
account_initialization_required,
@@ -10,7 +10,6 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from fields.base import ResponseModel
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from libs.login import login_required
@@ -24,13 +23,7 @@ class LoadBalancingCredentialPayload(BaseModel):
credentials: dict[str, object]
class LoadBalancingCredentialValidateResponse(ResponseModel):
result: str
error: str | None = None
register_schema_models(console_ns, LoadBalancingCredentialPayload)
register_response_schema_models(console_ns, LoadBalancingCredentialValidateResponse)
@console_ns.route(
@@ -38,11 +31,6 @@ register_response_schema_models(console_ns, LoadBalancingCredentialValidateRespo
)
class LoadBalancingCredentialsValidateApi(Resource):
@console_ns.expect(console_ns.models[LoadBalancingCredentialPayload.__name__])
@console_ns.response(
200,
"Credential validation result",
console_ns.models[LoadBalancingCredentialValidateResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -87,11 +75,6 @@ class LoadBalancingCredentialsValidateApi(Resource):
)
class LoadBalancingConfigCredentialsValidateApi(Resource):
@console_ns.expect(console_ns.models[LoadBalancingCredentialPayload.__name__])
@console_ns.response(
200,
"Credential validation result",
console_ns.models[LoadBalancingCredentialValidateResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
+2 -32
View File
@@ -8,7 +8,7 @@ from sqlalchemy import func, select
import services
from configs import dify_config
from controllers.common.fields import SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse
from controllers.common.fields import SimpleResultDataResponse, VerificationTokenResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import (
@@ -29,7 +29,6 @@ from controllers.console.wraps import (
)
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
from fields.member_fields import AccountWithRole, AccountWithRoleList
from libs.helper import extract_remote_ip
from libs.login import login_required
@@ -62,24 +61,6 @@ class OwnerTransferPayload(BaseModel):
token: str
class MemberInviteResultResponse(ResponseModel):
status: str
email: str
url: str | None = None
message: str | None = None
class MemberInviteResponse(ResponseModel):
result: str
invitation_results: list[MemberInviteResultResponse]
tenant_id: str
class MemberActionTenantResponse(ResponseModel):
result: str
tenant_id: str
register_enum_models(console_ns, TenantAccountRole)
register_schema_models(
console_ns,
@@ -91,14 +72,7 @@ register_schema_models(
OwnerTransferCheckPayload,
OwnerTransferPayload,
)
register_response_schema_models(
console_ns,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
MemberInviteResponse,
MemberActionTenantResponse,
)
register_response_schema_models(console_ns, SimpleResultDataResponse, VerificationTokenResponse)
def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
@@ -178,7 +152,6 @@ class MemberInviteEmailApi(Resource):
"""Invite a new member by email."""
@console_ns.expect(console_ns.models[MemberInvitePayload.__name__])
@console_ns.response(201, "Success", console_ns.models[MemberInviteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -248,7 +221,6 @@ class MemberInviteEmailApi(Resource):
class MemberCancelInviteApi(Resource):
"""Cancel an invitation by member id."""
@console_ns.response(200, "Success", console_ns.models[MemberActionTenantResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -282,7 +254,6 @@ class MemberUpdateRoleApi(Resource):
"""Update member role."""
@console_ns.expect(console_ns.models[MemberRoleUpdatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -425,7 +396,6 @@ class OwnerTransferCheckApi(Resource):
@console_ns.route("/workspaces/current/members/<uuid:member_id>/owner-transfer")
class OwnerTransfer(Resource):
@console_ns.expect(console_ns.models[OwnerTransferPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -5,8 +5,8 @@ from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from controllers.common.fields import BinaryFileResponse, SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
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.wraps import (
account_initialization_required,
@@ -15,7 +15,6 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from fields.base import ResponseModel
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from graphon.model_runtime.utils.encoders import jsonable_encoder
@@ -23,7 +22,6 @@ from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from services.billing_service import BillingService
from services.entities.model_provider_entities import ProviderResponse
from services.model_provider_service import ModelProviderService
@@ -84,23 +82,6 @@ class ParserPreferredProviderType(BaseModel):
preferred_provider_type: Literal["system", "custom"]
class ModelProviderListResponse(ResponseModel):
data: list[ProviderResponse]
class ProviderCredentialResponse(ResponseModel):
credentials: dict[str, Any] | None = Field(default=None)
class ProviderCredentialValidateResponse(ResponseModel):
result: Literal["success", "error"]
error: str | None = None
class ModelProviderPaymentCheckoutUrlResponse(ResponseModel):
payment_link: str
register_schema_models(
console_ns,
ParserModelList,
@@ -112,21 +93,12 @@ register_schema_models(
ParserCredentialValidate,
ParserPreferredProviderType,
)
register_response_schema_models(
console_ns,
BinaryFileResponse,
SimpleResultResponse,
ModelProviderListResponse,
ModelProviderPaymentCheckoutUrlResponse,
ProviderCredentialResponse,
ProviderCredentialValidateResponse,
)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/workspaces/current/model-providers")
class ModelProviderListApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserModelList))
@console_ns.response(200, "Success", console_ns.models[ModelProviderListResponse.__name__])
@console_ns.expect(console_ns.models[ParserModelList.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -143,8 +115,7 @@ class ModelProviderListApi(Resource):
@console_ns.route("/workspaces/current/model-providers/<path:provider>/credentials")
class ModelProviderCredentialApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserCredentialId))
@console_ns.response(200, "Success", console_ns.models[ProviderCredentialResponse.__name__])
@console_ns.expect(console_ns.models[ParserCredentialId.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -162,7 +133,6 @@ class ModelProviderCredentialApi(Resource):
return {"credentials": credentials}
@console_ns.expect(console_ns.models[ParserCredentialCreate.__name__])
@console_ns.response(201, "Credential created successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@@ -187,7 +157,6 @@ class ModelProviderCredentialApi(Resource):
return {"result": "success"}, 201
@console_ns.expect(console_ns.models[ParserCredentialUpdate.__name__])
@console_ns.response(200, "Credential updated successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@@ -256,11 +225,6 @@ class ModelProviderCredentialSwitchApi(Resource):
@console_ns.route("/workspaces/current/model-providers/<path:provider>/credentials/validate")
class ModelProviderValidateApi(Resource):
@console_ns.expect(console_ns.models[ParserCredentialValidate.__name__])
@console_ns.response(
200,
"Credential validation result",
console_ns.models[ProviderCredentialValidateResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -298,7 +262,6 @@ class ModelProviderIconApi(Resource):
Get model provider icon
"""
@console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__])
def get(self, tenant_id: str, provider: str, icon_type: str, lang: str):
model_provider_service = ModelProviderService()
icon, mimetype = model_provider_service.get_model_provider_icon(
@@ -335,7 +298,6 @@ class PreferredProviderTypeUpdateApi(Resource):
@console_ns.route("/workspaces/current/model-providers/<path:provider>/checkout-url")
class ModelProviderPaymentCheckoutUrlApi(Resource):
@console_ns.response(200, "Success", console_ns.models[ModelProviderPaymentCheckoutUrlResponse.__name__])
@setup_required
@login_required
@account_initialization_required
+7 -75
View File
@@ -6,12 +6,7 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import (
query_params_from_model,
register_enum_models,
register_response_schema_models,
register_schema_models,
)
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
@@ -20,19 +15,12 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from core.entities.provider_entities import CredentialConfiguration
from fields.base import ResponseModel
from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from services.entities.model_provider_entities import (
DefaultModelResponse,
ModelWithProviderEntityResponse,
ProviderWithModelsResponse,
)
from services.model_load_balancing_service import ModelLoadBalancingService
from services.model_provider_service import ModelProviderService
@@ -59,7 +47,7 @@ class ParserDeleteModels(BaseModel):
class LoadBalancingPayload(BaseModel):
configs: list[dict[str, Any]] | None = Field(default=None)
configs: list[dict[str, Any]] | None = None
enabled: bool | None = None
@@ -132,40 +120,6 @@ class ParserSwitch(BaseModel):
credential_id: str
class DefaultModelDataResponse(ResponseModel):
data: DefaultModelResponse | None = None
class ModelWithProviderListResponse(ResponseModel):
data: list[ModelWithProviderEntityResponse]
class ProviderWithModelsDataResponse(ResponseModel):
data: list[ProviderWithModelsResponse]
class ModelCredentialLoadBalancingResponse(ResponseModel):
enabled: bool
configs: list[dict[str, Any]] = Field(default_factory=list)
class ModelCredentialResponse(ResponseModel):
credentials: dict[str, Any] = Field(default_factory=dict)
current_credential_id: str | None = None
current_credential_name: str | None = None
load_balancing: ModelCredentialLoadBalancingResponse
available_credentials: list[CredentialConfiguration]
class ModelCredentialValidateResponse(ResponseModel):
result: str
error: str | None = None
class ModelParameterRulesResponse(ResponseModel):
data: list[ParameterRule]
register_schema_models(
console_ns,
ParserGetDefault,
@@ -180,24 +134,14 @@ register_schema_models(
Inner,
ParserSwitch,
)
register_response_schema_models(
console_ns,
SimpleResultResponse,
DefaultModelDataResponse,
ModelWithProviderListResponse,
ProviderWithModelsDataResponse,
ModelCredentialResponse,
ModelCredentialValidateResponse,
ModelParameterRulesResponse,
)
register_response_schema_models(console_ns, SimpleResultResponse)
register_enum_models(console_ns, ModelType)
@console_ns.route("/workspaces/current/default-model")
class DefaultModelApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserGetDefault))
@console_ns.response(200, "Success", console_ns.models[DefaultModelDataResponse.__name__])
@console_ns.expect(console_ns.models[ParserGetDefault.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -247,7 +191,6 @@ class DefaultModelApi(Resource):
@console_ns.route("/workspaces/current/model-providers/<path:provider>/models")
class ModelProviderModelApi(Resource):
@console_ns.response(200, "Success", console_ns.models[ModelWithProviderListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -259,7 +202,6 @@ class ModelProviderModelApi(Resource):
return jsonable_encoder({"data": models})
@console_ns.expect(console_ns.models[ParserPostModels.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@@ -325,8 +267,7 @@ class ModelProviderModelApi(Resource):
@console_ns.route("/workspaces/current/model-providers/<path:provider>/models/credentials")
class ModelProviderModelCredentialApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserGetCredentials))
@console_ns.response(200, "Success", console_ns.models[ModelCredentialResponse.__name__])
@console_ns.expect(console_ns.models[ParserGetCredentials.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -385,7 +326,6 @@ class ModelProviderModelCredentialApi(Resource):
)
@console_ns.expect(console_ns.models[ParserCreateCredential.__name__])
@console_ns.response(201, "Credential created successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@@ -417,7 +357,6 @@ class ModelProviderModelCredentialApi(Resource):
return {"result": "success"}, 201
@console_ns.expect(console_ns.models[ParserUpdateCredential.__name__])
@console_ns.response(200, "Credential updated successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@@ -542,11 +481,6 @@ register_schema_models(console_ns, ParserSwitch, ParserValidate)
@console_ns.route("/workspaces/current/model-providers/<path:provider>/models/credentials/validate")
class ModelProviderModelValidateApi(Resource):
@console_ns.expect(console_ns.models[ParserValidate.__name__])
@console_ns.response(
200,
"Credential validation result",
console_ns.models[ModelCredentialValidateResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -581,8 +515,7 @@ class ModelProviderModelValidateApi(Resource):
@console_ns.route("/workspaces/current/model-providers/<path:provider>/models/parameter-rules")
class ModelProviderModelParameterRuleApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserParameter))
@console_ns.response(200, "Success", console_ns.models[ModelParameterRulesResponse.__name__])
@console_ns.expect(console_ns.models[ParserParameter.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -600,7 +533,6 @@ class ModelProviderModelParameterRuleApi(Resource):
@console_ns.route("/workspaces/current/models/model-types/<string:model_type>")
class ModelProviderAvailableModelApi(Resource):
@console_ns.response(200, "Success", console_ns.models[ProviderWithModelsDataResponse.__name__])
@setup_required
@login_required
@account_initialization_required
+336 -237
View File
@@ -1,15 +1,16 @@
import io
from collections.abc import Mapping
from typing import Any, Literal
from datetime import datetime
from typing import Any, Literal, TypedDict
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, ConfigDict, Field
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.fields import BinaryFileResponse, SuccessResponse
from controllers.common.fields import SuccessResponse
from controllers.common.schema import (
query_params_from_model,
register_enum_models,
@@ -18,23 +19,33 @@ from controllers.common.schema import (
)
from controllers.console import console_ns
from controllers.console.workspace import plugin_permission_required
from controllers.console.wraps import (
account_initialization_required,
is_admin_or_owner_required,
setup_required,
with_current_tenant_id,
with_current_user,
with_current_user_id,
)
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
from core.helper.position_helper import is_filtered
from core.plugin.entities.plugin import PluginCategory, PluginDeclaration, PluginInstallationSource
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.plugin_service import PluginService
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.tool_manager import ToolManager
from fields.base import ResponseModel
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.login import login_required
from models.account import Account, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from models.provider_ids import ToolProviderID
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_parameter_service import PluginParameterService
from services.plugin.plugin_permission_service import PluginPermissionService
from services.tools.tools_transform_service import ToolTransformService
class AutoUpgradeSettingsResponse(TypedDict):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class ParserList(BaseModel):
@@ -42,6 +53,11 @@ class ParserList(BaseModel):
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
class PluginCategoryListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
class ParserLatest(BaseModel):
plugin_ids: list[str]
@@ -100,8 +116,8 @@ class ParserUninstall(BaseModel):
class ParserPermissionChange(BaseModel):
install_permission: TenantPluginPermission.InstallPermission
debug_permission: TenantPluginPermission.DebugPermission
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
class ParserDynamicOptions(BaseModel):
@@ -137,13 +153,40 @@ class PluginAutoUpgradeSettingsPayload(BaseModel):
include_plugins: list[str] = Field(default_factory=list)
class ParserPreferencesChange(BaseModel):
permission: PluginPermissionSettingsPayload
class PluginAutoUpgradeChangeResponse(ResponseModel):
success: bool
message: str | None = None
class PluginAutoUpgradeSettingsResponseModel(ResponseModel):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class PluginAutoUpgradeFetchResponse(ResponseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
class ParserAutoUpgradeChange(BaseModel):
model_config = ConfigDict(extra="forbid")
category: TenantPluginAutoUpgradeStrategy.PluginCategory
auto_upgrade: PluginAutoUpgradeSettingsPayload
class ParserAutoUpgradeFetch(BaseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserExcludePlugin(BaseModel):
model_config = ConfigDict(extra="forbid")
plugin_id: str
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserReadme(BaseModel):
@@ -157,61 +200,67 @@ class PluginDebuggingKeyResponse(ResponseModel):
port: int
class PluginDaemonOperationResponse(RootModel[Any]):
root: Any
class PluginCategoryInstalledPluginResponse(ResponseModel):
id: str
name: str
tenant_id: str
plugin_id: str
plugin_unique_identifier: str
endpoints_active: int
endpoints_setups: int
installation_id: str
declaration: PluginDeclaration
runtime_type: str
version: str
created_at: datetime
updated_at: datetime
source: PluginInstallationSource
checksum: str
meta: Mapping[str, Any]
class PluginListResponse(ResponseModel):
plugins: Any
total: int
class PluginCategoryBuiltinToolResponse(ResponseModel):
model_config = ConfigDict(extra="allow")
author: str
name: str
label: I18nObject
description: I18nObject
parameters: list[Mapping[str, Any]] | None = None
labels: list[str]
output_schema: Mapping[str, object]
class PluginVersionsResponse(ResponseModel):
versions: Any
class PluginCategoryBuiltinToolProviderResponse(ResponseModel):
model_config = ConfigDict(extra="allow")
id: str
author: str
name: str
plugin_id: str | None
plugin_unique_identifier: str | None
description: I18nObject
icon: str | Mapping[str, str]
icon_dark: str | Mapping[str, str] | None
label: I18nObject
type: ToolProviderType
team_credentials: Mapping[str, object]
is_team_authorization: bool
allow_delete: bool
tools: list[PluginCategoryBuiltinToolResponse]
labels: list[str]
class PluginInstallationsResponse(ResponseModel):
plugins: Any
class PluginManifestResponse(ResponseModel):
manifest: Any
class PluginTasksResponse(ResponseModel):
tasks: Any
class PluginTaskResponse(ResponseModel):
task: Any
class PluginPermissionResponse(ResponseModel):
install_permission: TenantPluginPermission.InstallPermission
debug_permission: TenantPluginPermission.DebugPermission
class PluginDynamicOptionsResponse(ResponseModel):
options: Any
class PluginOperationSuccessResponse(ResponseModel):
success: bool
message: str | None = None
class PluginPreferencesResponse(ResponseModel):
permission: PluginPermissionSettingsPayload
auto_upgrade: PluginAutoUpgradeSettingsPayload
class PluginReadmeResponse(ResponseModel):
readme: str
class PluginCategoryListResponse(ResponseModel):
plugins: list[PluginCategoryInstalledPluginResponse]
builtin_tools: list[PluginCategoryBuiltinToolProviderResponse]
has_more: bool
register_schema_models(
console_ns,
ParserList,
PluginCategoryListQuery,
PluginAutoUpgradeSettingsPayload,
PluginPermissionSettingsPayload,
ParserLatest,
@@ -228,38 +277,57 @@ register_schema_models(
ParserPermissionChange,
ParserDynamicOptions,
ParserDynamicOptionsWithCredentials,
ParserPreferencesChange,
ParserAutoUpgradeChange,
ParserAutoUpgradeFetch,
ParserExcludePlugin,
ParserReadme,
)
register_response_schema_models(
console_ns,
BinaryFileResponse,
PluginDaemonOperationResponse,
PluginAutoUpgradeChangeResponse,
PluginAutoUpgradeFetchResponse,
PluginAutoUpgradeSettingsResponseModel,
PluginDebuggingKeyResponse,
PluginDynamicOptionsResponse,
PluginInstallationsResponse,
PluginListResponse,
PluginManifestResponse,
PluginOperationSuccessResponse,
PluginPermissionResponse,
PluginPreferencesResponse,
PluginReadmeResponse,
PluginTaskResponse,
PluginTasksResponse,
PluginVersionsResponse,
PluginCategoryInstalledPluginResponse,
PluginCategoryBuiltinToolResponse,
PluginCategoryBuiltinToolProviderResponse,
PluginCategoryListResponse,
SuccessResponse,
)
register_enum_models(
console_ns,
TenantPluginPermission.DebugPermission,
TenantPluginAutoUpgradeStrategy.PluginCategory,
TenantPluginAutoUpgradeStrategy.UpgradeMode,
TenantPluginAutoUpgradeStrategy.StrategySetting,
TenantPluginPermission.InstallPermission,
)
def _default_auto_upgrade_settings(
tenant_id: str,
category: TenantPluginAutoUpgradeStrategy.PluginCategory,
) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category),
"upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id),
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
def _auto_upgrade_settings_to_dict(strategy: TenantPluginAutoUpgradeStrategy) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": strategy.strategy_setting,
"upgrade_time_of_day": strategy.upgrade_time_of_day,
"upgrade_mode": strategy.upgrade_mode,
"exclude_plugins": strategy.exclude_plugins,
"include_plugins": strategy.include_plugins,
}
def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
"""
Read the uploaded file and validate its actual size before delegating to the plugin service.
@@ -274,6 +342,33 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
return content
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
db_builtin_providers = {
str(ToolProviderID(provider.provider)): provider
for provider in ToolManager.list_default_builtin_providers(tenant_id)
}
builtin_providers = []
for provider in ToolManager.list_hardcoded_providers():
if is_filtered(
include_set=dify_config.POSITION_TOOL_INCLUDES_SET,
exclude_set=dify_config.POSITION_TOOL_EXCLUDES_SET,
data=provider,
name_func=lambda provider_controller: provider_controller.entity.identity.name,
):
continue
user_provider = ToolTransformService.builtin_provider_to_user_provider(
provider_controller=provider,
db_provider=db_builtin_providers.get(provider.entity.identity.name),
decrypt_credentials=False,
)
ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider)
builtin_providers.append(user_provider)
return [provider.to_dict() for provider in BuiltinToolProviderSort.sort(builtin_providers)]
@console_ns.route("/workspaces/current/plugin/debugging-key")
class PluginDebuggingKeyApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginDebuggingKeyResponse.__name__])
@@ -281,8 +376,9 @@ class PluginDebuggingKeyApi(Resource):
@login_required
@account_initialization_required
@plugin_permission_required(debug_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
def get(self):
_, tenant_id = current_account_with_tenant()
try:
return {
"key": PluginService.get_debugging_key(tenant_id),
@@ -295,27 +391,59 @@ class PluginDebuggingKeyApi(Resource):
@console_ns.route("/workspaces/current/plugin/list")
class PluginListApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserList))
@console_ns.response(200, "Success", console_ns.models[PluginListResponse.__name__])
@console_ns.expect(console_ns.models[ParserList.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, user_id: str):
def get(self):
_, tenant_id = current_account_with_tenant()
args = ParserList.model_validate(request.args.to_dict(flat=True))
try:
plugins_with_total = PluginService.list_with_total(tenant_id, user_id, args.page, args.page_size)
plugins_with_total = PluginService.list_with_total(tenant_id, args.page, args.page_size)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
return jsonable_encoder({"plugins": plugins_with_total.list, "total": plugins_with_total.total})
@console_ns.route("/workspaces/current/plugin/<string:category>/list")
class PluginCategoryListApi(Resource):
@console_ns.doc(params=query_params_from_model(PluginCategoryListQuery))
@console_ns.response(200, "Success", console_ns.models[PluginCategoryListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, category: str):
_, tenant_id = current_account_with_tenant()
args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True))
try:
plugin_category = PluginCategory(category)
except ValueError:
return {"code": "invalid_param", "message": "invalid plugin category"}, 400
try:
plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
builtin_tools = []
if plugin_category == PluginCategory.Tool:
builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id)
return dump_response(
PluginCategoryListResponse,
{
"plugins": jsonable_encoder(plugins.list),
"builtin_tools": builtin_tools,
"has_more": plugins.has_more,
},
)
@console_ns.route("/workspaces/current/plugin/list/latest-versions")
class PluginListLatestVersionsApi(Resource):
@console_ns.expect(console_ns.models[ParserLatest.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginVersionsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -333,12 +461,12 @@ class PluginListLatestVersionsApi(Resource):
@console_ns.route("/workspaces/current/plugin/list/installations/ids")
class PluginListInstallationsFromIdsApi(Resource):
@console_ns.expect(console_ns.models[ParserLatest.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginInstallationsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
_, tenant_id = current_account_with_tenant()
args = ParserLatest.model_validate(console_ns.payload)
try:
@@ -351,8 +479,7 @@ class PluginListInstallationsFromIdsApi(Resource):
@console_ns.route("/workspaces/current/plugin/icon")
class PluginIconApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserIcon))
@console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__])
@console_ns.expect(console_ns.models[ParserIcon.__name__])
@setup_required
def get(self):
args = ParserIcon.model_validate(request.args.to_dict(flat=True))
@@ -368,15 +495,14 @@ class PluginIconApi(Resource):
@console_ns.route("/workspaces/current/plugin/asset")
class PluginAssetApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserAsset))
@console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__])
@console_ns.expect(console_ns.models[ParserAsset.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
def get(self):
args = ParserAsset.model_validate(request.args.to_dict(flat=True))
_, tenant_id = current_account_with_tenant()
try:
binary = PluginService.extract_asset(tenant_id, args.plugin_unique_identifier, args.file_name)
return send_file(io.BytesIO(binary), mimetype="application/octet-stream")
@@ -386,13 +512,13 @@ class PluginAssetApi(Resource):
@console_ns.route("/workspaces/current/plugin/upload/pkg")
class PluginUploadFromPkgApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
_, tenant_id = current_account_with_tenant()
file = request.files["pkg"]
content = _read_upload_content(file, dify_config.PLUGIN_MAX_PACKAGE_SIZE)
try:
@@ -406,13 +532,13 @@ class PluginUploadFromPkgApi(Resource):
@console_ns.route("/workspaces/current/plugin/upload/github")
class PluginUploadFromGithubApi(Resource):
@console_ns.expect(console_ns.models[ParserGithubUpload.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
_, tenant_id = current_account_with_tenant()
args = ParserGithubUpload.model_validate(console_ns.payload)
try:
@@ -425,13 +551,13 @@ class PluginUploadFromGithubApi(Resource):
@console_ns.route("/workspaces/current/plugin/upload/bundle")
class PluginUploadFromBundleApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
_, tenant_id = current_account_with_tenant()
file = request.files["bundle"]
content = _read_upload_content(file, dify_config.PLUGIN_MAX_BUNDLE_SIZE)
try:
@@ -445,13 +571,12 @@ class PluginUploadFromBundleApi(Resource):
@console_ns.route("/workspaces/current/plugin/install/pkg")
class PluginInstallFromPkgApi(Resource):
@console_ns.expect(console_ns.models[ParserPluginIdentifiers.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
_, tenant_id = current_account_with_tenant()
args = ParserPluginIdentifiers.model_validate(console_ns.payload)
try:
@@ -465,13 +590,13 @@ class PluginInstallFromPkgApi(Resource):
@console_ns.route("/workspaces/current/plugin/install/github")
class PluginInstallFromGithubApi(Resource):
@console_ns.expect(console_ns.models[ParserGithubInstall.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
_, tenant_id = current_account_with_tenant()
args = ParserGithubInstall.model_validate(console_ns.payload)
try:
@@ -491,13 +616,13 @@ class PluginInstallFromGithubApi(Resource):
@console_ns.route("/workspaces/current/plugin/install/marketplace")
class PluginInstallFromMarketplaceApi(Resource):
@console_ns.expect(console_ns.models[ParserPluginIdentifiers.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
_, tenant_id = current_account_with_tenant()
args = ParserPluginIdentifiers.model_validate(console_ns.payload)
try:
@@ -510,14 +635,13 @@ class PluginInstallFromMarketplaceApi(Resource):
@console_ns.route("/workspaces/current/plugin/marketplace/pkg")
class PluginFetchMarketplacePkgApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserPluginIdentifierQuery))
@console_ns.response(200, "Success", console_ns.models[PluginManifestResponse.__name__])
@console_ns.expect(console_ns.models[ParserPluginIdentifierQuery.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
def get(self):
_, tenant_id = current_account_with_tenant()
args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True))
try:
@@ -535,14 +659,14 @@ class PluginFetchMarketplacePkgApi(Resource):
@console_ns.route("/workspaces/current/plugin/fetch-manifest")
class PluginFetchManifestApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserPluginIdentifierQuery))
@console_ns.response(200, "Success", console_ns.models[PluginManifestResponse.__name__])
@console_ns.expect(console_ns.models[ParserPluginIdentifierQuery.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
def get(self):
_, tenant_id = current_account_with_tenant()
args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True))
try:
@@ -555,14 +679,14 @@ class PluginFetchManifestApi(Resource):
@console_ns.route("/workspaces/current/plugin/tasks")
class PluginFetchInstallTasksApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserTasks))
@console_ns.response(200, "Success", console_ns.models[PluginTasksResponse.__name__])
@console_ns.expect(console_ns.models[ParserTasks.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
def get(self):
_, tenant_id = current_account_with_tenant()
args = ParserTasks.model_validate(request.args.to_dict(flat=True))
try:
@@ -573,13 +697,13 @@ class PluginFetchInstallTasksApi(Resource):
@console_ns.route("/workspaces/current/plugin/tasks/<task_id>")
class PluginFetchInstallTaskApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginTaskResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str, task_id: str):
def get(self, task_id: str):
_, tenant_id = current_account_with_tenant()
try:
return jsonable_encoder({"task": PluginService.fetch_install_task(tenant_id, task_id)})
except PluginDaemonClientSideError as e:
@@ -593,8 +717,9 @@ class PluginDeleteInstallTaskApi(Resource):
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str, task_id: str):
def post(self, task_id: str):
_, tenant_id = current_account_with_tenant()
try:
return {"success": PluginService.delete_install_task(tenant_id, task_id)}
except PluginDaemonClientSideError as e:
@@ -608,8 +733,9 @@ class PluginDeleteAllInstallTaskItemsApi(Resource):
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
_, tenant_id = current_account_with_tenant()
try:
return {"success": PluginService.delete_all_install_task_items(tenant_id)}
except PluginDaemonClientSideError as e:
@@ -623,8 +749,9 @@ class PluginDeleteInstallTaskItemApi(Resource):
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str, task_id: str, identifier: str):
def post(self, task_id: str, identifier: str):
_, tenant_id = current_account_with_tenant()
try:
return {"success": PluginService.delete_install_task_item(tenant_id, task_id, identifier)}
except PluginDaemonClientSideError as e:
@@ -634,13 +761,13 @@ class PluginDeleteInstallTaskItemApi(Resource):
@console_ns.route("/workspaces/current/plugin/upgrade/marketplace")
class PluginUpgradeFromMarketplaceApi(Resource):
@console_ns.expect(console_ns.models[ParserMarketplaceUpgrade.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
_, tenant_id = current_account_with_tenant()
args = ParserMarketplaceUpgrade.model_validate(console_ns.payload)
try:
@@ -656,13 +783,13 @@ class PluginUpgradeFromMarketplaceApi(Resource):
@console_ns.route("/workspaces/current/plugin/upgrade/github")
class PluginUpgradeFromGithubApi(Resource):
@console_ns.expect(console_ns.models[ParserGithubUpgrade.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
_, tenant_id = current_account_with_tenant()
args = ParserGithubUpgrade.model_validate(console_ns.payload)
try:
@@ -688,10 +815,11 @@ class PluginUninstallApi(Resource):
@login_required
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
args = ParserUninstall.model_validate(console_ns.payload)
_, tenant_id = current_account_with_tenant()
try:
return {"success": PluginService.uninstall(tenant_id, args.plugin_installation_id)}
except PluginDaemonClientSideError as e:
@@ -705,29 +833,33 @@ class PluginChangePermissionApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
def post(self):
current_user, current_tenant_id = current_account_with_tenant()
user = current_user
if not user.is_admin_or_owner:
raise Forbidden()
args = ParserPermissionChange.model_validate(console_ns.payload)
return {
"success": PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
}
tenant_id = current_tenant_id
set_permission_result = PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
return jsonable_encoder({"success": True})
@console_ns.route("/workspaces/current/plugin/permission/fetch")
class PluginFetchPermissionApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginPermissionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
def get(self):
_, tenant_id = current_account_with_tenant()
permission = PluginPermissionService.get_permission(tenant_id)
if not permission:
return jsonable_encoder(
@@ -747,21 +879,21 @@ class PluginFetchPermissionApi(Resource):
@console_ns.route("/workspaces/current/plugin/parameters/dynamic-options")
class PluginFetchDynamicSelectOptionsApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserDynamicOptions))
@console_ns.response(200, "Success", console_ns.models[PluginDynamicOptionsResponse.__name__])
@console_ns.expect(console_ns.models[ParserDynamicOptions.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account):
def get(self):
current_user, tenant_id = current_account_with_tenant()
user_id = current_user.id
args = ParserDynamicOptions.model_validate(request.args.to_dict(flat=True))
try:
options = PluginParameterService.get_dynamic_select_options(
tenant_id=tenant_id,
user_id=current_user.id,
user_id=user_id,
plugin_id=args.plugin_id,
provider=args.provider,
action=args.action,
@@ -778,21 +910,21 @@ class PluginFetchDynamicSelectOptionsApi(Resource):
@console_ns.route("/workspaces/current/plugin/parameters/dynamic-options-with-credentials")
class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
@console_ns.expect(console_ns.models[ParserDynamicOptionsWithCredentials.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginDynamicOptionsResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account):
def post(self):
"""Fetch dynamic options using credentials directly (for edit mode)."""
current_user, tenant_id = current_account_with_tenant()
user_id = current_user.id
args = ParserDynamicOptionsWithCredentials.model_validate(console_ns.payload)
try:
options = PluginParameterService.get_dynamic_select_options_with_credentials(
tenant_id=tenant_id,
user_id=current_user.id,
user_id=user_id,
plugin_id=args.plugin_id,
provider=args.provider,
action=args.action,
@@ -806,51 +938,29 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
return jsonable_encoder({"options": options})
@console_ns.route("/workspaces/current/plugin/preferences/change")
class PluginChangePreferencesApi(Resource):
@console_ns.expect(console_ns.models[ParserPreferencesChange.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginOperationSuccessResponse.__name__])
@console_ns.route("/workspaces/current/plugin/auto-upgrade/change")
class PluginChangeAutoUpgradeApi(Resource):
@console_ns.expect(console_ns.models[ParserAutoUpgradeChange.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeChangeResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
def post(self):
user, tenant_id = current_account_with_tenant()
if not user.is_admin_or_owner:
raise Forbidden()
args = ParserPreferencesChange.model_validate(console_ns.payload)
permission = args.permission
install_permission = permission.install_permission
debug_permission = permission.debug_permission
args = ParserAutoUpgradeChange.model_validate(console_ns.payload)
auto_upgrade = args.auto_upgrade
strategy_setting = auto_upgrade.strategy_setting
upgrade_time_of_day = auto_upgrade.upgrade_time_of_day
upgrade_mode = auto_upgrade.upgrade_mode
exclude_plugins = auto_upgrade.exclude_plugins
include_plugins = auto_upgrade.include_plugins
# set permission
set_permission_result = PluginPermissionService.change_permission(
tenant_id,
install_permission,
debug_permission,
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
# set auto upgrade strategy
set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy(
tenant_id,
strategy_setting,
upgrade_time_of_day,
upgrade_mode,
exclude_plugins,
include_plugins,
auto_upgrade.strategy_setting,
auto_upgrade.upgrade_time_of_day,
auto_upgrade.upgrade_mode,
auto_upgrade.exclude_plugins,
auto_upgrade.include_plugins,
category=args.category,
)
if not set_auto_upgrade_strategy_result:
return jsonable_encoder({"success": False, "message": "Failed to set auto upgrade strategy"})
@@ -858,69 +968,58 @@ class PluginChangePreferencesApi(Resource):
return jsonable_encoder({"success": True})
@console_ns.route("/workspaces/current/plugin/preferences/fetch")
class PluginFetchPreferencesApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginPreferencesResponse.__name__])
@console_ns.route("/workspaces/current/plugin/auto-upgrade/fetch")
class PluginFetchAutoUpgradeApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserAutoUpgradeFetch))
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeFetchResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
permission = PluginPermissionService.get_permission(tenant_id)
permission_dict = {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
}
def get(self):
_, tenant_id = current_account_with_tenant()
if permission:
permission_dict["install_permission"] = permission.install_permission
permission_dict["debug_permission"] = permission.debug_permission
args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True))
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category)
auto_upgrade_dict = (
_auto_upgrade_settings_to_dict(auto_upgrade)
if auto_upgrade
else _default_auto_upgrade_settings(tenant_id, args.category)
)
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id)
auto_upgrade_dict = {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
"upgrade_time_of_day": 0,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
if auto_upgrade:
auto_upgrade_dict = {
"strategy_setting": auto_upgrade.strategy_setting,
"upgrade_time_of_day": auto_upgrade.upgrade_time_of_day,
"upgrade_mode": auto_upgrade.upgrade_mode,
"exclude_plugins": auto_upgrade.exclude_plugins,
"include_plugins": auto_upgrade.include_plugins,
return jsonable_encoder(
{
"category": args.category,
"auto_upgrade": auto_upgrade_dict,
}
return jsonable_encoder({"permission": permission_dict, "auto_upgrade": auto_upgrade_dict})
)
@console_ns.route("/workspaces/current/plugin/preferences/autoupgrade/exclude")
@console_ns.route("/workspaces/current/plugin/auto-upgrade/exclude")
class PluginAutoUpgradeExcludePluginApi(Resource):
@console_ns.expect(console_ns.models[ParserExcludePlugin.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginOperationSuccessResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str):
def post(self):
# exclude one single plugin
_, tenant_id = current_account_with_tenant()
args = ParserExcludePlugin.model_validate(console_ns.payload)
return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id)})
return jsonable_encoder(
{"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id, args.category)}
)
@console_ns.route("/workspaces/current/plugin/readme")
class PluginReadmeApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserReadme))
@console_ns.response(200, "Success", console_ns.models[PluginReadmeResponse.__name__])
@console_ns.expect(console_ns.models[ParserReadme.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
def get(self):
_, tenant_id = current_account_with_tenant()
args = ParserReadme.model_validate(request.args.to_dict(flat=True))
return jsonable_encoder(
{"readme": PluginService.fetch_plugin_readme(tenant_id, args.plugin_unique_identifier, args.language)}
+38 -63
View File
@@ -1,17 +1,14 @@
import logging
import re
from typing import Any
from urllib.parse import quote
from flask import Response, request
from flask_restx import Resource, marshal
from pydantic import RootModel
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.datastructures import MultiDict
from werkzeug.exceptions import NotFound
from controllers.common.fields import TextFileResponse
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.snippets.payloads import (
CreateSnippetPayload,
@@ -24,14 +21,10 @@ from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.snippet_fields import snippet_fields, snippet_list_fields, snippet_pagination_fields
from libs.login import login_required
from models import Account
from libs.login import current_account_with_tenant, login_required
from models.snippet import SnippetType
from services.app_dsl_service import ImportStatus
from services.snippet_dsl_service import SnippetDslService
@@ -42,19 +35,6 @@ _TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
_CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
class SnippetImportResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class SnippetDependencyCheckResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class SnippetUseCountResponse(ResponseModel):
result: str
use_count: int
def _snippet_service() -> SnippetService:
return SnippetService(sessionmaker(bind=db.engine, expire_on_commit=False))
@@ -96,13 +76,6 @@ register_schema_models(
SnippetImportPayload,
IncludeSecretQuery,
)
register_response_schema_models(
console_ns,
TextFileResponse,
SnippetImportResponse,
SnippetDependencyCheckResponse,
SnippetUseCountResponse,
)
# Create namespace models for marshaling
snippet_model = console_ns.model("Snippet", snippet_fields)
@@ -113,14 +86,15 @@ snippet_pagination_model = console_ns.model("SnippetPagination", snippet_paginat
@console_ns.route("/workspaces/current/customized-snippets")
class CustomizedSnippetsApi(Resource):
@console_ns.doc("list_customized_snippets")
@console_ns.doc(params=query_params_from_model(SnippetListQuery))
@console_ns.expect(console_ns.models.get(SnippetListQuery.__name__))
@console_ns.response(200, "Snippets retrieved successfully", snippet_pagination_model)
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
def get(self):
"""List customized snippets with pagination and search."""
_, current_tenant_id = current_account_with_tenant()
query = SnippetListQuery.model_validate(_normalize_snippet_list_query_args(request.args))
snippet_service = _snippet_service()
@@ -150,10 +124,10 @@ class CustomizedSnippetsApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
def post(self):
"""Create a new customized snippet."""
current_user, current_tenant_id = current_account_with_tenant()
payload = CreateSnippetPayload.model_validate(console_ns.payload or {})
try:
@@ -189,9 +163,10 @@ class CustomizedSnippetDetailApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, snippet_id: str):
def get(self, snippet_id: str):
"""Get customized snippet details."""
_, current_tenant_id = current_account_with_tenant()
snippet_service = _snippet_service()
snippet = snippet_service.get_snippet_by_id(
snippet_id=str(snippet_id),
@@ -212,10 +187,10 @@ class CustomizedSnippetDetailApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account, snippet_id: str):
def patch(self, snippet_id: str):
"""Update customized snippet."""
current_user, current_tenant_id = current_account_with_tenant()
snippet_service = _snippet_service()
snippet = snippet_service.get_snippet_by_id(
snippet_id=str(snippet_id),
@@ -256,9 +231,10 @@ class CustomizedSnippetDetailApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def delete(self, current_tenant_id: str, snippet_id: str):
def delete(self, snippet_id: str):
"""Delete customized snippet."""
_, current_tenant_id = current_account_with_tenant()
snippet_service = _snippet_service()
snippet = snippet_service.get_snippet_by_id(
snippet_id=str(snippet_id),
@@ -284,16 +260,16 @@ class CustomizedSnippetExportApi(Resource):
@console_ns.doc("export_customized_snippet")
@console_ns.doc(description="Export snippet configuration as DSL")
@console_ns.doc(params={"snippet_id": "Snippet ID to export"})
@console_ns.doc(params=query_params_from_model(IncludeSecretQuery))
@console_ns.response(200, "Snippet exported successfully", console_ns.models[TextFileResponse.__name__])
@console_ns.response(200, "Snippet exported successfully")
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def get(self, current_tenant_id: str, snippet_id: str):
def get(self, snippet_id: str):
"""Export snippet as DSL."""
_, current_tenant_id = current_account_with_tenant()
snippet_service = _snippet_service()
snippet = snippet_service.get_snippet_by_id(
snippet_id=str(snippet_id),
@@ -329,16 +305,16 @@ class CustomizedSnippetImportApi(Resource):
@console_ns.doc("import_customized_snippet")
@console_ns.doc(description="Import snippet from DSL")
@console_ns.expect(console_ns.models.get(SnippetImportPayload.__name__))
@console_ns.response(200, "Snippet imported successfully", console_ns.models[SnippetImportResponse.__name__])
@console_ns.response(202, "Import pending confirmation", console_ns.models[SnippetImportResponse.__name__])
@console_ns.response(200, "Snippet imported successfully")
@console_ns.response(202, "Import pending confirmation")
@console_ns.response(400, "Import failed")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
def post(self, current_user: Account):
def post(self):
"""Import snippet from DSL."""
current_user, _ = current_account_with_tenant()
payload = SnippetImportPayload.model_validate(console_ns.payload or {})
with Session(db.engine) as session:
@@ -368,15 +344,16 @@ class CustomizedSnippetImportConfirmApi(Resource):
@console_ns.doc("confirm_snippet_import")
@console_ns.doc(description="Confirm a pending snippet import")
@console_ns.doc(params={"import_id": "Import ID to confirm"})
@console_ns.response(200, "Import confirmed successfully", console_ns.models[SnippetImportResponse.__name__])
@console_ns.response(200, "Import confirmed successfully")
@console_ns.response(400, "Import failed")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
def post(self, current_user: Account, import_id: str):
def post(self, import_id: str):
"""Confirm a pending snippet import."""
current_user, _ = current_account_with_tenant()
with Session(db.engine) as session:
import_service = SnippetDslService(session)
result = import_service.confirm_import(import_id=import_id, account=current_user)
@@ -392,19 +369,16 @@ class CustomizedSnippetCheckDependenciesApi(Resource):
@console_ns.doc("check_snippet_dependencies")
@console_ns.doc(description="Check dependencies for a snippet")
@console_ns.doc(params={"snippet_id": "Snippet ID"})
@console_ns.response(
200,
"Dependencies checked successfully",
console_ns.models[SnippetDependencyCheckResponse.__name__],
)
@console_ns.response(200, "Dependencies checked successfully")
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def get(self, current_tenant_id: str, snippet_id: str):
def get(self, snippet_id: str):
"""Check dependencies for a snippet."""
_, current_tenant_id = current_account_with_tenant()
snippet_service = _snippet_service()
snippet = snippet_service.get_snippet_by_id(
snippet_id=str(snippet_id),
@@ -426,15 +400,16 @@ class CustomizedSnippetUseCountIncrementApi(Resource):
@console_ns.doc("increment_snippet_use_count")
@console_ns.doc(description="Increment snippet use count by 1")
@console_ns.doc(params={"snippet_id": "Snippet ID"})
@console_ns.response(200, "Use count incremented successfully", console_ns.models[SnippetUseCountResponse.__name__])
@console_ns.response(200, "Use count incremented successfully")
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def post(self, current_tenant_id: str, snippet_id: str):
def post(self, snippet_id: str):
"""Increment snippet use count when it is inserted into a workflow."""
_, current_tenant_id = current_account_with_tenant()
snippet_service = _snippet_service()
snippet = snippet_service.get_snippet_by_id(
snippet_id=str(snippet_id),

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