Compare commits

..

433 Commits

Author SHA1 Message Date
yyh
5619062ad1 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2
# Conflicts:
#	web/app/components/main-nav/__tests__/index.spec.tsx
2026-06-08 17:45:12 +08:00
yyh
38af34a742 fix: keep global nav account expanded 2026-06-08 17:44:02 +08:00
yyh
1bf2748130 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2
# Conflicts:
#	web/app/components/main-nav/__tests__/index.spec.tsx
#	web/app/components/main-nav/index.tsx
2026-06-08 17:40:18 +08:00
a6084ca3c7 chore: chunking Setting move place 2026-06-08 17:27:42 +08:00
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
5582b35d56 chroe: copywriting 2026-06-08 17:04:28 +08:00
db83df9f9c chore: knowledge icon 2026-06-08 17:04:28 +08:00
0683c0e7a7 chore: sidbar hover toggle and no data icon 2026-06-08 17:04:28 +08:00
42b7bf8152 feat: gen app starred openAPI 2026-06-08 16:53:35 +08:00
bc33ef1b97 feat: app star and its list 2026-06-08 16:47:26 +08:00
0f493a52a1 fix: annotation page highlight 2026-06-08 16:35:17 +08:00
yyh
fc7cd1100e Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-08 16:32:24 +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
453c4c4c5f chore: update annotations 2026-06-08 16:20:44 +08:00
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
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
yyh
d0b376d31a feat(web): support search input autofocus (#37175) 2026-06-08 07:40:09 +00:00
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
56c569d6af Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-08 14:51:22 +08:00
yyh
11f78289d3 fix: highlight integration popover triggers 2026-06-08 14:50:18 +08:00
yyh
85e600e579 Merge branch 'feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-08 14:37:21 +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
9c24b7bac5 chore(web): sync i18n (#37169)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-08 06:23:38 +00:00
yyh
c56e9813bb Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-08 14:22:34 +08:00
yyh
6ea9ba5926 fix: question icon style 2026-06-08 14:20:29 +08:00
6291452020 refactor(web): mark Props of base/ components as read-only (#25219) (#37161) 2026-06-08 05:48:04 +00:00
d46a4c05b1 fix(web): z-index issue of variable picker in prompt editor (#37163) 2026-06-08 05:39:06 +00:00
yyh
7a9054fdea fix: workspace filter style 2026-06-08 13:30:00 +08:00
yyh
1820e6eab8 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-08 13:17:06 +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
f15a8f02ef ci: add flag for linter (#37018)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-08 04:53:12 +00:00
yyh
0c4b36b3f5 chore: update npm deps (#37156) 2026-06-08 04:38:47 +00:00
yyh
2bf60a67ad fix: style 2026-06-08 12:36:17 +08:00
37e1d452b8 feat(api): add MCP user-identity forwarding (#36839)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-08 04:32:11 +00:00
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
0dd2d36d68 fix: align api 2026-06-08 11:00:41 +08:00
yyh
786c7190f0 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-08 10:42:02 +08:00
yyh
ed10b82bb1 fix: export 2026-06-08 10:40:36 +08:00
yyh
ba19aee2bf Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-08 10:35:07 +08:00
yyh
41106ef6c9 fix: lint and type-check 2026-06-08 10:34:16 +08:00
db1aa683bc feat(web): gate /create and /refine slash commands behind feature preview flag (#37094)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 02:32:52 +00: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
yyh
a88c15c906 fix(web): align viewport and overlay accessibility (#37142)
Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-08 01:45:39 +00:00
yyh
12bd8d2aa8 style(dify-ui): align focus rings (#37144) 2026-06-08 01:34:43 +00:00
813bfea730 feat(api): support embedded Excel images in knowledge import (#37104) 2026-06-08 01:26:07 +00:00
f0bcb77d55 fix(web): update preferences active icon 2026-06-07 00:12:29 -07:00
44a89bf870 fix(web): align main nav glass tokens 2026-06-07 00:11:58 -07:00
60ad023553 Revert "chore(web): remove explicit plugin type anys"
This reverts commit 9c6a7679ac.
2026-06-06 22:29:41 -07:00
9c6a7679ac chore(web): remove explicit plugin type anys 2026-06-06 22:06:10 -07:00
11b2ba29c1 fix(web): preserve marketplace install dialog during loading 2026-06-06 21:54:42 -07:00
c52df73117 fix(web): align integration loading skeletons with loaded cards 2026-06-06 21:54:42 -07:00
9b8d81c852 fix(web): keep plugin install tasks visible while pending 2026-06-06 21:54:42 -07:00
b28b2892f1 chroe: remove app create card 2026-06-06 10:42:13 +08:00
7f5349e707 chore: type selctc 2026-06-06 10:33:14 +08:00
0aaa1df1b8 chroe: tags and types copywriting 2026-06-06 10:19:55 +08:00
bd41c5c3c0 chore: tags filter and pipeline conrner 2026-06-06 10:04:22 +08:00
95a2eea611 chore: knowledge align and remove create 2026-06-06 09:38:08 +08:00
yyh
d959c73884 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-05 12:26:16 +08:00
yyh
19193891cf fix: remove redundant main nav link titles 2026-06-05 12:23:50 +08:00
4746dd39d3 chore: only cloud can show upgrade 2026-06-05 11:36:18 +08:00
a5e6f58285 fix(web): resolve lint issues 2026-06-04 20:19:05 -07:00
4bf0398873 fix(web): use workspace last opened time 2026-06-04 20:19:05 -07:00
e6db98ef64 feat: add task detail 2026-06-05 11:13:54 +08:00
6b694ce829 fix(web): polish tool provider details 2026-06-04 19:58:45 -07:00
0ab3b5d677 feat(web): improve plugins panel filtering 2026-06-04 19:58:45 -07:00
346fabda27 fix(web): redirect legacy integration routes 2026-06-04 19:58:45 -07:00
3da0f12815 fix(web): refine plugin task popover 2026-06-04 19:58:45 -07:00
303cff1353 feat(web): improve data source plugin actions 2026-06-04 19:58:45 -07:00
10c3849887 fix(web): improve model selector empty state 2026-06-04 19:58:45 -07:00
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
92fdbd5c51 fix: css 2026-06-05 10:37:21 +08:00
yyh
09053ab760 fix: use controlled form validation 2026-06-05 10:28:32 +08:00
yyh
c1afdc030c Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-05 10:18:46 +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
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
5c2f4709a5 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-04 20:46:36 +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
de0ccbb960 fix: question mark ui 2026-06-04 18:31:06 +08:00
yyh
0493552c73 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-04 18:05:33 +08:00
407d3e28bb fix: focus template focus ring not show the full 2026-06-04 17:24:28 +08:00
8a51b3a296 chroe: remove mock logic 2026-06-04 17:02:47 +08:00
yyh
8fc3882042 refactor: normalize agent v2 translation hooks 2026-06-04 16:56:25 +08:00
yyh
0ffb9667d4 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2
# Conflicts:
#	web/app/components/main-nav/__tests__/index.spec.tsx
#	web/app/components/main-nav/index.tsx
2026-06-04 16:54:53 +08:00
yyh
e7886c1bac fix: format agent roster updated time 2026-06-04 16:51:29 +08:00
41894ad182 chore: uis 2026-06-04 16:46:41 +08:00
95ea709c91 chroe: knowledge info more info 2026-06-04 16:46:41 +08:00
3a01b91a45 chore: sidebar ui 2026-06-04 16:46:41 +08:00
f50abac3f9 chore: knowledge info 2026-06-04 16:46:41 +08:00
7f44b2f601 chore: knowledge sidebar ui 2026-06-04 16:46:41 +08:00
yyh
9583f17960 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-04 16:43:34 +08:00
yyh
c71d2ac460 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-04 16:43:22 +08:00
yyh
505ad3994c fix(agent-v2): align configure queries and skeletons 2026-06-04 16:41:15 +08:00
yyh
87c6a82df0 fix(agent-v2): show detail title skeleton while loading 2026-06-04 16:35:55 +08:00
yyh
0d97d44222 feat(agent-v2): show memory config from contracts 2026-06-04 16:33:11 +08:00
yyh
64f1d125a6 fix(agent-v2): focus roster dialog name fields 2026-06-04 16:25:34 +08:00
yyh
090ef21881 feat(agent-v2): connect detail header to contracts 2026-06-04 16:23:44 +08:00
yyh
56ed953e2a feat(agent-v2): restore roster sidebar 2026-06-04 16:14:47 +08:00
yyh
f61f15371a Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-04 16:06:58 +08:00
yyh
1a7542052e style: create from template modal 2026-06-04 16:06:46 +08:00
yyh
009c6adc8f feat(agent-v2): connect roster to generated contracts 2026-06-04 16:05:50 +08:00
6d26f6ea73 chore: handle tempalte and learn dify click to new 2026-06-04 14:45:02 +08:00
yyh
35e21de9f8 style: create from template modal 2026-06-04 14:43:17 +08:00
yyh
8c925b1422 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-04 14:25:26 +08:00
b0d3347de9 chore: padding 2026-06-04 14:12:00 +08:00
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
d407c1fbf7 chore(integrations): update endpoint copy and header tests 2026-06-03 21:19:26 -07:00
fe6ee8aa04 style(plugins): align empty states and remove actions 2026-06-03 21:19:25 -07:00
25228e3cde fix(integrations): align tool create actions 2026-06-03 21:19:25 -07:00
8049da9331 fix(integrations): align custom endpoint toolbar with design 2026-06-03 21:19:25 -07:00
e404195c8c fix(integrations): align navigation and marketplace layout 2026-06-03 21:19:25 -07:00
b3c7110768 fix(plugins): show correct badges and endpoint metadata 2026-06-03 21:19:25 -07:00
7b2b21c348 feat(integrations): use plugin category APIs for integration pages 2026-06-03 21:19:24 -07:00
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
a24d1a0bbc feat: add PluginCategory openAPI 2026-06-04 11:52:35 +08:00
fffa89a10e chore: handle tag icon and jump action 2026-06-03 18:12:38 +08:00
422461f360 chore: handle card ui 2026-06-03 17:56:47 +08:00
2ce913aaac fix: slide scroll show other slide promble 2026-06-03 17:40:23 +08:00
30f0a69fea feat: add builtin tools 2026-06-03 17:29:41 +08:00
04e8c6127f Revert "feat: PluginInstallTaskStartResponse add optional PluginInstallTask"
This reverts commit ccc9122980.
2026-06-03 17:21:07 +08:00
20e37a0457 chore: add become a partnter link 2026-06-03 17:08:00 +08:00
36c7209301 chroe: description vertical padding 2026-06-03 16:54:24 +08:00
ccc9122980 feat: PluginInstallTaskStartResponse add optional PluginInstallTask 2026-06-03 16:51:22 +08:00
9143d44ec6 chore: new all plugin icon and request comp 2026-06-03 16:45:42 +08:00
1f856960f0 feat: PluginList by category 2026-06-03 16:35:33 +08:00
a8218d5809 chore: market header align 2026-06-03 16:25:24 +08:00
70ec17bc34 feat: support scroll to show more plugins in group 2026-06-03 16:15:19 +08:00
e4ea9d2e07 feat: marketplace page change; 2026-06-03 15:50:34 +08:00
yyh
fd342ccac0 fix: use atoms 2026-06-03 15:22:27 +08:00
yyh
929a1da26c Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-03 14:57:02 +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
3a2ea826ff fix(plugins): refresh model defaults after plugin changes 2026-06-02 15:54:52 -07:00
4581ce2f45 style(model-provider): align model selectors 2026-06-02 15:54:15 -07:00
3925b2bf4f fix(model-provider): handle unavailable credentials 2026-06-02 15:53:45 -07:00
5744148e6d style(model-provider): align quota panel 2026-06-02 15:53:18 -07:00
6ba42e6d73 refactor(model-provider): split provider page body 2026-06-02 15:52:42 -07:00
aff22cb5ed refactor integrations header layout 2026-06-02 11:19:28 -07:00
58c4a174ba align model provider toolbar copy 2026-06-02 11:19:27 -07:00
c98f65cbcb refactor integrations tool provider cards 2026-06-02 11:19:27 -07:00
add7c75f18 chore: stadio no app tiny 2026-06-02 17:53:12 +08:00
75e74ee8b9 chore: no knowledge some tiny 2026-06-02 17:51:10 +08:00
40a5236553 fix: learn dify no ssr to fix not same to server side if not on 2026-06-02 17:38:09 +08:00
f820813e9f chore: knowledge empty page 2026-06-02 17:25:15 +08:00
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
777265d898 chore: no apps page 2026-06-02 16:47:58 +08:00
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
284c1027da Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-02 15:48:48 +08:00
yyh
ba06ed5f41 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-06-02 15:48:10 +08:00
3fd9d5eb14 chore: install border and nav top 2026-06-02 15:38:13 +08:00
yyh
a70e8eb2b5 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-02 15:37:50 +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
72c3cd0d67 chore: installed app 2026-06-02 15:24:32 +08:00
fd0cb47a81 chore: spacing in home page 2026-06-02 14:52:37 +08:00
87382673f3 chore: templates ui 2026-06-02 14:47:38 +08:00
3c716a6eee chore: learn dify sketlon 2026-06-02 14:32:41 +08:00
8da34fb60b chore: update banner and learn dify ui 2026-06-02 14:24:18 +08:00
f0efb73fd0 chroe: recents ui and home padding x 2026-06-02 13:51:17 +08:00
e2bd2355e9 chroe: app sidebar and continue work 2026-06-02 13:46:54 +08:00
73b50f5ede chore: add splits in nav 2026-06-02 13:36:46 +08:00
52cfe62d8d fix: home page auto jump to /apps 2026-06-02 11:24:10 +08:00
f293253a7a feat: add logs and annotions title 2026-06-02 11:11:59 +08:00
1ac3ad4c81 chore: split logs and annotations page 2026-06-02 11:01:38 +08:00
2d6fd70733 feat: align integrations auto-update settings dialog 2026-06-01 16:00:52 -07:00
639c8d5967 chore: update frontend review component guidance 2026-06-01 16:00:15 -07:00
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
791296cc8d Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-06-01 13:10:49 +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
e525880773 feat(integrations): reorganize sidebar navigation 2026-05-31 20:06:06 -07:00
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
08bbd3bfdf Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-05-31 22:02:20 +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
87a3980c76 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-05-30 14:00:06 +08:00
yyh
27bdee85fe fix(auth): avoid leaking request origin in refresh redirects 2026-05-30 13:59:53 +08:00
cca8295ad8 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-29 11:27:07 -07:00
yyh
f8cc85ce28 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-05-29 22:17:43 +08: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
2d4e494162 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-05-29 20:39:28 +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
yyh
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
5e1fac09bb Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2
# Conflicts:
#	web/app/components/main-nav/index.tsx
2026-05-29 11:54:10 +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
5a2604265c fix: align integrations search empty states 2026-05-28 12:43:58 -07:00
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
8c7540f698 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2
# Conflicts:
#	web/app/components/main-nav/index.tsx
2026-05-28 16:44:17 +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
89571bd241 fix: type 2026-05-28 14:19:43 +08:00
yyh
afee58cca7 refactor: move to agent detail 2026-05-28 14:18:43 +08:00
yyh
76a55535f2 fix: style 2026-05-28 14:09:58 +08:00
29cb993042 feat: add memory and enchance version list 2026-05-28 14:07:00 +08:00
yyh
00581a4daa feat(agent-v2): align monitoring charts 2026-05-28 13:56:44 +08:00
yyh
bfc71bb087 refactor(agent-v2): organize vertical modules 2026-05-28 13:42:04 +08:00
yyh
a95a6ea263 feat(agent-v2): add access sharing page 2026-05-28 13:36:42 +08:00
yyh
264e97a4c2 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-05-28 12:52:04 +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
43f67ef2d1 feat: publish and version 2026-05-28 11:16:15 +08:00
yyh
c118fe9ad2 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-05-28 10:19:07 +08:00
0c96426d91 fix: link app sidebar studio breadcrumb 2026-05-27 18:47:52 -07:00
67fee14770 feat: migrate installed app route 2026-05-27 17:38:33 -07:00
d94006162d refactor: split large onboarding UI components 2026-05-27 17:04:42 -07:00
3d53cee8a9 fix: render home loading skeletons 2026-05-27 16:46:51 -07:00
1acd1b568a fix: add integration loading placeholders 2026-05-27 16:46:23 -07:00
68f939f3b3 fix(web): narrow home active state 2026-05-27 14:52:25 -07:00
1f4b76ba7e fix(web): scope common layout loading and marketplace title 2026-05-27 14:51:57 -07:00
4d974d8f72 fix(web): use scroll areas for integrations lists 2026-05-27 14:51:31 -07:00
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
7e34e2347a Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-05-27 20:15:47 +08:00
yyh
94a376a5a7 Merge remote-tracking branch 'origin/main' into feat/ui-onboarding-rewrite 2026-05-27 20:11:47 +08:00
33f6b0c9aa feat: agent version 2026-05-27 17:50:09 +08:00
yyh
2b130d0d2a Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-05-27 16:44:05 +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
4f9adfb9ae chore: publish 2026-05-27 16:39:10 +08:00
yyh
f3974d6176 Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-05-27 16:22:34 +08:00
yyh
ef00f850e4 fix: use chip for app type selector 2026-05-27 16:18:49 +08:00
yyh
cb2e404eb6 feat: agent log details 2026-05-27 16:02:01 +08:00
yyh
14e7fc87e4 fix(web): prevent agent nav active layout shift 2026-05-27 15:38:50 +08:00
yyh
40b4c3476d feat(web): add agent monitoring page 2026-05-27 15:37:23 +08:00
1c641d2b44 chore: remove roster annotation 2026-05-27 15:32:42 +08:00
yyh
c3c9a349cc Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2 2026-05-27 15:23:49 +08:00
yyh
169293c8da feat(web): add agent detail scaffold 2026-05-27 14:54:48 +08:00
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
dcd40b5004 fix install_app n+1 query 2026-05-27 14:44:00 +08:00
yyh
bcc4b208c7 feat(web): add agent roster scaffold 2026-05-27 14:27:51 +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
755760b97c refactor(web): migrate mcp modal controls to dify ui primitives 2026-05-26 19:06:52 -07:00
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
065246a9a7 Merge origin/main into feat/ui-onboarding-rewrite 2026-05-26 17:50:55 -07:00
0d12b5ab1c test(web): cover integrations permission-gated actions 2026-05-21 16:44:33 -07:00
514dcae189 style(web): align update setting time picker token 2026-05-21 15:49:39 -07:00
228dd84a91 refactor(web): tighten tools provider card and marketplace handling 2026-05-21 15:49:27 -07:00
336ddad096 fix(web): expose current nav links to assistive tech 2026-05-21 15:49:13 -07:00
92bb9a17b7 fix(web): scope main nav fullscreen canvas routes 2026-05-21 15:49:02 -07:00
b8868dab90 fix(integrations): update install success action copy 2026-05-21 10:54:43 -07:00
94225682cd docs: update main nav and integrations follow-ups 2026-05-20 17:11:16 -07:00
18b6568c2a fix: refine integrations sidebar controls 2026-05-20 16:21:25 -07:00
a3a9ded29b chore: localize main nav and integrations copy 2026-05-20 15:50:52 -07:00
de78a26920 fix: scope dataset detail navigation routes 2026-05-20 15:50:20 -07:00
c54d029e7c fix: restore dataset list markup 2026-05-20 15:49:49 -07:00
ad4b9dc2c3 refactor: reuse toggle group in update settings 2026-05-20 12:42:53 -07:00
cdec0c69a6 chore: learn dify try action same to template 2026-05-20 18:04:43 +08:00
53acc3726c merge 2026-05-20 17:43:06 +08:00
b1d393f4d9 chore: hide select model provider in model provider page 2026-05-20 15:22:14 +08:00
62e9bdd70d chore: app permission show in app card 2026-05-20 15:00:18 +08:00
d36c76c20e merge 2026-05-20 14:34:04 +08:00
f525e1a5eb fix(web): align onboarding and integrations i18n copy 2026-05-19 16:03:01 -07:00
e2f779b20d chore: load 8 contiue items 2026-05-19 14:54:23 +08:00
e198d6305c merge 2026-05-19 14:14:51 +08:00
5e67514265 chore: support edcation action 2026-05-19 13:56:49 +08:00
b63896de87 feat: learn dify use api 2026-05-19 13:44:14 +08:00
e463389f2c feat: improve integration install flows 2026-05-18 20:55:05 -07:00
cda348ca10 feat: split plugin settings by category 2026-05-18 20:54:32 -07:00
ca48050666 feat: refine integrations page shell controls 2026-05-18 20:54:11 -07:00
9c0f592f34 feat: open integrations settings in account modal 2026-05-18 20:53:47 -07:00
b70241ad36 fix: app list not refresh 2026-05-18 12:11:08 -07:00
4abe622b2e feat: continue with use the app list data 2026-05-18 12:10:58 -07:00
16c32c82e3 feat: knowledge new sidebar 2026-05-18 12:10:48 -07:00
46424513d1 chore: missing files 2026-05-18 12:10:37 -07:00
2c4baa20d8 feat: app new nav 2026-05-18 12:10:25 -07:00
b0ae553f2e fix(web): correct custom icon class names 2026-05-18 12:07:16 -07:00
0266a12ee5 fix(web): align rebased UI type contracts 2026-05-18 11:19:12 -07:00
9d7765d5fd docs: update main nav follow-up notes 2026-05-18 11:16:16 -07:00
d4ef983f42 refactor(web): organize integrations page helpers 2026-05-18 11:16:16 -07:00
018f36711d fix(web): route document settings to integrations 2026-05-18 11:16:16 -07:00
dacd333e4a chore(i18n): rename plugin-facing copy to integrations 2026-05-18 11:16:16 -07:00
b079a26314 fix(web): gate integrations install actions 2026-05-18 11:16:15 -07:00
7e953ebe0b feat(web): complete update setting popover 2026-05-18 11:16:15 -07:00
b4d28fca54 fix(web): polish integration page titles 2026-05-18 11:16:15 -07:00
728c6b8201 chore: rename to marketplace path 2026-05-18 11:16:15 -07:00
f56e23b5fd chore: remove discover entrance 2026-05-18 11:16:15 -07:00
5600cefa53 feat: add interation discover route 2026-05-18 11:16:15 -07:00
561eb9cbd2 fix: trigger, agent-strategry, extension problem 2026-05-18 11:16:15 -07:00
83766ca694 chore: new pages add to dataset route guard 2026-05-18 11:16:15 -07:00
678be94d22 fix: custom tool copywriting 2026-05-18 11:16:15 -07:00
9e852429be chore: split logic from accont setting and integrating setting 2026-05-18 11:16:15 -07:00
d93c5028f1 chore: rename to integration setting 2026-05-18 11:14:15 -07:00
54f189305e chore: use new hook to handle setting 2026-05-18 11:13:33 -07:00
a610a24507 chore: filter apps and knowledges no data 2026-05-18 11:12:17 -07:00
05e8a94bb5 fix: not configure default model tip not align 2026-05-18 11:12:17 -07:00
b2e2e7b60b chore: homepage coninue with to improve 2026-05-18 11:12:17 -07:00
e7d2e66ff5 chore: popup create hide some 2026-05-18 11:12:17 -07:00
c51069685c chore: some tiny style 2026-05-18 11:12:17 -07:00
28c208f36a feat: knowledge items 2026-05-18 11:12:17 -07:00
53a1386b87 feat: knowledge title 2026-05-18 11:12:17 -07:00
0e366c7300 chore: show no empty logic 2026-05-18 11:12:17 -07:00
939bdde373 feat: knowledge empty list 2026-05-18 11:12:17 -07:00
13dfa3aba4 feat(integrations): add unavailable page fallback 2026-05-18 11:12:16 -07:00
2705a7c1db feat(integrations): align tools and plugin category UI 2026-05-18 11:12:16 -07:00
258a751b8c feat(integrations): improve data source plugin management 2026-05-18 11:12:16 -07:00
5a35d3d9cd feat(plugin): add update settings popover 2026-05-18 11:12:16 -07:00
c3fbafae83 chore(i18n): localize integrations updates 2026-05-18 11:12:16 -07:00
f727c8f838 docs: update frontend agent guidance 2026-05-18 11:12:16 -07:00
90af4c39b4 chore: some small ui 2026-05-18 11:12:16 -07:00
f7c3a4e4cb feat: empty page 2026-05-18 11:12:16 -07:00
be7d043edd chore: remove mock app data 2026-05-18 11:12:16 -07:00
cef8fe3a4b chore: remove shortcut 2026-05-18 11:12:16 -07:00
afe0e6c393 chore: missing files 2026-05-18 11:12:16 -07:00
37309b931e feat: new head 2026-05-18 11:12:15 -07:00
6a83c6705c temp: app hearder 2026-05-18 11:10:59 -07:00
3e75d5e443 chore: create app card 2026-05-18 11:10:11 -07:00
7be8a5b883 chore: app card ui 2026-05-18 11:10:11 -07:00
80dcb344f4 docs: record integrations install permission follow-up 2026-05-18 11:10:11 -07:00
b029c9b1cd feat: add integrations plugin category views 2026-05-18 11:10:11 -07:00
6cb97e9201 fix: align tools and mcp provider behavior 2026-05-18 11:10:11 -07:00
4ef2e952bd feat: add integrations page shell refinements 2026-05-18 11:10:10 -07:00
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
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
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
5a6ba2ffb5 fix: localize integrations i18n copy 2026-05-18 11:09:15 -07:00
aa53afe07d fix: update custom tool integration route 2026-05-18 11:09:14 -07:00
4740a89f4a feat: add canonical integrations routes 2026-05-18 11:09:14 -07:00
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
88062fb247 feat: explore page to home page 2026-05-18 11:09:14 -07:00
045da59220 chore: app card icon and palce of learn dify 2026-05-18 11:09:14 -07:00
948b0f6bc7 chore: templates item ui and learn dify 2026-05-18 11:09:14 -07:00
14a59f6e44 chore: tag ui 2026-05-18 11:09:14 -07:00
f9f361113e feat: add description and tag filter 2026-05-18 11:09:14 -07:00
eea6f59307 chore: remove more learning templates and templates copywrite 2026-05-18 11:09:14 -07:00
718f69dc43 feat: hide learn dify anim effect 2026-05-18 11:09:14 -07:00
82a2ba9264 feat: learn dify 2026-05-18 11:09:14 -07:00
6c8e032fbb chore: fix small css 2026-05-18 11:09:14 -07:00
28c2c3bfd3 chore: split icon to new file and enchance data struct 2026-05-18 11:09:14 -07:00
9d463e1024 feat: continue work 2026-05-18 11:09:14 -07:00
7f87616625 chore: no show slide logic 2026-05-18 11:09:14 -07:00
43a04ed0c2 feat: finish slide 2026-05-18 11:09:13 -07:00
5083edd0ce fix: align main nav gating and account popup behavior 2026-05-18 11:09:13 -07:00
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
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
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
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
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
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
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
0292bc2728 feat: refine desktop main nav visuals 2026-05-18 11:09:13 -07:00
5c21120977 feat: add desktop main navigation 2026-05-18 11:09:13 -07:00
1282 changed files with 48492 additions and 12342 deletions

View File

@ -0,0 +1,33 @@
---
name: karpathy-guidelines
description: Lightweight coding guardrails for making focused, simple, and verifiable changes in this repo. Use for all coding work.
---
# Karpathy Guidelines
Use this skill whenever you touch code in this repository.
## Principles
- Keep the change small and directly tied to the user request.
- Prefer the simplest implementation that fits the existing codebase.
- Read the nearby code first, then match its patterns.
- Avoid unrelated refactors, broad rewrites, or style churn.
- Preserve existing behavior unless the user explicitly asked to change it.
- Treat regressions as a signal to narrow the change, not to add workaround layers.
## Workflow
1. Inspect the current implementation and tests around the change.
2. Make the smallest coherent edit.
3. Add or update focused tests when the behavior changes or the risk is non-trivial.
4. Run the narrowest relevant verification first.
5. Report exactly what was verified and anything left unverified.
## Review Checklist
- Does this change solve the stated problem without expanding scope?
- Did it preserve existing route/component/data-flow semantics?
- Are new abstractions justified by real complexity?
- Are tests focused on the behavior that could regress?
- Are unrelated files and generated artifacts left alone?

View File

@ -29,13 +29,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@ -91,13 +91,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@ -142,13 +142,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
@ -195,7 +195,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
files: ./coverage.xml
disable_search: true

View File

@ -20,7 +20,7 @@ jobs:
run: echo "autofix.ci updates pull request branches, not merge group refs."
- if: github.event_name != 'merge_group'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check Docker Compose inputs
if: github.event_name != 'merge_group'
@ -66,7 +66,7 @@ jobs:
python-version: "3.11"
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'

View File

@ -68,7 +68,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
@ -78,13 +78,13 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env[matrix.image_name_env] }}
- name: Build Docker image
id: build
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ${{ matrix.build_context }}
@ -124,10 +124,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Validate Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.build_context }}
@ -156,14 +156,14 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env[matrix.image_name_env] }}
tags: |

View File

@ -35,7 +35,7 @@ jobs:
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -98,7 +98,7 @@ jobs:
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 1
@ -114,7 +114,7 @@ jobs:
run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV"
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.0.2
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
with:
bun-version-file: cli/.bun-version

View File

@ -24,7 +24,7 @@ jobs:
shell: bash
steps:
- name: Checkout cli ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false

View File

@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -46,7 +46,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
directory: cli/coverage
flags: cli

View File

@ -13,13 +13,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
@ -40,7 +40,7 @@ jobs:
cp envs/middleware.env.example middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.middleware.yaml
@ -63,13 +63,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
@ -94,7 +94,7 @@ jobs:
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.middleware.yaml

View File

@ -53,7 +53,7 @@ jobs:
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Build Docker Image
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
push: false
@ -77,10 +77,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build Docker Image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.context }}

View File

@ -24,7 +24,7 @@ jobs:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

View File

@ -48,7 +48,7 @@ jobs:
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:

View File

@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true

View File

@ -21,10 +21,10 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
steps:
- name: Checkout default branch (trusted code)
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true

View File

@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true

View File

@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
days-before-issue-stale: 15
days-before-issue-close: 3

View File

@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -33,7 +33,7 @@ jobs:
- name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: false
python-version: "3.12"
@ -71,7 +71,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -114,7 +114,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -171,7 +171,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false

View File

@ -24,7 +24,7 @@ jobs:
working-directory: sdks/nodejs-client
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

View File

@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@fbda2eb1bdc90d319b8d853f5deb53bca199a7c1 # v1.0.140
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

View File

@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -28,7 +28,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"

View File

@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -83,7 +83,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
directory: web/coverage
flags: web
@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -117,7 +117,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
directory: packages/dify-ui/coverage
flags: dify-ui

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",

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)

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
@ -14,7 +15,12 @@ from werkzeug.exceptions import BadRequest
from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse
from controllers.common.helpers import FileInfo
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.common.schema import (
query_params_from_model,
register_enum_models,
register_response_schema_models,
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model, with_session
from controllers.console.workspace.models import LoadBalancingPayload
@ -36,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 (
@ -68,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")
@ -114,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]] = []
@ -377,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
@ -446,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, RedirectUrlResponse, SimpleResultResponse)
register_schema_models(
console_ns,
AppListQuery,
StarredAppListQuery,
CreateAppPayload,
UpdateAppPayload,
CopyAppPayload,
@ -495,7 +556,7 @@ register_schema_models(
class AppListApi(Resource):
@console_ns.doc("list_apps")
@console_ns.doc(description="Get list of applications with pagination and filtering")
@console_ns.expect(console_ns.models[AppListQuery.__name__])
@console_ns.doc(params=query_params_from_model(AppListQuery))
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
@setup_required
@login_required
@ -511,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,
@ -524,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
@ -599,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")

View File

@ -155,19 +155,28 @@ class InstalledAppsListApi(Resource):
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)
installed_app_list: list[dict[str, Any]] = [
{
"id": installed_app.id,
"app": installed_app.app,
"app_owner_tenant_id": installed_app.app_owner_tenant_id,
"is_pinned": installed_app.is_pinned,
"last_used_at": installed_app.last_used_at,
"editable": current_user.role in {"owner", "admin"},
"uninstallable": current_tenant_id == installed_app.app_owner_tenant_id,
}
for installed_app in installed_apps
if installed_app.app is not None
]
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 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,
"app": app_model,
"app_owner_tenant_id": installed_app.app_owner_tenant_id,
"is_pinned": installed_app.is_pinned,
"last_used_at": installed_app.last_used_at,
"editable": current_user.role in {"owner", "admin"},
"uninstallable": current_tenant_id == installed_app.app_owner_tenant_id,
}
)
# filter out apps that user doesn't have access to
if FeatureService.get_system_features().webapp_auth.enabled:

View File

@ -65,15 +65,28 @@ class RecommendedAppListResponse(ResponseModel):
categories: list[str]
class LearnDifyAppListResponse(ResponseModel):
recommended_apps: list[RecommendedAppResponse]
register_schema_models(
console_ns,
RecommendedAppsQuery,
RecommendedAppInfoResponse,
RecommendedAppResponse,
RecommendedAppListResponse,
LearnDifyAppListResponse,
)
def _resolve_language(language: str | None, user: Account) -> str:
if language and language in languages:
return language
if user.interface_language:
return user.interface_language
return languages[0]
@console_ns.route("/explore/apps")
class RecommendedAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@ -84,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),
@ -98,6 +105,23 @@ 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):
@login_required

View File

@ -1,28 +1,51 @@
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
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 SuccessResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.common.schema import (
query_params_from_model,
register_enum_models,
register_response_schema_models,
register_schema_models,
)
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
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.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):
@ -30,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]
@ -88,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):
@ -125,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):
@ -145,9 +200,67 @@ class PluginDebuggingKeyResponse(ResponseModel):
port: int
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 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 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 PluginCategoryListResponse(ResponseModel):
plugins: list[PluginCategoryInstalledPluginResponse]
builtin_tools: list[PluginCategoryBuiltinToolProviderResponse]
has_more: bool
register_schema_models(
console_ns,
ParserList,
PluginCategoryListQuery,
PluginAutoUpgradeSettingsPayload,
PluginPermissionSettingsPayload,
ParserLatest,
@ -164,21 +277,57 @@ register_schema_models(
ParserPermissionChange,
ParserDynamicOptions,
ParserDynamicOptionsWithCredentials,
ParserPreferencesChange,
ParserAutoUpgradeChange,
ParserAutoUpgradeFetch,
ParserExcludePlugin,
ParserReadme,
)
register_response_schema_models(console_ns, PluginDebuggingKeyResponse, SuccessResponse)
register_response_schema_models(
console_ns,
PluginAutoUpgradeChangeResponse,
PluginAutoUpgradeFetchResponse,
PluginAutoUpgradeSettingsResponseModel,
PluginDebuggingKeyResponse,
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.
@ -193,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__])
@ -230,6 +406,41 @@ class PluginListApi(Resource):
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__])
@ -632,11 +843,13 @@ class PluginChangePermissionApi(Resource):
tenant_id = current_tenant_id
return {
"success": PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
}
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")
@ -725,9 +938,10 @@ 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.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
@ -736,38 +950,17 @@ class PluginChangePreferencesApi(Resource):
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"})
@ -775,48 +968,36 @@ class PluginChangePreferencesApi(Resource):
return jsonable_encoder({"success": True})
@console_ns.route("/workspaces/current/plugin/preferences/fetch")
class PluginFetchPreferencesApi(Resource):
@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
def get(self):
_, tenant_id = current_account_with_tenant()
permission = PluginPermissionService.get_permission(tenant_id)
permission_dict = {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
}
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)
)
if permission:
permission_dict["install_permission"] = permission.install_permission
permission_dict["debug_permission"] = permission.debug_permission
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[SuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -826,7 +1007,9 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
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")

View File

@ -20,7 +20,7 @@ from controllers.console.wraps import (
setup_required,
)
from core.db.session_factory import session_factory
from core.entities.mcp_provider import MCPAuthentication, MCPConfiguration
from core.entities.mcp_provider import IdentityMode, MCPAuthentication, MCPConfiguration
from core.mcp.auth.auth_flow import auth, handle_callback
from core.mcp.error import MCPAuthError, MCPError, MCPRefreshTokenError
from core.mcp.mcp_client import MCPClient
@ -210,6 +210,30 @@ class MCPProviderBasePayload(BaseModel):
configuration: dict[str, Any] | None = Field(default_factory=dict)
headers: dict[str, Any] | None = Field(default_factory=dict)
authentication: dict[str, Any] | None = Field(default_factory=dict)
# None means "leave unchanged" on update; the controller resolves it to a
# concrete IdentityMode before calling the service (see _resolve_identity_mode).
identity_mode: IdentityMode | None = None
def _resolve_identity_mode(requested: IdentityMode | None, *, current: IdentityMode) -> IdentityMode:
"""Resolve the effective MCP identity_mode for a create/update request.
Keeps two API-layer concerns out of the service so the service always
receives a concrete value:
* ``None`` means "leave unchanged" (update semantics) — fall back to
``current`` (``IdentityMode.OFF`` for a brand-new provider).
* Identity forwarding is an enterprise-only capability. On non-enterprise
deployments any non-OFF value is coerced back to OFF so a persisted row
can never imply forwarding that the runtime won't perform. This gates the
API surface to match the backend gate in
``MCPTool._forwarding_requested`` — both the API and the backend
invocation must be gated on ``dify_config.ENTERPRISE_ENABLED``.
"""
mode = current if requested is None else requested
if mode != IdentityMode.OFF and not dify_config.ENTERPRISE_ENABLED:
return IdentityMode.OFF
return mode
class MCPProviderCreatePayload(MCPProviderBasePayload):
@ -1000,6 +1024,7 @@ class ToolProviderMCPApi(Resource):
headers=payload.headers or {},
configuration=configuration,
authentication=authentication,
identity_mode=_resolve_identity_mode(payload.identity_mode, current=IdentityMode.OFF),
)
# 2) Try to fetch tools immediately after creation so they appear without a second save.
@ -1054,6 +1079,11 @@ class ToolProviderMCPApi(Resource):
# Step 3: Perform database update in a transaction
with sessionmaker(db.engine).begin() as session:
service = MCPToolManageService(session=session)
# Resolve "leave unchanged" (None) against the stored value, and gate
# the result on ENTERPRISE_ENABLED — both are API-layer concerns, so
# the service receives a concrete IdentityMode.
existing = service.get_provider(provider_id=payload.provider_id, tenant_id=current_tenant_id)
identity_mode = _resolve_identity_mode(payload.identity_mode, current=IdentityMode(existing.identity_mode))
service.update_provider(
tenant_id=current_tenant_id,
provider_id=payload.provider_id,
@ -1067,6 +1097,7 @@ class ToolProviderMCPApi(Resource):
configuration=configuration,
authentication=authentication,
validation_result=validation_result,
identity_mode=identity_mode,
)
return {"result": "success"}

View File

@ -31,9 +31,9 @@ from controllers.console.wraps import (
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import TimestampField, dump_response, to_timestamp
from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp
from libs.login import login_required
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
from services.account_service import TenantService
from services.billing_service import BillingService, SubscriptionPlan
from services.enterprise.enterprise_service import EnterpriseService
@ -144,6 +144,7 @@ tenants_fields = {
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"last_opened_at": OptionalTimestampField,
"current": fields.Boolean,
}
@ -158,7 +159,12 @@ class TenantListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
tenants = TenantService.get_join_tenants(current_user)
tenant_rows: list[tuple[Tenant, TenantAccountJoin]] = [
(tenant, membership)
for tenant, membership in TenantService.get_workspaces_for_account(db.session, current_user.id)
if tenant.status == TenantStatus.NORMAL
]
tenants = [tenant for tenant, _ in tenant_rows]
tenant_dicts = []
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED
@ -171,7 +177,7 @@ class TenantListApi(Resource):
if not tenant_plans:
logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path")
for tenant in tenants:
for tenant, membership in tenant_rows:
plan: str = CloudPlan.SANDBOX
if is_saas:
tenant_plan = tenant_plans.get(tenant.id)
@ -190,6 +196,7 @@ class TenantListApi(Resource):
"name": tenant.name,
"status": tenant.status,
"created_at": tenant.created_at,
"last_opened_at": membership.last_opened_at,
"plan": plan,
"current": tenant.id == current_tenant_id if current_tenant_id else False,
}

View File

@ -37,6 +37,13 @@ class MCPSupportGrantType(StrEnum):
REFRESH_TOKEN = "refresh_token"
class IdentityMode(StrEnum):
"""How Dify forwards the end-user's identity to an MCP server."""
OFF = "off"
IDP_TOKEN = "idp_token"
class MCPAuthentication(BaseModel):
client_id: str
client_secret: str | None = None
@ -76,6 +83,8 @@ class MCPProviderEntity(BaseModel):
created_at: datetime
updated_at: datetime
identity_mode: IdentityMode = IdentityMode.OFF
@classmethod
def from_db_model(cls, db_provider: MCPToolProvider) -> MCPProviderEntity:
"""Create entity from database model with decryption"""
@ -96,6 +105,7 @@ class MCPProviderEntity(BaseModel):
icon=db_provider.icon or "",
created_at=db_provider.created_at,
updated_at=db_provider.updated_at,
identity_mode=IdentityMode(db_provider.identity_mode),
)
@property
@ -170,6 +180,7 @@ class MCPProviderEntity(BaseModel):
"updated_at": int(self.updated_at.timestamp()),
"label": I18nObject(en_US=self.name, zh_Hans=self.name).to_dict(),
"description": I18nObject(en_US="", zh_Hans="").to_dict(),
"identity_mode": self.identity_mode,
}
# Add configuration

View File

@ -316,6 +316,7 @@ class IndexingRunner:
qa_preview_texts: list[QAPreviewDetail] = []
total_segments = 0
deleted_preview_images = False
# doc_form represents the segmentation method (general, parent-child, QA)
index_type = doc_form
index_processor = IndexProcessorFactory(index_type).init_index_processor()
@ -368,6 +369,10 @@ class IndexingRunner:
upload_file_id,
)
db.session.delete(image_file)
deleted_preview_images = True
if deleted_preview_images:
db.session.commit()
if doc_form and doc_form == "qa_model":
return IndexingEstimate(total_segments=total_segments * 20, qa_preview=qa_preview_texts, preview=[])

View File

@ -40,6 +40,7 @@ class MCPClientWithAuthRetry(MCPClient):
provider_entity: MCPProviderEntity | None = None,
authorization_code: str | None = None,
by_server_id: bool = False,
forward_identity_active: bool = False,
):
"""
Initialize the MCP client with auth retry capability.
@ -52,12 +53,15 @@ class MCPClientWithAuthRetry(MCPClient):
provider_entity: Provider entity for authentication
authorization_code: Optional authorization code for initial auth
by_server_id: Whether to look up provider by server ID
forward_identity_active: If True, suppress the static-OAuth retry
on 401 — the forwarded identity must propagate as-is.
"""
super().__init__(server_url, headers, timeout, sse_read_timeout)
self.provider_entity = provider_entity
self.authorization_code = authorization_code
self.by_server_id = by_server_id
self.forward_identity_active = forward_identity_active
self._has_retried = False
def _handle_auth_error(self, error: MCPAuthError) -> None:
@ -73,6 +77,8 @@ class MCPClientWithAuthRetry(MCPClient):
Raises:
MCPAuthError: If authentication fails or max retries reached
"""
if self.forward_identity_active:
raise error
if not self.provider_entity:
raise error
if self._has_retried:

View File

@ -7,7 +7,7 @@ import threading
import time
from collections.abc import Mapping
from datetime import timedelta
from typing import TYPE_CHECKING, Any, TypedDict
from typing import TYPE_CHECKING, Any, TypedDict, override
from uuid import UUID, uuid4
from cachetools import LRUCache
@ -221,6 +221,7 @@ class TracingProviderConfigEntry(TypedDict):
class OpsTraceProviderConfigMap(collections.UserDict[str, TracingProviderConfigEntry]):
@override
def __getitem__(self, key: str) -> TracingProviderConfigEntry:
try:
match key:

View File

@ -168,6 +168,7 @@ class PluginInstallTask(BasePluginEntity):
class PluginInstallTaskStartResponse(BaseModel):
all_installed: bool = Field(description="Whether all plugins are installed.")
task_id: str = Field(description="The ID of the install task.")
task: PluginInstallTask | None = Field(default=None, description="The install task.")
class PluginVerification(BaseModel):
@ -206,6 +207,11 @@ class PluginListResponse(BaseModel):
total: int
class PluginListWithoutTotalResponse(BaseModel):
list: list[PluginEntity]
has_more: bool
class PluginDynamicSelectOptionsResponse(BaseModel):
options: Sequence[PluginParameterOption] = Field(description="The options of the dynamic select.")

View File

@ -6,6 +6,7 @@ from requests import HTTPError
from core.plugin.entities.bundle import PluginBundleDependency
from core.plugin.entities.plugin import (
MissingPluginDependency,
PluginCategory,
PluginDeclaration,
PluginEntity,
PluginInstallation,
@ -16,6 +17,7 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginReadmeResponse,
)
from core.plugin.impl.base import BasePluginClient
@ -74,6 +76,16 @@ class PluginInstaller(BasePluginClient):
params={"page": page, "page_size": page_size, "response_type": "paged"},
)
def list_plugins_by_category(
self, tenant_id: str, category: PluginCategory, page: int, page_size: int
) -> PluginListWithoutTotalResponse:
return self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/{category.value}/list",
PluginListWithoutTotalResponse,
params={"page": page, "page_size": page_size, "response_type": "paged"},
)
def upload_pkg(
self,
tenant_id: str,

View File

@ -23,6 +23,7 @@ from core.helper.marketplace import download_plugin_pkg
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
from core.plugin.entities.bundle import PluginBundleDependency
from core.plugin.entities.plugin import (
PluginCategory,
PluginDeclaration,
PluginEntity,
PluginInstallation,
@ -33,6 +34,7 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTask,
PluginInstallTaskStatus,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginModelProviderEntity,
PluginVerification,
)
@ -295,6 +297,19 @@ class PluginService:
plugins = manager.list_plugins_with_total(tenant_id, page, page_size)
return plugins
@staticmethod
def list_by_category(
tenant_id: str, category: PluginCategory, page: int, page_size: int
) -> PluginListWithoutTotalResponse:
"""
List plugins in one category with a has-more cursor signal and without calculating total.
The daemon scans tenant installations in the existing list order and stops once it finds one extra match.
This keeps pagination usable before category is persisted on installation rows.
"""
manager = PluginInstaller()
return manager.list_plugins_by_category(tenant_id, category, page, page_size)
@staticmethod
def list_installations_from_ids(tenant_id: str, ids: Sequence[str]) -> Sequence[PluginInstallation]:
"""

View File

@ -1,7 +1,7 @@
import base64
import logging
import pickle
from typing import Any, cast
from typing import Any, cast, override
import numpy as np
from sqlalchemy import select
@ -25,6 +25,7 @@ class CacheEmbedding(Embeddings):
def __init__(self, model_instance: ModelInstance):
self._model_instance = model_instance
@override
def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Embed search docs in batches of 10."""
# use doc embedding cache or store if not exists
@ -106,6 +107,7 @@ class CacheEmbedding(Embeddings):
return text_embeddings
@override
def embed_multimodal_documents(self, multimodel_documents: list[dict[str, Any]]) -> list[list[float]]:
"""Embed file documents."""
# use doc embedding cache or store if not exists
@ -189,6 +191,7 @@ class CacheEmbedding(Embeddings):
return multimodel_embeddings
@override
def embed_query(self, text: str) -> list[float]:
"""Embed query text."""
# use doc embedding cache or store if not exists
@ -232,6 +235,7 @@ class CacheEmbedding(Embeddings):
return embedding_results # type: ignore
@override
def embed_multimodal_query(self, multimodel_document: dict[str, Any]) -> list[float]:
"""Embed multimodal documents."""
# use doc embedding cache or store if not exists

View File

@ -1,13 +1,32 @@
"""Abstract interface for document loader implementations."""
"""Excel document extractor used for RAG ingestion.
Supports cell hyperlinks for both `.xls` and `.xlsx`, and embedded worksheet images
for `.xlsx` files by converting them into markdown image links. Embedded images are
stored with deterministic keys derived from the source upload file and anchor cell so
retries can safely reuse the same assets.
"""
import hashlib
import logging
import mimetypes
import os
from typing import TypedDict, override
import pandas as pd
from openpyxl import load_workbook
from sqlalchemy import select
from configs import dify_config
from core.db.session_factory import session_factory
from core.rag.extractor.extractor_base import BaseExtractor
from core.rag.models.document import Document
from extensions.ext_storage import storage
from extensions.storage.storage_type import StorageType
from libs.datetime_utils import naive_utc_now
from models.enums import CreatorUserRole
from models.model import UploadFile
logger = logging.getLogger(__name__)
class Candidate(TypedDict):
@ -16,17 +35,42 @@ class Candidate(TypedDict):
map: dict[int, str]
class SheetImageCandidate(TypedDict):
anchor: tuple[int, int]
content_hash: str
file_key: str
image_bytes: bytes
image_ext: str
class ExcelExtractor(BaseExtractor):
"""Load Excel files.
Args:
file_path: Path to the file to load.
"""
def __init__(self, file_path: str, encoding: str | None = None, autodetect_encoding: bool = False):
_file_path: str
_encoding: str | None
_autodetect_encoding: bool
_tenant_id: str | None
_user_id: str | None
_source_file_id: str | None
def __init__(
self,
file_path: str,
tenant_id: str | None = None,
user_id: str | None = None,
source_file_id: str | None = None,
encoding: str | None = None,
autodetect_encoding: bool = False,
):
"""Initialize with file path."""
self._file_path = file_path
self._tenant_id = tenant_id
self._user_id = user_id
self._source_file_id = source_file_id
self._encoding = encoding
self._autodetect_encoding = autodetect_encoding
@ -37,7 +81,8 @@ class ExcelExtractor(BaseExtractor):
file_extension = os.path.splitext(self._file_path)[-1].lower()
if file_extension == ".xlsx":
wb = load_workbook(self._file_path, read_only=True, data_only=True)
# Worksheet drawing objects, including embedded images, are not available in read-only mode.
wb = load_workbook(self._file_path, data_only=True)
try:
for sheet_name in wb.sheetnames:
sheet = wb[sheet_name]
@ -45,10 +90,15 @@ class ExcelExtractor(BaseExtractor):
if not column_map:
continue
start_row = header_row_idx + 1
sheet_image_map = self._extract_images_from_sheet(
sheet_name=sheet_name,
sheet=sheet,
valid_columns={column_idx + 1 for column_idx in column_map},
min_row=start_row,
)
for row in sheet.iter_rows(min_row=start_row, max_col=max_col_idx, values_only=False):
if all(cell.value is None for cell in row):
continue
page_content = []
row_has_content = False
for col_idx, cell in enumerate(row):
value = cell.value
if col_idx in column_map:
@ -56,14 +106,27 @@ class ExcelExtractor(BaseExtractor):
if hasattr(cell, "hyperlink") and cell.hyperlink:
target = getattr(cell.hyperlink, "target", None)
if target:
value = f"[{value}]({target})"
display_value = value if value is not None and str(value).strip() else target
value = f"[{display_value}]({target})"
cell_row = getattr(cell, "row", None)
cell_column = getattr(cell, "column", None)
image_links = (
sheet_image_map.get((cell_row, cell_column), [])
if isinstance(cell_row, int) and isinstance(cell_column, int)
else []
)
if value is None:
value = ""
elif not isinstance(value, str):
value = str(value)
value = value.strip().replace('"', '\\"')
if image_links:
value = " ".join(filter(None, [value, " ".join(image_links)]))
value = value.strip()
if value:
row_has_content = True
value = value.replace('"', '\\"')
page_content.append(f'"{col_name}":"{value}"')
if page_content:
if row_has_content and page_content:
documents.append(
Document(page_content=";".join(page_content), metadata={"source": self._file_path})
)
@ -89,6 +152,166 @@ class ExcelExtractor(BaseExtractor):
return documents
def _extract_images_from_sheet(
self, sheet_name: str, sheet, valid_columns: set[int], min_row: int
) -> dict[tuple[int, int], list[str]]:
"""
Extract embedded worksheet images and map them to their anchor cell.
Images are stored with deterministic keys derived from the source upload file,
sheet, anchor cell, and content hash so retried tasks can reuse the same
UploadFile rows and storage objects.
"""
if not self._tenant_id or not self._user_id or not self._source_file_id:
return {}
images = getattr(sheet, "_images", None) or []
image_candidates: list[SheetImageCandidate] = []
for image in images:
marker = getattr(getattr(image, "anchor", None), "_from", None)
row_idx = getattr(marker, "row", None)
col_idx = getattr(marker, "col", None)
if row_idx is None or col_idx is None:
continue
if row_idx + 1 < min_row or col_idx + 1 not in valid_columns:
continue
image_bytes = self._get_image_bytes(image)
if not image_bytes:
continue
image_ext = self._get_image_extension(image)
if not image_ext:
continue
anchor_row = row_idx + 1
anchor_column = col_idx + 1
content_hash = self._hash_image_bytes(image_bytes)
image_candidates.append(
{
"anchor": (anchor_row, anchor_column),
"content_hash": content_hash,
"file_key": self._build_image_file_key(
sheet_name=sheet_name,
anchor_row=anchor_row,
anchor_column=anchor_column,
content_hash=content_hash,
image_ext=image_ext,
),
"image_bytes": image_bytes,
"image_ext": image_ext,
}
)
if not image_candidates:
return {}
image_map: dict[tuple[int, int], list[str]] = {}
base_url = dify_config.FILES_URL
candidate_keys = sorted({candidate["file_key"] for candidate in image_candidates})
with session_factory.create_session() as session:
existing_upload_files = session.scalars(
select(UploadFile).where(
UploadFile.tenant_id == self._tenant_id,
UploadFile.key.in_(candidate_keys),
)
).all()
upload_files_by_key = {upload_file.key: upload_file for upload_file in existing_upload_files}
new_upload_files: list[UploadFile] = []
for candidate in image_candidates:
upload_file = upload_files_by_key.get(candidate["file_key"])
if upload_file is None:
storage.save(candidate["file_key"], candidate["image_bytes"])
mime_type, _ = mimetypes.guess_type(candidate["file_key"])
upload_file = UploadFile(
tenant_id=self._tenant_id,
storage_type=StorageType(dify_config.STORAGE_TYPE),
key=candidate["file_key"],
name=candidate["file_key"],
size=len(candidate["image_bytes"]),
extension=candidate["image_ext"],
mime_type=mime_type or "",
created_by=self._user_id,
created_by_role=CreatorUserRole.ACCOUNT,
created_at=naive_utc_now(),
used=True,
used_by=self._user_id,
used_at=naive_utc_now(),
hash=candidate["content_hash"],
)
upload_files_by_key[candidate["file_key"]] = upload_file
new_upload_files.append(upload_file)
image_map.setdefault(candidate["anchor"], []).append(
f"![image]({base_url}/files/{upload_file.id}/file-preview)"
)
if new_upload_files:
session.add_all(new_upload_files)
session.commit()
return image_map
@staticmethod
def _hash_image_bytes(image_bytes: bytes) -> str:
"""Return a stable content hash for extracted image bytes."""
return hashlib.sha256(image_bytes).hexdigest()
def _build_image_file_key(
self,
*,
sheet_name: str,
anchor_row: int,
anchor_column: int,
content_hash: str,
image_ext: str,
) -> str:
"""Build a deterministic storage key for an embedded worksheet image."""
assert self._tenant_id is not None, "tenant_id is required for image extraction"
assert self._source_file_id is not None, "source_file_id is required for image extraction"
normalized_ext = image_ext.strip().lower()
sheet_hash = hashlib.sha256(sheet_name.encode("utf-8")).hexdigest()[:16]
return (
f"image_files/{self._tenant_id}/{self._source_file_id}/"
f"{sheet_hash}_r{anchor_row}_c{anchor_column}_{content_hash}.{normalized_ext}"
)
def _get_image_bytes(self, image) -> bytes | None:
"""Return embedded image bytes from an openpyxl image object."""
data_loader = getattr(image, "_data", None)
if not callable(data_loader):
return None
try:
data = data_loader()
if isinstance(data, bytes):
return data
if isinstance(data, bytearray):
return bytes(data)
logger.warning("Unexpected embedded image payload type: %s", type(data).__name__)
return None
except Exception:
logger.warning("Failed to read embedded image bytes from Excel sheet", exc_info=True)
return None
def _get_image_extension(self, image) -> str | None:
"""Resolve an image extension from openpyxl metadata."""
image_format = getattr(image, "format", None)
if isinstance(image_format, str) and image_format.strip():
return image_format.strip().lower()
image_path = getattr(image, "path", None)
if isinstance(image_path, str):
_, extension = os.path.splitext(image_path)
if extension:
return extension.lstrip(".").lower()
return None
def _find_header_and_columns(self, sheet, scan_rows=10) -> tuple[int, dict[int, str], int]:
"""
Scan first N rows to find the most likely header row.

View File

@ -113,7 +113,12 @@ class ExtractProcessor:
unstructured_api_key = dify_config.UNSTRUCTURED_API_KEY or ""
if file_extension in {".xlsx", ".xls"}:
extractor = ExcelExtractor(file_path)
extractor = ExcelExtractor(
file_path,
upload_file.tenant_id,
upload_file.created_by,
upload_file.id,
)
elif file_extension == ".pdf":
assert upload_file is not None
extractor = PdfExtractor(file_path, upload_file.tenant_id, upload_file.created_by)
@ -151,7 +156,12 @@ class ExtractProcessor:
extractor = TextExtractor(file_path, autodetect_encoding=True)
else:
if file_extension in {".xlsx", ".xls"}:
extractor = ExcelExtractor(file_path)
extractor = ExcelExtractor(
file_path,
upload_file.tenant_id,
upload_file.created_by,
upload_file.id,
)
elif file_extension == ".pdf":
assert upload_file is not None
extractor = PdfExtractor(file_path, upload_file.tenant_id, upload_file.created_by)

View File

@ -3,7 +3,7 @@
import logging
import re
import uuid
from typing import Any, TypedDict, cast
from typing import Any, TypedDict, cast, override
logger = logging.getLogger(__name__)
@ -61,6 +61,7 @@ class ParagraphFormatPreviewDict(TypedDict):
class ParagraphIndexProcessor(BaseIndexProcessor):
@override
def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]:
text_docs = ExtractProcessor.extract(
extract_setting=extract_setting,
@ -71,6 +72,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
return text_docs
@override
def transform(self, documents: list[Document], current_user: Account | None = None, **kwargs) -> list[Document]:
process_rule = kwargs.get("process_rule")
if not process_rule:
@ -120,6 +122,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
all_documents.extend(split_documents)
return all_documents
@override
def load(
self,
dataset: Dataset,
@ -142,6 +145,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
else:
keyword.add_texts(documents)
@override
def clean(self, dataset: Dataset, node_ids: list[str] | None, with_keywords: bool = True, **kwargs) -> None:
# Note: Summary indexes are now disabled (not deleted) when segments are disabled.
# This method is called for actual deletion scenarios (e.g., when segment is deleted).
@ -178,6 +182,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
else:
keyword.delete()
@override
def retrieve(
self,
retrieval_method: RetrievalMethod,
@ -206,6 +211,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
docs.append(doc)
return docs
@override
def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None:
documents: list[Any] = []
all_multimodal_documents: list[Any] = []
@ -271,6 +277,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
keyword = Keyword(dataset)
keyword.add_texts(documents)
@override
def format_preview(self, chunks: Any) -> ParagraphFormatPreviewDict:
if isinstance(chunks, list):
preview = []
@ -285,6 +292,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
else:
raise ValueError("Chunks is not a list")
@override
def generate_summary_preview(
self,
tenant_id: str,

View File

@ -3,7 +3,7 @@
import json
import logging
import uuid
from typing import Any, TypedDict
from typing import Any, TypedDict, override
from sqlalchemy import delete, select
@ -44,6 +44,7 @@ class ParentChildFormatPreviewDict(TypedDict):
class ParentChildIndexProcessor(BaseIndexProcessor):
@override
def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]:
text_docs = ExtractProcessor.extract(
extract_setting=extract_setting,
@ -54,6 +55,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
return text_docs
@override
def transform(self, documents: list[Document], current_user: Account | None = None, **kwargs) -> list[Document]:
process_rule = kwargs.get("process_rule")
if not process_rule:
@ -129,6 +131,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
return all_documents
@override
def load(
self,
dataset: Dataset,
@ -149,6 +152,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
if multimodal_documents and dataset.is_multimodal:
vector.create_multimodal(multimodal_documents)
@override
def clean(self, dataset: Dataset, node_ids: list[str] | None, with_keywords: bool = True, **kwargs) -> None:
# node_ids is segment's node_ids
# Note: Summary indexes are now disabled (not deleted) when segments are disabled.
@ -219,6 +223,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
)
db.session.commit()
@override
def retrieve(
self,
retrieval_method: RetrievalMethod,
@ -283,6 +288,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
child_nodes.append(child_document)
return child_nodes
@override
def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None:
parent_childs = ParentChildStructureChunk.model_validate(chunks)
documents = []
@ -356,6 +362,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
if all_multimodal_documents and dataset.is_multimodal:
vector.create_multimodal(all_multimodal_documents)
@override
def format_preview(self, chunks: Any) -> ParentChildFormatPreviewDict:
parent_childs = ParentChildStructureChunk.model_validate(chunks)
preview = []
@ -369,6 +376,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
}
return result
@override
def generate_summary_preview(
self,
tenant_id: str,

View File

@ -4,7 +4,7 @@ import logging
import re
import threading
import uuid
from typing import Any, TypedDict
from typing import Any, TypedDict, override
import pandas as pd
from flask import Flask, current_app
@ -43,6 +43,7 @@ class QAFormatPreviewDict(TypedDict):
class QAIndexProcessor(BaseIndexProcessor):
@override
def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]:
text_docs = ExtractProcessor.extract(
extract_setting=extract_setting,
@ -52,6 +53,7 @@ class QAIndexProcessor(BaseIndexProcessor):
)
return text_docs
@override
def transform(self, documents: list[Document], current_user: Account | None = None, **kwargs) -> list[Document]:
preview = kwargs.get("preview")
process_rule = kwargs.get("process_rule")
@ -139,6 +141,7 @@ class QAIndexProcessor(BaseIndexProcessor):
raise ValueError(str(e))
return text_docs
@override
def load(
self,
dataset: Dataset,
@ -153,6 +156,7 @@ class QAIndexProcessor(BaseIndexProcessor):
if multimodal_documents and dataset.is_multimodal:
vector.create_multimodal(multimodal_documents)
@override
def clean(self, dataset: Dataset, node_ids: list[str] | None, with_keywords: bool = True, **kwargs) -> None:
# Note: Summary indexes are now disabled (not deleted) when segments are disabled.
# This method is called for actual deletion scenarios (e.g., when segment is deleted).
@ -183,6 +187,7 @@ class QAIndexProcessor(BaseIndexProcessor):
else:
vector.delete()
@override
def retrieve(
self,
retrieval_method: RetrievalMethod,
@ -211,6 +216,7 @@ class QAIndexProcessor(BaseIndexProcessor):
docs.append(doc)
return docs
@override
def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None:
qa_chunks = QAStructureChunk.model_validate(chunks)
documents = []
@ -234,6 +240,7 @@ class QAIndexProcessor(BaseIndexProcessor):
else:
raise ValueError("Indexing technique must be high quality.")
@override
def format_preview(self, chunks: Any) -> QAFormatPreviewDict:
qa_chunks = QAStructureChunk.model_validate(chunks)
preview = []
@ -246,6 +253,7 @@ class QAIndexProcessor(BaseIndexProcessor):
}
return result
@override
def generate_summary_preview(
self,
tenant_id: str,

View File

@ -1,4 +1,5 @@
import base64
from typing import override
from core.model_manager import ModelInstance, ModelManager
from core.rag.index_processor.constant.doc_type import DocType
@ -16,6 +17,7 @@ class RerankModelRunner(BaseRerankRunner):
def __init__(self, rerank_model_instance: ModelInstance):
self.rerank_model_instance = rerank_model_instance
@override
def run(
self,
query: str,

View File

@ -1,5 +1,6 @@
import math
from collections import Counter
from typing import override
import numpy as np
@ -19,6 +20,7 @@ class WeightRerankRunner(BaseRerankRunner):
self.tenant_id = tenant_id
self.weights = weights
@override
def run(
self,
query: str,

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import codecs
import re
from collections.abc import Set as AbstractSet
from typing import Any, Literal
from typing import Any, Literal, override
from core.model_manager import ModelInstance
from core.rag.splitter.text_splitter import RecursiveCharacterTextSplitter
@ -51,6 +51,7 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
self._fixed_separator = codecs.decode(fixed_separator, "unicode_escape")
self._separators = separators or ["\n\n", "\n", "", ". ", " ", ""]
@override
def split_text(self, text: str) -> list[str]:
"""Split incoming text and return chunks."""
if self._fixed_separator:

View File

@ -7,7 +7,7 @@ from abc import ABC, abstractmethod
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from typing import Any, Literal
from typing import Any, Literal, override
from core.rag.models.document import BaseDocumentTransformer, Document
@ -148,10 +148,12 @@ class TextSplitter(BaseDocumentTransformer, ABC):
)
return cls(length_function=lambda x: [_huggingface_tokenizer_length(text) for text in x], **kwargs)
@override
def transform_documents(self, documents: Sequence[Document], **kwargs: Any) -> Sequence[Document]:
"""Transform sequence of documents by splitting them."""
return self.split_documents(list(documents))
@override
async def atransform_documents(self, documents: Sequence[Document], **kwargs: Any) -> Sequence[Document]:
"""Asynchronously transform a sequence of documents by splitting them."""
raise NotImplementedError
@ -211,6 +213,7 @@ class TokenTextSplitter(TextSplitter):
self._allowed_special: Literal["all"] | AbstractSet[str] = allowed_special
self._disallowed_special: Literal["all"] | AbstractSet[str] = disallowed_special
@override
def split_text(self, text: str) -> list[str]:
def _encode(_text: str) -> list[int]:
return self._tokenizer.encode(
@ -287,5 +290,6 @@ class RecursiveCharacterTextSplitter(TextSplitter):
return final_chunks
@override
def split_text(self, text: str) -> list[str]:
return self._split_text(text, self._separators)

View File

@ -1,6 +1,6 @@
from abc import abstractmethod
from os import listdir, path
from typing import Any
from typing import Any, override
from core.entities.provider_entities import ProviderConfig
from core.helper.module_import_helper import load_single_subclass_from_source
@ -105,6 +105,7 @@ class BuiltinToolProviderController(ToolProviderController):
"""
return self.tools
@override
def get_credentials_schema(self) -> list[ProviderConfig]:
"""
returns the credentials schema of the provider
@ -182,6 +183,7 @@ class BuiltinToolProviderController(ToolProviderController):
)
@property
@override
def provider_type(self) -> ToolProviderType:
"""
returns the type of the provider

View File

@ -1,8 +1,9 @@
from typing import Any
from typing import Any, override
from core.tools.builtin_tool.provider import BuiltinToolProviderController
class AudioToolProvider(BuiltinToolProviderController):
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
pass

View File

@ -1,6 +1,6 @@
import io
from collections.abc import Generator
from typing import Any
from typing import Any, override
from core.model_manager import ModelManager
from core.plugin.entities.parameters import PluginParameterOption
@ -14,6 +14,7 @@ from services.model_provider_service import ModelProviderService
class ASRTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,
@ -56,6 +57,7 @@ class ASRTool(BuiltinTool):
items.append((provider, model.model))
return items
@override
def get_runtime_parameters(
self,
conversation_id: str | None = None,

View File

@ -1,6 +1,6 @@
import io
from collections.abc import Generator
from typing import Any
from typing import Any, override
from core.model_manager import ModelManager
from core.plugin.entities.parameters import PluginParameterOption
@ -12,6 +12,7 @@ from services.model_provider_service import ModelProviderService
class TTSTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,
@ -66,6 +67,7 @@ class TTSTool(BuiltinTool):
items.append((provider, model.model, voices))
return items
@override
def get_runtime_parameters(
self,
conversation_id: str | None = None,

View File

@ -1,8 +1,9 @@
from typing import Any
from typing import Any, override
from core.tools.builtin_tool.provider import BuiltinToolProviderController
class CodeToolProvider(BuiltinToolProviderController):
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
pass

View File

@ -1,5 +1,5 @@
from collections.abc import Generator
from typing import Any
from typing import Any, override
from core.helper.code_executor.code_executor import CodeExecutor, CodeLanguage
from core.tools.builtin_tool.tool import BuiltinTool
@ -8,6 +8,7 @@ from core.tools.errors import ToolInvokeError
class SimpleCode(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,8 +1,9 @@
from typing import Any
from typing import Any, override
from core.tools.builtin_tool.provider import BuiltinToolProviderController
class WikiPediaProvider(BuiltinToolProviderController):
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
pass

View File

@ -1,6 +1,6 @@
from collections.abc import Generator
from datetime import UTC, datetime
from typing import Any
from typing import Any, override
from pytz import timezone as pytz_timezone # type: ignore[import-untyped]
@ -9,6 +9,7 @@ from core.tools.entities.tool_entities import ToolInvokeMessage
class CurrentTimeTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,6 +1,6 @@
from collections.abc import Generator
from datetime import datetime
from typing import Any
from typing import Any, override
import pytz # type: ignore[import-untyped]
@ -10,6 +10,7 @@ from core.tools.errors import ToolInvokeError
class LocaltimeToTimestampTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,6 +1,6 @@
from collections.abc import Generator
from datetime import datetime
from typing import Any
from typing import Any, override
import pytz # type: ignore[import-untyped]
@ -10,6 +10,7 @@ from core.tools.errors import ToolInvokeError
class TimestampToLocaltimeTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,6 +1,6 @@
from collections.abc import Generator
from datetime import datetime
from typing import Any
from typing import Any, override
import pytz # type: ignore[import-untyped]
@ -10,6 +10,7 @@ from core.tools.errors import ToolInvokeError
class TimezoneConversionTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,13 +1,14 @@
import calendar
from collections.abc import Generator
from datetime import datetime
from typing import Any
from typing import Any, override
from core.tools.builtin_tool.tool import BuiltinTool
from core.tools.entities.tool_entities import ToolInvokeMessage
class WeekdayTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,5 +1,5 @@
from collections.abc import Generator
from typing import Any
from typing import Any, override
from core.tools.builtin_tool.tool import BuiltinTool
from core.tools.entities.tool_entities import ToolInvokeMessage
@ -8,6 +8,7 @@ from core.tools.utils.web_reader_tool import get_url
class WebscraperTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,9 +1,10 @@
from typing import Any
from typing import Any, override
from core.tools.builtin_tool.provider import BuiltinToolProviderController
class WebscraperProvider(BuiltinToolProviderController):
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
"""
Validate credentials

View File

@ -1,5 +1,7 @@
from __future__ import annotations
from typing import override
from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.entities.tool_entities import ToolProviderType
@ -26,6 +28,7 @@ class BuiltinTool(Tool):
super().__init__(**kwargs)
self.provider = provider
@override
def fork_tool_runtime(self, runtime: ToolRuntime) -> BuiltinTool:
"""
fork a new tool with metadata
@ -56,6 +59,7 @@ class BuiltinTool(Tool):
caller_user_id=self.runtime.user_id,
)
@override
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.BUILT_IN

View File

@ -1,5 +1,7 @@
from __future__ import annotations
from typing import override
from pydantic import Field
from sqlalchemy import select
@ -122,6 +124,7 @@ class ApiToolProviderController(ToolProviderController):
)
@property
@override
def provider_type(self) -> ToolProviderType:
return ToolProviderType.API
@ -194,6 +197,7 @@ class ApiToolProviderController(ToolProviderController):
self.tools = tools
return tools
@override
def get_tool(self, tool_name: str) -> ApiTool:
"""
get tool by name

View File

@ -2,7 +2,7 @@ import json
from collections.abc import Generator
from dataclasses import dataclass
from os import getenv
from typing import Any, Union
from typing import Any, Union, override
from urllib.parse import urlencode
import httpx
@ -45,6 +45,7 @@ class ApiTool(Tool):
self.api_bundle = api_bundle
self.provider_id = provider_id
@override
def fork_tool_runtime(self, runtime: ToolRuntime):
"""
fork a new tool with metadata
@ -77,6 +78,7 @@ class ApiTool(Tool):
# For credential validation, always return as string
return parsed_response.to_string()
@override
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.API
@ -373,6 +375,7 @@ class ApiTool(Tool):
except ValueError:
return value
@override
def _invoke(
self,
user_id: str,

View File

@ -54,6 +54,9 @@ class ToolProviderApiEntity(BaseModel):
configuration: MCPConfiguration | None = Field(
default=None, description="The timeout and sse_read_timeout of the MCP tool"
)
# M3 — user-identity forwarding selector. Round-tripped through the
# console API so the create/edit modal can hydrate the toggle state.
identity_mode: str = Field(default="off", description="Identity-forwarding mechanism: 'off' or 'idp_token'")
# Workflow
workflow_app_id: str | None = Field(default=None, description="The app id of the workflow tool")
@ -92,6 +95,9 @@ class ToolProviderApiEntity(BaseModel):
optional_fields.update(self.optional_field("is_dynamic_registration", self.is_dynamic_registration))
optional_fields.update(self.optional_field("masked_headers", self.masked_headers))
optional_fields.update(self.optional_field("original_headers", self.original_headers))
# M3 — forwarding selector. Always emit ("off" is a valid
# value that the UI must hydrate, not skip).
optional_fields["identity_mode"] = self.identity_mode
case ToolProviderType.WORKFLOW:
optional_fields.update(self.optional_field("workflow_app_id", self.workflow_app_id))
case _:

View File

@ -1,6 +1,6 @@
from typing import Any, Self
from typing import Any, Self, override
from core.entities.mcp_provider import MCPProviderEntity
from core.entities.mcp_provider import IdentityMode, MCPProviderEntity
from core.mcp.types import Tool as RemoteMCPTool
from core.tools.__base.tool_provider import ToolProviderController
from core.tools.__base.tool_runtime import ToolRuntime
@ -28,6 +28,7 @@ class MCPToolProviderController(ToolProviderController):
headers: dict[str, str] | None = None,
timeout: float | None = None,
sse_read_timeout: float | None = None,
identity_mode: IdentityMode = IdentityMode.OFF,
):
super().__init__(entity)
self.entity: ToolProviderEntityWithPlugin = entity
@ -37,8 +38,10 @@ class MCPToolProviderController(ToolProviderController):
self.headers = headers or {}
self.timeout = timeout
self.sse_read_timeout = sse_read_timeout
self.identity_mode: IdentityMode = identity_mode
@property
@override
def provider_type(self) -> ToolProviderType:
"""
returns the type of the provider
@ -105,6 +108,7 @@ class MCPToolProviderController(ToolProviderController):
headers=entity.headers,
timeout=entity.timeout,
sse_read_timeout=entity.sse_read_timeout,
identity_mode=entity.identity_mode,
)
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
@ -113,6 +117,7 @@ class MCPToolProviderController(ToolProviderController):
"""
pass
@override
def get_tool(self, tool_name: str) -> MCPTool:
"""
return tool with given name
@ -134,6 +139,7 @@ class MCPToolProviderController(ToolProviderController):
headers=self.headers,
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
identity_mode=self.identity_mode,
)
def get_tools(self) -> list[MCPTool]:
@ -151,6 +157,7 @@ class MCPToolProviderController(ToolProviderController):
headers=self.headers,
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
identity_mode=self.identity_mode,
)
for tool_entity in self.entity.tools
]

View File

@ -4,8 +4,10 @@ import base64
import json
import logging
from collections.abc import Generator, Mapping
from typing import Any, cast
from typing import Any, cast, override
from configs import dify_config
from core.entities.mcp_provider import IdentityMode
from core.mcp.auth_client import MCPClientWithAuthRetry
from core.mcp.error import MCPConnectionError
from core.mcp.types import (
@ -25,6 +27,11 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetada
logger = logging.getLogger(__name__)
# Custom header used to carry the forwarded SSO access token. Picked to avoid
# stomping on the workspace-scoped Authorization header (provider OAuth /
# user-supplied custom credentials), which would silently break those flows.
FORWARDED_IDENTITY_HEADER = "X-Dify-SSO-Access-Token"
class MCPTool(Tool):
def __init__(
@ -38,6 +45,7 @@ class MCPTool(Tool):
headers: dict[str, str] | None = None,
timeout: float | None = None,
sse_read_timeout: float | None = None,
identity_mode: IdentityMode = IdentityMode.OFF,
):
super().__init__(entity, runtime)
self.tenant_id = tenant_id
@ -47,11 +55,14 @@ class MCPTool(Tool):
self.headers = headers or {}
self.timeout = timeout
self.sse_read_timeout = sse_read_timeout
self.identity_mode: IdentityMode = identity_mode
self._latest_usage = LLMUsage.empty_usage()
@override
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.MCP
@override
def _invoke(
self,
user_id: str,
@ -60,7 +71,7 @@ class MCPTool(Tool):
app_id: str | None = None,
message_id: str | None = None,
) -> Generator[ToolInvokeMessage, None, None]:
result = self.invoke_remote_mcp_tool(tool_parameters)
result = self.invoke_remote_mcp_tool(tool_parameters, user_id=user_id, app_id=app_id)
# Extract usage metadata from MCP protocol's _meta field
self._latest_usage = self._derive_usage_from_result(result)
@ -223,6 +234,7 @@ class MCPTool(Tool):
return found
return None
@override
def fork_tool_runtime(self, runtime: ToolRuntime) -> MCPTool:
return MCPTool(
entity=self.entity,
@ -234,6 +246,7 @@ class MCPTool(Tool):
headers=self.headers,
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
identity_mode=self.identity_mode,
)
def _handle_none_parameter(self, parameter: dict[str, Any]) -> dict[str, Any]:
@ -246,7 +259,26 @@ class MCPTool(Tool):
if value is not None and not (isinstance(value, str) and value.strip() == "")
}
def invoke_remote_mcp_tool(self, tool_parameters: dict[str, Any]) -> CallToolResult:
@property
def _forwarding_requested(self) -> bool:
"""True only when the configured identity_mode wants forwarding AND
the deployment actually has the enterprise side that can mint tokens.
Non-enterprise installs treat the DB value as a no-op — a stale row
won't trigger a 5xx against a missing inner-API endpoint."""
return self.identity_mode != IdentityMode.OFF and dify_config.ENTERPRISE_ENABLED
def invoke_remote_mcp_tool(
self,
tool_parameters: dict[str, Any],
user_id: str | None = None,
app_id: str | None = None,
) -> CallToolResult:
# Fail closed: forwarding requires user_id (refuse before any DB I/O).
if self._forwarding_requested and not user_id:
raise ToolInvokeError(
"Forward-user-identity is enabled for this MCP provider but no end-user context was supplied."
)
headers = self.headers.copy() if self.headers else {}
tool_parameters = self._handle_none_parameter(tool_parameters)
@ -271,6 +303,15 @@ class MCPTool(Tool):
if tokens and tokens.access_token:
headers["Authorization"] = f"{tokens.token_type.capitalize()} {tokens.access_token}"
# Forwarded identity rides in a custom header so workspace-scoped
# provider credentials (Authorization / custom Headers) keep working
# untouched. The MCP server is expected to read X-Dify-SSO-Access-Token
# when identity forwarding is configured.
forward_identity_active = False
if self._forwarding_requested and user_id:
self._inject_forwarded_identity(headers, user_id=user_id, app_id=app_id, audience=server_url)
forward_identity_active = True
# Step 2: Session is now closed, perform network operations without holding database connection
# MCPClientWithAuthRetry will create a new session lazily only if auth retry is needed
try:
@ -280,9 +321,44 @@ class MCPTool(Tool):
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
provider_entity=provider_entity,
forward_identity_active=forward_identity_active,
) as mcp_client:
return mcp_client.invoke_tool(tool_name=self.entity.identity.name, tool_args=tool_parameters)
except MCPConnectionError as e:
raise ToolInvokeError(f"Failed to connect to MCP server: {e}") from e
except Exception as e:
raise ToolInvokeError(f"Failed to invoke tool: {e}") from e
def _inject_forwarded_identity(
self,
headers: dict[str, str],
*,
user_id: str,
app_id: str | None,
audience: str,
) -> None:
"""Call the enterprise IssueMCPToken endpoint and stamp the issued
token into X-Dify-SSO-Access-Token.
A custom header is used (rather than Authorization) so it composes
with workspace-scoped provider credentials — the user may have OAuth
tokens or a custom Authorization header configured on the MCP
provider, and forwarding must not silently overwrite them.
Errors are surfaced as ToolInvokeError so the workflow halts with a
clear message instead of silently dropping identity and hitting the
MCP server unauthenticated.
"""
from services.enterprise.base import MCPTokenError
from services.enterprise.enterprise_service import EnterpriseService
try:
token, _expires_at = EnterpriseService.issue_mcp_token(
user_id=user_id,
tenant_id=self.tenant_id,
app_id=app_id,
audience=audience,
)
except MCPTokenError as e:
raise ToolInvokeError(f"Failed to obtain forwarded identity token: {e}") from e
headers[FORWARDED_IDENTITY_HEADER] = token

View File

@ -1,4 +1,4 @@
from typing import Any
from typing import Any, override
from core.plugin.impl.tool import PluginToolManager
from core.tools.__base.tool_runtime import ToolRuntime
@ -23,6 +23,7 @@ class PluginToolProviderController(BuiltinToolProviderController):
self.plugin_unique_identifier = plugin_unique_identifier
@property
@override
def provider_type(self) -> ToolProviderType:
"""
returns the type of the provider
@ -31,6 +32,7 @@ class PluginToolProviderController(BuiltinToolProviderController):
"""
return ToolProviderType.PLUGIN
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
"""
validate the credentials of the provider

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Generator
from typing import Any
from typing import Any, override
from core.plugin.impl.tool import PluginToolManager
from core.plugin.utils.converter import convert_parameters_to_plugin_format
@ -20,9 +20,11 @@ class PluginTool(Tool):
self.plugin_unique_identifier = plugin_unique_identifier
self.runtime_parameters: list[ToolParameter] | None = None
@override
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.PLUGIN
@override
def _invoke(
self,
user_id: str,
@ -48,6 +50,7 @@ class PluginTool(Tool):
message_id=message_id,
)
@override
def fork_tool_runtime(self, runtime: ToolRuntime) -> PluginTool:
return PluginTool(
entity=self.entity,
@ -57,6 +60,7 @@ class PluginTool(Tool):
plugin_unique_identifier=self.plugin_unique_identifier,
)
@override
def get_runtime_parameters(
self,
conversation_id: str | None = None,

View File

@ -1,4 +1,5 @@
import threading
from typing import override
from flask import Flask, current_app
from pydantic import BaseModel, Field
@ -46,6 +47,7 @@ class DatasetMultiRetrieverTool(DatasetRetrieverBaseTool):
name=f"dataset_{tenant_id.replace('-', '_')}", tenant_id=tenant_id, dataset_ids=dataset_ids, **kwargs
)
@override
def _run(self, query: str) -> str:
threads = []
all_documents: list[RagDocument] = []

View File

@ -1,4 +1,4 @@
from typing import Any, cast
from typing import Any, cast, override
from pydantic import BaseModel, Field
from sqlalchemy import select
@ -56,6 +56,7 @@ class DatasetRetrieverTool(DatasetRetrieverBaseTool):
**kwargs,
)
@override
def _run(self, query: str) -> str:
dataset_stmt = select(Dataset).where(Dataset.tenant_id == self.tenant_id, Dataset.id == self.dataset_id)
dataset = db.session.scalar(dataset_stmt)

View File

@ -1,5 +1,5 @@
from collections.abc import Generator
from typing import Any
from typing import Any, override
from core.app.app_config.entities import DatasetRetrieveConfigEntity
from core.app.entities.app_invoke_entities import InvokeFrom
@ -85,6 +85,7 @@ class DatasetRetrieverTool(Tool):
return tools
@override
def get_runtime_parameters(
self,
conversation_id: str | None = None,
@ -105,9 +106,11 @@ class DatasetRetrieverTool(Tool):
),
]
@override
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.DATASET_RETRIEVAL
@override
def _invoke(
self,
user_id: str,

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import override
from pydantic import Field
from sqlalchemy import select
@ -80,6 +81,7 @@ class WorkflowToolProviderController(ToolProviderController):
return controller
@property
@override
def provider_type(self) -> ToolProviderType:
return ToolProviderType.WORKFLOW

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import Generator, Mapping, Sequence
from typing import Any, cast
from typing import Any, cast, override
from sqlalchemy import select
@ -67,6 +67,7 @@ class WorkflowTool(Tool):
super().__init__(entity=entity, runtime=runtime)
@override
def tool_provider_type(self) -> ToolProviderType:
"""
get the tool provider type
@ -75,6 +76,7 @@ class WorkflowTool(Tool):
"""
return ToolProviderType.WORKFLOW
@override
def _invoke(
self,
user_id: str,
@ -206,6 +208,7 @@ class WorkflowTool(Tool):
return found
return None
@override
def fork_tool_runtime(self, runtime: ToolRuntime) -> WorkflowTool:
"""
fork a new tool with metadata

View File

@ -6,7 +6,7 @@ import time
from abc import ABC, abstractmethod
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from typing import Any, override
from pydantic import BaseModel
@ -62,6 +62,7 @@ class TriggerDebugEventPoller(ABC):
class PluginTriggerDebugEventPoller(TriggerDebugEventPoller):
@override
def poll(self) -> TriggerDebugEvent | None:
from services.trigger.trigger_service import TriggerService
@ -103,6 +104,7 @@ class PluginTriggerDebugEventPoller(TriggerDebugEventPoller):
class WebhookTriggerDebugEventPoller(TriggerDebugEventPoller):
@override
def poll(self) -> TriggerDebugEvent | None:
pool_key = build_webhook_pool_key(
tenant_id=self.tenant_id,
@ -190,6 +192,7 @@ class ScheduleTriggerDebugEventPoller(TriggerDebugEventPoller):
inputs={},
)
@override
def poll(self) -> TriggerDebugEvent | None:
schedule_debug_runtime = self.get_or_create_schedule_debug_runtime()
if schedule_debug_runtime.next_run_at > naive_utc_now():

View File

@ -1,5 +1,5 @@
from collections.abc import Mapping
from typing import Union
from typing import Union, override
from core.entities.provider_entities import BasicProviderConfig, ProviderConfig
from core.helper.provider_cache import ProviderCredentialsCache
@ -16,6 +16,7 @@ class TriggerProviderCredentialsCache(ProviderCredentialsCache):
def __init__(self, tenant_id: str, provider_id: str, credential_id: str):
super().__init__(tenant_id=tenant_id, provider_id=provider_id, credential_id=credential_id)
@override
def _generate_cache_key(self, **kwargs) -> str:
tenant_id = kwargs["tenant_id"]
provider_id = kwargs["provider_id"]
@ -29,6 +30,7 @@ class TriggerProviderOAuthClientParamsCache(ProviderCredentialsCache):
def __init__(self, tenant_id: str, provider_id: str):
super().__init__(tenant_id=tenant_id, provider_id=provider_id)
@override
def _generate_cache_key(self, **kwargs) -> str:
tenant_id = kwargs["tenant_id"]
provider_id = kwargs["provider_id"]
@ -41,6 +43,7 @@ class TriggerProviderPropertiesCache(ProviderCredentialsCache):
def __init__(self, tenant_id: str, provider_id: str, subscription_id: str):
super().__init__(tenant_id=tenant_id, provider_id=provider_id, subscription_id=subscription_id)
@override
def _generate_cache_key(self, **kwargs) -> str:
tenant_id = kwargs["tenant_id"]
provider_id = kwargs["provider_id"]

View File

@ -12,6 +12,7 @@ examples accurate or the LLM will invent fields.
"""
import json
from collections.abc import Iterable
from typing import Any
# Per-node-type configuration cheatsheet.
@ -22,11 +23,24 @@ from typing import Any
# both ``WorkflowService.sync_draft_workflow``'s structural checks and the
# runtime entity validation each node performs when the workflow runs.
#
# The cheatsheet is assembled DYNAMICALLY per request: the planner decides
# which node types the workflow needs, and ``build_node_config_cheatsheet``
# stitches together only the snippets for those types (plus the always-needed
# wrapper / shared-field / edge-handle preamble, and the containers section
# when an iteration / loop is planned). This keeps the builder prompt tight —
# a 3-node summariser no longer carries the schema for 12 unrelated node
# types — and lets each snippet document its FULL schema (e.g. a "file" start
# variable's required ``allowed_file_types``) without bloating every prompt.
#
# The postprocessor in ``runner.py`` fills missing wrapper fields (``type``,
# ``positionAbsolute``, ``width``, ``height``, ``sourcePosition`` /
# ``targetPosition``, edge ``data.sourceType`` / ``data.targetType``), so the
# LLM only needs to emit semantically meaningful fields.
NODE_CONFIG_CHEATSHEET = """\
# Always-included preamble: the node/edge wrapper shape and the shared
# ``data`` fields that apply to every node type, plus the "## Per type" header
# the per-type snippets slot under.
_CHEATSHEET_PREAMBLE = """\
## Node wrapper (every node, top-level)
{"id": "node1" (digits + letters only — see "Node IDs" below),
@ -46,14 +60,26 @@ Children of iteration / loop containers additionally need
"desc": "<one-liner>",
"selected": false}
## Per type — additional "data" fields
## Per type — additional "data" fields (only the node types in your plan are shown)"""
# node_type → its per-type schema snippet. Keyed by the exact ``node_type``
# string the planner emits so ``build_node_config_cheatsheet`` can look each
# one up directly. Iteration / loop are documented in the Containers section
# (they are subgraphs, not leaf nodes) rather than here.
_NODE_SNIPPETS: dict[str, str] = {
"start": """\
- start:
{"variables": [
{"variable": "url", "label": "URL", "type": "text-input",
"required": true, "max_length": 256, "options": []},
{"variable": "topic", "label": "Topic", "type": "paragraph",
"required": false, "max_length": 4096, "options": []}
"required": false, "max_length": 4096, "options": []},
{"variable": "doc", "label": "Document", "type": "file",
"required": true,
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["local_file", "remote_url"],
"allowed_file_extensions": []}
]}
EVERY user-supplied value referenced by a downstream node
(``{{#node-id.var#}}`` in a prompt / answer / template, or
@ -62,19 +88,29 @@ Children of iteration / loop containers additionally need
If the planner's ``start_inputs`` list is non-empty, use it verbatim
(the user prompt section "Start inputs" surfaces it). Types:
text-input | paragraph | select | number | file | file-list.
For a "file" or "file-list" variable you MUST also set
``allowed_file_types`` to a NON-EMPTY subset of
["document", "image", "audio", "video", "custom"] — it is a REQUIRED
field and the draft fails to load (showing "supported file types is
required") without it. Choose by purpose: ["document"] for text
extraction (PDF / Word / PPT / Markdown / …), ["image"] for vision,
etc. Always set ``allowed_file_upload_methods`` to
["local_file", "remote_url"]. Only when you include "custom" must you
also set ``allowed_file_extensions`` to a non-empty list like
[".epub", ".rtf"]; otherwise leave it [].
In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic
system variables — downstream nodes may reference them; do NOT add
them to ``variables``.
them to ``variables``.""",
"end": """\
- end (Workflow mode only):
{"outputs": [
{"variable": "result", "value_selector": ["<src-node-id>", "<out-var>"]}
]}
]}""",
"answer": """\
- answer (Advanced Chat mode only):
{"variables": [],
"answer": "<text with {{#<src>.<var>#}} placeholders>"}
"answer": "<text with {{#<src>.<var>#}} placeholders>"}""",
"llm": """\
- llm:
{"model": {"provider": "<provider>", "name": "<model>", "mode": "chat",
"completion_params": {"temperature": 0.7}},
@ -100,26 +136,26 @@ Children of iteration / loop containers additionally need
values are the translations.
Input: {{#node1.text#}}
* Each placeholder only resolves the variable from its source node —
it cannot be a Jinja template or call a function.
it cannot be a Jinja template or call a function.""",
"knowledge-retrieval": """\
- knowledge-retrieval:
{"query_variable_selector": ["<src>", "<var>"],
"query_attachment_selector": [],
"dataset_ids": [],
"retrieval_mode": "multiple",
"multiple_retrieval_config": {"top_k": 4, "score_threshold": null,
"reranking_enable": false}}
"reranking_enable": false}}""",
"code": """\
- code (escape hatch — only if no installed tool fits):
{"code_language": "python3",
"code": "def main(arg1: str) -> dict:\\n return {'result': arg1}",
"variables": [{"variable": "arg1", "value_selector": ["<src>", "<var>"]}],
"outputs": {"result": {"type": "string", "children": null}}}
"outputs": {"result": {"type": "string", "children": null}}}""",
"template-transform": """\
- template-transform:
{"template": "Hello {{ name }}",
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}""",
"http-request": """\
- http-request (escape hatch — only if no installed tool fits):
{"variables": [], "method": "get", "url": "https://example.com",
"authorization": {"type": "no-auth", "config": null},
@ -129,8 +165,8 @@ Children of iteration / loop containers additionally need
"timeout": {"max_connect_timeout": 0, "max_read_timeout": 0,
"max_write_timeout": 0},
"retry_config": {"retry_enabled": true, "max_retries": 3,
"retry_interval": 100}}
"retry_interval": 100}}""",
"tool": """\
- tool (PREFERRED for external actions when listed in Available tools):
{"provider_id": "<provider>", # provider portion of provider/tool
"provider_type": "builtin", # exact value from catalogue
@ -144,8 +180,8 @@ Children of iteration / loop containers additionally need
Parameter ``type`` is one of:
"mixed" — string template referencing variables ({{#...#}})
"variable" — direct reference, value is ["<src>", "<var>"]
"constant" — literal value
"constant" — literal value""",
"if-else": """\
- if-else:
{"_targetBranches": [{"id": "true", "name": "IF"},
{"id": "false", "name": "ELSE"}],
@ -158,8 +194,8 @@ Children of iteration / loop containers additionally need
"comparison_operator": "is",
"value": "<value>"}]}
]}
Source handle for downstream edges = the case_id ("true" / "false").
Source handle for downstream edges = the case_id ("true" / "false").""",
"question-classifier": """\
- question-classifier:
{"query_variable_selector": ["<src>", "<var>"],
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
@ -169,8 +205,8 @@ Children of iteration / loop containers additionally need
"_targetBranches": [{"id": "1", "name": ""}, {"id": "2", "name": ""}],
"vision": {"enabled": false},
"instruction": ""}
Source handle for downstream edges = the class_id ("1" / "2" / ...).
Source handle for downstream edges = the class_id ("1" / "2" / ...).""",
"parameter-extractor": """\
- parameter-extractor:
{"query": [["<src>", "<var>"]], # array of value_selector arrays
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
@ -179,8 +215,8 @@ Children of iteration / loop containers additionally need
"description": "<purpose>", "required": true}],
"reasoning_mode": "prompt",
"vision": {"enabled": false},
"instruction": ""}
"instruction": ""}""",
"document-extractor": """\
- document-extractor:
{"variable_selector": ["<src>", "<file-var>"], # a file / file-list input
"is_array_file": false} # true when the input is a
@ -188,8 +224,9 @@ Children of iteration / loop containers additionally need
Single output variable ``text``: a string when ``is_array_file`` is false,
an array of strings (one per file) when it is true. ``variable_selector``
MUST point at a ``start`` variable declared with type "file" / "file-list"
(or ``sys.files`` in Advanced-Chat mode).
(or ``sys.files`` in Advanced-Chat mode). That start variable MUST set a
non-empty ``allowed_file_types`` (use ["document"] for document text).""",
"variable-aggregator": """\
- variable-aggregator (merge mutually-exclusive branches into one output):
{"output_type": "string", # VarType of the merged value — one of
# string | number | object | array[string] |
@ -200,8 +237,8 @@ Children of iteration / loop containers additionally need
Output variable: ``output`` (the first branch that actually ran). Place it
after an ``if-else`` / ``question-classifier`` to rejoin paths before the
``end`` / ``answer`` node. Each entry of ``variables`` is a value_selector
array, NOT a placeholder string.
array, NOT a placeholder string.""",
"list-operator": """\
- list-operator (filter / sort / slice an array variable):
{"variable": ["<src>", "<array-var>"],
"filter_by": {"enabled": false, "conditions": []},
@ -210,8 +247,12 @@ Children of iteration / loop containers additionally need
"limit": {"enabled": false, "size": 10}}
Enable only the sub-features you need; ``conditions`` reuse the if-else
condition shape (key / comparison_operator / value). Outputs: ``result``
(the processed array), ``first_record``, ``last_record``.
(the processed array), ``first_record``, ``last_record``.""",
}
# Pulled into the cheatsheet only when an iteration / loop appears in the plan.
_CONTAINERS_SECTION = """\
## Containers — iteration / loop
These are SUBGRAPH nodes. To use one you MUST emit, in order:
@ -270,16 +311,59 @@ These are SUBGRAPH nodes. To use one you MUST emit, in order:
5. The container's incoming/outgoing edges connect to the container's id
(``nodeK``), NOT to inner nodes. The first inner edge connects from
``nodeKstart``.
``nodeKstart``."""
# Always-included trailer: edge handle conventions for every graph.
_EDGE_HANDLES_SECTION = """\
## Edge handles
- Most nodes: sourceHandle "source", targetHandle "target".
- if-else cases: sourceHandle is the case_id ("true" / "false" / ...).
- question-classifier: sourceHandle is the class_id ("1" / "2" / ...).
- iteration-start / sourceHandle "source"; the edge from the *start node
loop-start: is what kicks off the first inner step.
"""
loop-start: is what kicks off the first inner step."""
# Container node types are described in ``_CONTAINERS_SECTION`` rather than as
# leaf snippets; their presence in a plan pulls that section in.
_CONTAINER_NODE_TYPES = frozenset({"iteration", "loop"})
def build_node_config_cheatsheet(node_types: Iterable[str] | None = None) -> str:
"""
Assemble the builder cheatsheet for exactly the node types in the plan.
``node_types`` is the set of ``node_type`` strings the planner chose. We
emit the always-on preamble (wrapper / shared fields), then only the
per-type snippets for the requested types (``start`` is always included —
every graph has one), the Containers section when an iteration / loop is
planned, and the edge-handles trailer. Unknown / unrecognised type strings
are ignored (the runtime / structural validator catches genuinely bogus
types).
``None`` returns the FULL cheatsheet (every snippet + containers) — used to
build the static back-compat constants below and as a safe fallback.
"""
if node_types is None:
requested: set[str] = set(_NODE_SNIPPETS) | set(_CONTAINER_NODE_TYPES)
else:
requested = {str(t).strip() for t in node_types if str(t).strip()}
requested.add("start") # every workflow has exactly one start node
parts: list[str] = [_CHEATSHEET_PREAMBLE]
# Iterate _NODE_SNIPPETS (not ``requested``) to keep a stable, readable order.
parts.extend(snippet for node_type, snippet in _NODE_SNIPPETS.items() if node_type in requested)
if requested & _CONTAINER_NODE_TYPES:
parts.append(_CONTAINERS_SECTION)
parts.append(_EDGE_HANDLES_SECTION)
return "\n\n".join(parts) + "\n"
# Full cheatsheet (all node types) — retained as a module constant so callers
# and tests that want the complete reference can import it directly. The
# dynamic per-request prompt is built by ``get_builder_system_prompt``.
NODE_CONFIG_CHEATSHEET = build_node_config_cheatsheet()
_BASE_SYSTEM_PROMPT_HEAD = """You are a Dify workflow builder.
@ -402,21 +486,24 @@ _ADVANCED_CHAT_MODE_RULES = """# Mode-specific rules — Advanced Chat (Chatflow
"""
BUILDER_SYSTEM_PROMPT_WORKFLOW = (
_BASE_SYSTEM_PROMPT_HEAD
+ _WORKFLOW_MODE_RULES
+ _BASE_SYSTEM_PROMPT_TAIL
+ NODE_CONFIG_CHEATSHEET
+ _BASE_SYSTEM_PROMPT_FOOTER
)
def _assemble_builder_system_prompt(mode: str, node_types: Iterable[str] | None) -> str:
"""Stitch the builder system prompt for ``mode`` around a cheatsheet built
for ``node_types`` (``None`` → full cheatsheet)."""
mode_rules = _ADVANCED_CHAT_MODE_RULES if mode == "advanced-chat" else _WORKFLOW_MODE_RULES
return (
_BASE_SYSTEM_PROMPT_HEAD
+ mode_rules
+ _BASE_SYSTEM_PROMPT_TAIL
+ build_node_config_cheatsheet(node_types)
+ _BASE_SYSTEM_PROMPT_FOOTER
)
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = (
_BASE_SYSTEM_PROMPT_HEAD
+ _ADVANCED_CHAT_MODE_RULES
+ _BASE_SYSTEM_PROMPT_TAIL
+ NODE_CONFIG_CHEATSHEET
+ _BASE_SYSTEM_PROMPT_FOOTER
)
# Static full-cheatsheet prompts — the back-compat default returned by
# ``get_builder_system_prompt`` when the caller doesn't pin a node-type set.
BUILDER_SYSTEM_PROMPT_WORKFLOW = _assemble_builder_system_prompt("workflow", None)
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = _assemble_builder_system_prompt("advanced-chat", None)
BUILDER_USER_PROMPT = """# User instruction
@ -546,8 +633,16 @@ def format_plan_block(plan_nodes: list[dict[str, Any]]) -> str:
return "\n".join(lines)
def get_builder_system_prompt(mode: str) -> str:
"""Pick the system prompt branch for Workflow vs Advanced Chat."""
if mode == "advanced-chat":
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
return BUILDER_SYSTEM_PROMPT_WORKFLOW
def get_builder_system_prompt(mode: str, node_types: Iterable[str] | None = None) -> str:
"""
Build the builder system prompt for ``mode``, with a cheatsheet scoped to
``node_types`` (the planner's chosen node types).
When ``node_types`` is ``None`` we return the cached full-cheatsheet
constant (back-compat default). When the runner passes the plan's node-type
set we assemble a fresh prompt carrying only the relevant per-type schemas,
so the builder isn't handed config for node types the workflow never uses.
"""
if node_types is None:
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT if mode == "advanced-chat" else BUILDER_SYSTEM_PROMPT_WORKFLOW
return _assemble_builder_system_prompt(mode, node_types)

View File

@ -74,6 +74,21 @@ _DEFAULT_VIEWPORT: GraphViewportDict = {"x": 0.0, "y": 0.0, "zoom": 0.7}
_DEFAULT_NODE_WIDTH = 244
_DEFAULT_NODE_HEIGHT = 100
# Start-node input variable types that carry file uploads. Mirrors
# ``graphon.variables.input_entities.VariableEntityType.FILE / FILE_LIST``.
_FILE_VARIABLE_TYPES = frozenset({"file", "file-list"})
# Backstop defaults for a file / file-list start variable when the builder
# omits the required upload config. ``allowed_file_types`` is a REQUIRED field
# (Studio rejects the draft with "supported file types is required" when it's
# empty — see ``config-var/config-modal/utils.ts``); we default to every
# standard type so no valid upload is rejected. ``custom`` is intentionally
# excluded because it would in turn require a non-empty
# ``allowed_file_extensions``. The real fix is the builder now documenting and
# emitting these fields; this is the safety net that guarantees a loadable draft.
_DEFAULT_ALLOWED_FILE_TYPES = ("document", "image", "audio", "video")
_DEFAULT_FILE_UPLOAD_METHODS = ("local_file", "remote_url")
# Token ceiling for the planner call when the caller didn't pin one. The plan
# is a short JSON node list (a handful of nodes with labels/purposes), so this
# is generous headroom while still bounding a runaway response. The builder is
@ -512,8 +527,15 @@ class WorkflowGenerator:
tool_catalogue_section=format_builder_tool_catalogue_section(tool_catalogue_text),
start_inputs_section=format_start_inputs_section(start_inputs or []),
)
# Scope the builder cheatsheet to exactly the node types the planner
# chose, so the prompt carries each type's FULL schema (e.g. a file
# start variable's required ``allowed_file_types``) without dragging in
# config for unrelated node types.
plan_node_types = {
str(node.get("node_type") or "").strip() for node in plan_nodes if str(node.get("node_type") or "").strip()
}
messages = [
SystemPromptMessage(content=get_builder_system_prompt(mode)),
SystemPromptMessage(content=get_builder_system_prompt(mode, plan_node_types)),
UserPromptMessage(content=user_prompt),
]
parsed = cls._invoke_and_parse_json(
@ -658,6 +680,13 @@ class WorkflowGenerator:
# variables before we surface them as errors.
cls._reconcile_variable_references(nodes=nodes, mode=mode)
# Schema backstop: a "file" / "file-list" start variable MUST carry a
# non-empty ``allowed_file_types`` or Studio refuses to load the draft
# ("supported file types is required"). The builder is now told to set
# it, but we fill safe defaults for any variable that still lacks it so
# the generated workflow always loads and runs.
cls._normalize_start_file_variables(nodes=nodes)
return cast(GraphDict, {"nodes": nodes, "edges": deduped_edges, "viewport": viewport})
# ------------------------------------------------------------------
@ -693,6 +722,21 @@ class WorkflowGenerator:
# remapping when we defensively strip hyphens out of LLM-emitted ids.
_ID_FIELDS: ClassVar = frozenset({"start_node_id", "iteration_id", "loop_id", "parentId"})
# ``data`` keys whose value is a plain string list, never a
# ``[node_id, var]`` value-selector — so the reference walker must not read
# a 2-element one as a selector. ``default`` holds an input's default value;
# ``options`` holds select choices; the ``allowed_file_*`` keys hold a file
# variable's upload config (types / extensions / methods).
_NON_SELECTOR_LIST_KEYS: ClassVar = frozenset(
{
"default",
"options",
"allowed_file_types",
"allowed_file_extensions",
"allowed_file_upload_methods",
}
)
@classmethod
def _reconcile_variable_references(cls, *, nodes: list[dict[str, Any]], mode: WorkflowGenerationMode) -> None:
"""
@ -747,12 +791,16 @@ class WorkflowGenerator:
# Known selector shapes: 2-element [node_id, var] lists.
for k, v in value.items():
# ``value_selector`` / ``query_variable_selector`` / etc.: a
# flat 2-element list of strings.
# flat 2-element list of strings. Skip keys whose value is a
# plain string list that merely HAPPENS to have two entries —
# a 2-option ``select`` or a file variable's two allowed upload
# methods are NOT ``[node_id, var]`` selectors and must not be
# mistaken for references.
if (
isinstance(v, list)
and len(v) == 2
and all(isinstance(x, str) for x in v)
and k != "default" # default values for input variables are not selectors
and k not in cls._NON_SELECTOR_LIST_KEYS
):
node_id, var = v[0].strip(), v[1].strip()
if node_id and var:
@ -923,6 +971,96 @@ class WorkflowGenerator:
}
)
@classmethod
def _normalize_start_file_variables(cls, *, nodes: list[dict[str, Any]]) -> None:
"""
Fill the required upload config on every file / file-list start variable.
A start variable of type ``file`` / ``file-list`` is invalid without a
non-empty ``allowed_file_types`` — Studio rejects the draft with
"supported file types is required" (see the front-end validator in
``config-var/config-modal/utils.ts``) and the workflow never runs. The
builder prompt now documents these fields, but LLMs still drop them, so
we backfill safe defaults here:
* a start variable a ``document-extractor`` consumes but that wasn't
declared as a file type → promoted to ``file`` (or ``file-list``
when the extractor's ``is_array_file`` is set), defaulting its
allowed types to ``["document"]`` (what extraction needs);
* empty / missing ``allowed_file_types`` → every standard file type;
* ``custom`` present without ``allowed_file_extensions`` → drop
``custom`` (it would otherwise require a non-empty extension list);
* empty / missing ``allowed_file_upload_methods`` → local + remote;
* ensure ``allowed_file_extensions`` is at least an empty list.
Idempotent: a variable that already declares valid file config is left
untouched.
"""
start_node = next(
(n for n in nodes if (n.get("data") or {}).get("type") == BuiltinNodeTypes.START),
None,
)
if start_node is None:
return
variables = (start_node.get("data") or {}).get("variables")
if not isinstance(variables, list):
return
# Start variables a document-extractor reads → whether it wants an
# array (file-list). These MUST be file inputs even if the builder
# mistyped them (e.g. declared "paragraph"), or the extractor fails at
# run time. ``["document"]`` is the right default for text extraction.
extractor_file_vars = cls._document_extractor_start_vars(nodes=nodes, start_id=start_node.get("id", ""))
for var in variables:
if not isinstance(var, dict):
continue
name = var.get("variable")
if name in extractor_file_vars and var.get("type") not in _FILE_VARIABLE_TYPES:
var["type"] = "file-list" if extractor_file_vars[name] else "file"
var.setdefault("allowed_file_types", ["document"])
if var.get("type") not in _FILE_VARIABLE_TYPES:
continue
allowed_types = var.get("allowed_file_types")
if not isinstance(allowed_types, list) or not allowed_types:
allowed_types = list(_DEFAULT_ALLOWED_FILE_TYPES)
var["allowed_file_types"] = allowed_types
# ``custom`` demands a non-empty extension list; without one, drop it
# so the variable doesn't trip the "file extensions required" check.
extensions = var.get("allowed_file_extensions")
has_extensions = isinstance(extensions, list) and bool(extensions)
if "custom" in allowed_types and not has_extensions:
pruned = [t for t in allowed_types if t != "custom"]
var["allowed_file_types"] = pruned or list(_DEFAULT_ALLOWED_FILE_TYPES)
methods = var.get("allowed_file_upload_methods")
if not isinstance(methods, list) or not methods:
var["allowed_file_upload_methods"] = list(_DEFAULT_FILE_UPLOAD_METHODS)
if not isinstance(var.get("allowed_file_extensions"), list):
var["allowed_file_extensions"] = []
@classmethod
def _document_extractor_start_vars(cls, *, nodes: list[dict[str, Any]], start_id: str) -> dict[str, bool]:
"""
Map start-variable name → ``is_array_file`` for every start variable a
``document-extractor`` node reads via its ``variable_selector``.
When two extractors read the same variable we keep ``True`` (file-list)
if any of them wants an array, since a file-list also satisfies a
single-file read.
"""
out: dict[str, bool] = {}
if not start_id:
return out
for node in nodes:
data = node.get("data") or {}
if data.get("type") != BuiltinNodeTypes.DOCUMENT_EXTRACTOR:
continue
selector = data.get("variable_selector")
if isinstance(selector, list) and len(selector) == 2 and selector[0] == start_id:
var_name = selector[1]
out[var_name] = out.get(var_name, False) or bool(data.get("is_array_file"))
return out
@classmethod
def _fill_node_defaults(cls, node: dict[str, Any]) -> None:
"""Ensure every node has the wrapper-level fields the Studio canvas needs."""

View File

@ -10,7 +10,7 @@ from __future__ import annotations
import enum
import uuid
from collections.abc import Mapping, Sequence
from typing import Annotated, Any, ClassVar, Literal
from typing import Annotated, Any, ClassVar, Literal, override
import bleach
import markdown
@ -158,6 +158,7 @@ class EmailDeliveryMethod(_DeliveryMethodBase):
type: Literal[DeliveryMethodType.EMAIL] = DeliveryMethodType.EMAIL
config: EmailDeliveryConfig
@override
def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
variable_template_parser = VariableTemplateParser(template=self.config.body)
selectors: list[Sequence[str]] = []

View File

@ -195,13 +195,16 @@ class _LazyNodeTypeClassesMapping(MutableMapping[NodeType, Mapping[str, type[Nod
snapshot.update(self._overrides)
return snapshot
@override
def __getitem__(self, key: NodeType) -> Mapping[str, type[Node]]:
return self._snapshot()[key]
@override
def __setitem__(self, key: NodeType, value: Mapping[str, type[Node]]) -> None:
self._deleted.discard(key)
self._overrides[key] = value
@override
def __delitem__(self, key: NodeType) -> None:
if key in self._overrides:
del self._overrides[key]
@ -211,9 +214,11 @@ class _LazyNodeTypeClassesMapping(MutableMapping[NodeType, Mapping[str, type[Nod
return
raise KeyError(key)
@override
def __iter__(self) -> Iterator[NodeType]:
return iter(self._snapshot())
@override
def __len__(self) -> int:
return len(self._snapshot())

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Callable, Generator, Mapping, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, cast, overload
from typing import TYPE_CHECKING, Any, Literal, cast, overload, override
from sqlalchemy import select
from sqlalchemy.orm import Session
@ -136,6 +136,7 @@ class DifyFileReferenceFactory(FileReferenceFactoryProtocol):
def __init__(self, run_context: Mapping[str, Any] | DifyRunContext) -> None:
self._run_context = resolve_dify_run_context(run_context)
@override
def build_from_mapping(self, *, mapping: Mapping[str, Any]):
return file_factory.build_from_mapping(
mapping=mapping,
@ -151,25 +152,31 @@ class DifyPreparedLLM(LLMProtocol):
self._model_instance = model_instance
@property
@override
def provider(self) -> str:
return self._model_instance.provider
@property
@override
def model_name(self) -> str:
return self._model_instance.model_name
@property
@override
def parameters(self) -> Mapping[str, Any]:
return self._model_instance.parameters
@parameters.setter
@override
def parameters(self, value: Mapping[str, Any]) -> None:
self._model_instance.parameters = value
@property
@override
def stop(self) -> Sequence[str] | None:
return self._model_instance.stop
@override
def get_model_schema(self) -> AIModelEntity:
model_schema = cast(LargeLanguageModel, self._model_instance.model_type_instance).get_model_schema(
self._model_instance.model_name,
@ -179,6 +186,7 @@ class DifyPreparedLLM(LLMProtocol):
raise ValueError(f"Model schema not found for {self._model_instance.model_name}")
return model_schema
@override
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
return self._model_instance.get_llm_num_tokens(prompt_messages)
@ -204,6 +212,7 @@ class DifyPreparedLLM(LLMProtocol):
stream: Literal[True],
) -> Generator[LLMResultChunk, None, None]: ...
@override
def invoke_llm(
self,
*,
@ -243,6 +252,7 @@ class DifyPreparedLLM(LLMProtocol):
stream: Literal[True],
) -> Generator[LLMResultChunkWithStructuredOutput, None, None]: ...
@override
def invoke_llm_with_structured_output(
self,
*,
@ -263,11 +273,13 @@ class DifyPreparedLLM(LLMProtocol):
stream=stream,
)
@override
def is_structured_output_parse_error(self, error: Exception) -> bool:
return isinstance(error, OutputParserError)
class DifyPromptMessageSerializer(PromptMessageSerializerProtocol):
@override
def serialize(
self,
*,
@ -294,6 +306,7 @@ class DifyRetrieverAttachmentLoader(RetrieverAttachmentLoaderProtocol):
self._file_reference_factory = file_reference_factory
self._segment_access_checker = segment_access_checker
@override
def load(self, *, segment_id: str) -> Sequence[File]:
if not is_retriever_segment_access_granted(segment_id):
return []
@ -341,6 +354,7 @@ class DifyToolFileManager(ToolFileManagerProtocol):
self._manager = ToolFileManager()
self._conversation_id_getter = conversation_id_getter
@override
def create_file_by_raw(
self,
*,
@ -358,6 +372,7 @@ class DifyToolFileManager(ToolFileManagerProtocol):
filename=filename,
)
@override
def get_file_generator_by_tool_file_id(self, tool_file_id: str):
return self._manager.get_file_generator_by_tool_file_id(tool_file_id)
@ -394,9 +409,11 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
def file_reference_factory(self) -> FileReferenceFactoryProtocol:
return self._file_reference_factory
@override
def build_file_reference(self, *, mapping: Mapping[str, Any]):
return self._file_reference_factory.build_from_mapping(mapping=mapping)
@override
def get_runtime(
self,
*,
@ -447,6 +464,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
)
)
@override
def get_runtime_parameters(
self,
*,
@ -458,6 +476,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
for parameter in (tool.get_merged_runtime_parameters() or [])
]
@override
def invoke(
self,
*,
@ -503,6 +522,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
return self._adapt_messages(transformed_messages, provider_name=provider_name)
@override
def get_usage(
self,
*,
@ -745,6 +765,7 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol):
form_repository=form_repository,
)
@override
def get_form(self, *, node_id: str) -> HumanInputFormStateProtocol | None:
repo = self.build_form_repository()
return repo.get_form(node_id)
@ -766,6 +787,7 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol):
)
return restored_data
@override
def create_form(
self,
*,

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, override
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.workflow.system_variables import SystemVariableKey, get_system_text
@ -56,9 +56,11 @@ class AgentNode(Node[AgentNodeData]):
self._message_transformer = message_transformer
@classmethod
@override
def version(cls) -> str:
return "1"
@override
def populate_start_event(self, event) -> None:
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
event.extras["agent_strategy"] = {
@ -69,6 +71,7 @@ class AgentNode(Node[AgentNodeData]):
),
}
@override
def _run(self) -> Generator[NodeEventBase, None, None]:
from core.plugin.impl.exc import PluginDaemonClientSideError
@ -167,6 +170,7 @@ class AgentNode(Node[AgentNodeData]):
)
@classmethod
@override
def _extract_variable_selector_to_variable_mapping(
cls,
*,

View File

@ -1,11 +1,14 @@
from __future__ import annotations
from typing import override
from factories.agent_factory import get_plugin_agent_strategy
from .strategy_protocols import AgentStrategyPresentationProvider, AgentStrategyResolver, ResolvedAgentStrategy
class PluginAgentStrategyResolver(AgentStrategyResolver):
@override
def resolve(
self,
*,
@ -21,6 +24,7 @@ class PluginAgentStrategyResolver(AgentStrategyResolver):
class PluginAgentStrategyPresentationProvider(AgentStrategyPresentationProvider):
@override
def get_icon(self, *, tenant_id: str, agent_strategy_provider_name: str) -> str | None:
from core.plugin.impl.plugin import PluginInstaller

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import logging
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, override
from agenton.compositor import CompositorSessionSnapshot
@ -101,12 +101,15 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
self._session_store = session_store
@classmethod
@override
def version(cls) -> str:
return "2"
@override
def populate_start_event(self, event) -> None:
event.extras["agent_node"] = {"version": "2", "agent_node_kind": self.node_data.agent_node_kind}
@override
def _run(self) -> Generator[NodeEventBase, None, None]:
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
workflow_id = self.graph_init_params.workflow_id
@ -577,6 +580,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
metadata["agent_backend"] = agent_backend
@classmethod
@override
def _extract_variable_selector_to_variable_mapping(
cls,
*,

View File

@ -1,5 +1,5 @@
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, override
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.datasource.datasource_manager import DatasourceManager
@ -49,10 +49,12 @@ class DatasourceNode(Node[DatasourceNodeData]):
)
self.datasource_manager = DatasourceManager
@override
def populate_start_event(self, event) -> None:
event.provider_id = f"{self.node_data.plugin_id}/{self.node_data.provider_name}"
event.provider_type = self.node_data.provider_type
@override
def _run(self) -> Generator:
"""
Run the datasource node
@ -183,6 +185,7 @@ class DatasourceNode(Node[DatasourceNodeData]):
)
@classmethod
@override
def _extract_variable_selector_to_variable_mapping(
cls,
*,
@ -219,5 +222,6 @@ class DatasourceNode(Node[DatasourceNodeData]):
return result
@classmethod
@override
def version(cls) -> str:
return "1"

View File

@ -1,6 +1,6 @@
import logging
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, override
from core.rag.index_processor.index_processor import IndexProcessor
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
@ -46,6 +46,7 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
self.index_processor = IndexProcessor()
self.summary_index_service = SummaryIndex()
@override
def _run(self) -> NodeRunResult:
node_data = self.node_data
variable_pool = self.graph_runtime_state.variable_pool
@ -145,6 +146,7 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
return rst
@classmethod
@override
def version(cls) -> str:
return "1"

View File

@ -6,7 +6,7 @@ the workflow node registry.
import logging
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any, Literal, override
from core.app.app_config.entities import DatasetRetrieveConfigEntity
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
@ -87,9 +87,11 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
self._rag_retrieval = DatasetRetrieval()
@classmethod
@override
def version(cls):
return "1"
@override
def _run(self) -> NodeRunResult:
usage = LLMUsage.empty_usage()
if not self._node_data.query_variable_selector and not self._node_data.query_attachment_selector:
@ -327,6 +329,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
)
@classmethod
@override
def _extract_variable_selector_to_variable_mapping(
cls,
*,

View File

@ -1,5 +1,5 @@
from collections.abc import Mapping
from typing import Any
from typing import Any, override
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
@ -15,6 +15,7 @@ class TriggerEventNode(Node[TriggerEventNodeData]):
execution_type = NodeExecutionType.ROOT
@classmethod
@override
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
return {
"type": "plugin",
@ -30,12 +31,15 @@ class TriggerEventNode(Node[TriggerEventNodeData]):
}
@classmethod
@override
def version(cls) -> str:
return "1"
@override
def populate_start_event(self, event) -> None:
event.provider_id = self.node_data.provider_id
@override
def _run(self) -> NodeRunResult:
"""
Run the plugin trigger node.

View File

@ -1,4 +1,5 @@
from collections.abc import Mapping
from typing import override
from core.trigger.constants import TRIGGER_SCHEDULE_NODE_TYPE
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
@ -14,10 +15,12 @@ class TriggerScheduleNode(Node[TriggerScheduleNodeData]):
execution_type = NodeExecutionType.ROOT
@classmethod
@override
def version(cls) -> str:
return "1"
@classmethod
@override
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
return {
"type": TRIGGER_SCHEDULE_NODE_TYPE,
@ -29,6 +32,7 @@ class TriggerScheduleNode(Node[TriggerScheduleNodeData]):
},
}
@override
def _run(self) -> NodeRunResult:
node_inputs = dict(self.graph_runtime_state.variable_pool.get_by_prefix(self.id))
system_inputs = self.graph_runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)

View File

@ -1,6 +1,6 @@
import logging
from collections.abc import Mapping
from typing import Any
from typing import Any, override
from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE
from core.workflow.file_reference import resolve_file_record_id
@ -25,12 +25,14 @@ class TriggerWebhookNode(Node[WebhookData]):
_file_reference_factory: FileReferenceFactoryProtocol
@override
def post_init(self) -> None:
from core.workflow.node_runtime import DifyFileReferenceFactory
self._file_reference_factory = DifyFileReferenceFactory(self.run_context)
@classmethod
@override
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
return {
"type": "webhook",
@ -48,9 +50,11 @@ class TriggerWebhookNode(Node[WebhookData]):
}
@classmethod
@override
def version(cls) -> str:
return "1"
@override
def _run(self) -> NodeRunResult:
"""
Run the webhook node.

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from typing import Any, override
from core.helper.code_executor.code_executor import CodeExecutionError, CodeExecutor
from graphon.nodes.code.entities import CodeLanguage
@ -11,6 +11,7 @@ from graphon.template_rendering import Jinja2TemplateRenderer, TemplateRenderErr
class CodeExecutorJinja2TemplateRenderer(Jinja2TemplateRenderer):
"""Sandbox-backed Jinja2 renderer for workflow-owned node composition."""
@override
def render_template(self, template: str, variables: Mapping[str, Any]) -> str:
try:
result = CodeExecutor.execute_workflow_code_template(

View File

@ -1,3 +1,5 @@
from typing import override
"""Custom OTEL ID Generator for correlation-based trace/span ID derivation.
Uses contextvars for thread-safe correlation_id -> trace_id mapping.
@ -52,6 +54,7 @@ class CorrelationIdGenerator(IdGenerator):
parent-child linking), otherwise random
"""
@override
def generate_trace_id(self) -> int:
correlation_id = _correlation_id_context.get()
if correlation_id:
@ -61,6 +64,7 @@ class CorrelationIdGenerator(IdGenerator):
pass
return random.getrandbits(128)
@override
def generate_span_id(self) -> int:
source = _span_id_source_context.get()
if source:

View File

@ -5,6 +5,7 @@ def init_app(app: DifyApp):
from commands import (
add_qdrant_index,
archive_workflow_runs,
backfill_plugin_auto_upgrade,
clean_expired_messages,
clean_workflow_runs,
cleanup_orphaned_draft_variables,
@ -53,6 +54,7 @@ def init_app(app: DifyApp):
upgrade_db,
fix_app_site_missing,
migrate_data_for_plugin,
backfill_plugin_auto_upgrade,
extract_plugins,
extract_unique_plugins,
install_plugins,

View File

@ -1,4 +1,5 @@
import json
from typing import override
from flask_restx import fields
@ -7,6 +8,7 @@ from libs.helper import AppIconUrlField, TimestampField
class JsonStringField(fields.Raw):
@override
def format(self, value):
if isinstance(value, str):
try:

View File

@ -1,9 +1,12 @@
from typing import override
from flask_restx import fields
from graphon.file import File
class FilesContainedField(fields.Raw):
@override
def format(self, value):
return self._format_file_object(value)

View File

@ -1,3 +1,5 @@
from typing import override
from flask_restx import fields
from core.helper import encrypter
@ -11,6 +13,7 @@ ENVIRONMENT_VARIABLE_SUPPORTED_TYPES = (SegmentType.STRING, SegmentType.NUMBER,
class EnvironmentVariableField(fields.Raw):
@override
def format(self, value):
# Mask secret variables values in environment_variables
if isinstance(value, SecretVariable):

View File

@ -0,0 +1,42 @@
"""add plugin auto upgrade category
Revision ID: f6a7b8c9d012
Revises: 8d4c2a1b9f03
Create Date: 2026-05-15 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f6a7b8c9d012"
down_revision = "8d4c2a1b9f03"
branch_labels = None
depends_on = None
LEGACY_CATEGORY = "tool"
UNIQUE_CONSTRAINT_NAME = "unique_tenant_plugin_auto_upgrade_strategy"
UPGRADE_TIME_INDEX_NAME = "idx_tenant_plugin_auto_upgrade_strategy_time"
STRATEGY_TABLE_NAME = "tenant_plugin_auto_upgrade_strategies"
def upgrade():
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
batch_op.add_column(
sa.Column("category", sa.String(length=32), server_default=LEGACY_CATEGORY, nullable=False)
)
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id", "category"])
batch_op.create_index(UPGRADE_TIME_INDEX_NAME, ["upgrade_time_of_day"])
def downgrade():
op.execute(sa.text(f"DELETE FROM {STRATEGY_TABLE_NAME} WHERE category != '{LEGACY_CATEGORY}'"))
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
batch_op.drop_index(UPGRADE_TIME_INDEX_NAME)
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
batch_op.drop_column("category")
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id"])

View File

@ -0,0 +1,26 @@
"""add learn dify flag to recommended apps
Revision ID: f5e8a9c0d2b3
Revises: f6a7b8c9d012
Create Date: 2026-05-18 15:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f5e8a9c0d2b3"
down_revision = "f6a7b8c9d012"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
batch_op.add_column(sa.Column("is_learn_dify", sa.Boolean(), server_default=sa.text("false"), nullable=False))
def downgrade():
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
batch_op.drop_column("is_learn_dify")

View File

@ -0,0 +1,44 @@
"""add identity mode to mcp tool provider
Revision ID: 3df4dbcc1e21
Revises: 2b3c4d5e6f70
Create Date: 2026-05-29 15:00:00.000000
Adds the `identity_mode` column to `tool_mcp_providers` to drive the M2 MCP
user-identity forwarding feature. Reserved values:
"off" — no header forwarded (default; pre-M2 behaviour).
"idp_token" — call dify-enterprise /inner/api/mcp/issue-token, stamp the
returned SSO access token on the outbound MCP request as
`X-Dify-SSO-Access-Token: <token>`.
The column is filled with the safe default "off" for existing rows so older
providers keep their current behaviour until an admin opts in.
"""
import sqlalchemy as sa
from alembic import op
import models as models
# revision identifiers, used by Alembic.
revision = "3df4dbcc1e21"
down_revision = "2b3c4d5e6f70"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"tool_mcp_providers",
sa.Column(
"identity_mode",
sa.String(length=32),
nullable=False,
server_default=sa.text("'off'"),
),
)
def downgrade():
op.drop_column("tool_mcp_providers", "identity_mode")

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