Compare commits

..

439 Commits

Author SHA1 Message Date
958e0709db merge hitl fix 2026-06-05 15:54:42 +08:00
2bfd051d0d fix(web): style issue of add input field panel in human input form content 2026-06-05 15:44:15 +08:00
yyh
a1d9340a62 feat: update frontend code review skill (#37098) 2026-06-05 07:35:04 +00:00
yyh
3addc1e386 fix(workflow): prevent inspect trigger text wrapping (#37099) 2026-06-05 07:26:23 +00:00
24876bb05d fix: avoid duplicating lines when merging text for summarization (#37093)
Co-authored-by: bymle <229636660+bymle@users.noreply.github.com>
2026-06-05 07:02:57 +00:00
yyh
0cdd478f25 fix(web): stabilize block selector layout (#37089) 2026-06-05 07:00:03 +00:00
0db9714eb6 fix(web): attach Amplitude user ID before firing registration event (#37091)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 06:31:27 +00:00
yyh
9da4d167fa fix(explore): render human input preview handles (#37086) 2026-06-05 03:32:29 +00:00
a1ad4be61e fix(api): expose device-flow approve rate limit as env var (#37083) 2026-06-05 02:56:23 +00:00
8cb2cffbf7 feat: improve output node (#35511) 2026-06-05 02:14:23 +00:00
a66ffa66dd merge main 2026-06-04 10:56:39 +08:00
59f287a491 Merge branch 'feat/snippet-fe' into feat/snippet 2026-06-04 10:52:04 +08:00
d2416e0106 merge main 2026-06-04 10:51:01 +08:00
a348304ad0 chore: clean up snippet whitespace 2026-06-04 10:20:44 +08:00
5c17a01e21 feat: add customized snippets table and update workflows schema 2026-06-04 10:19:27 +08:00
a244cbcbdf feat: add author name property to snippet and update fields 2026-06-04 10:19:27 +08:00
8056f56fc0 feat: support indexed creator IDs in snippet list query normalization 2026-06-04 10:19:27 +08:00
0cedbafe46 feat: add alias support for creator ID in snippet list query 2026-06-04 10:19:27 +08:00
b7e7351ffa feat: validate snippet graph for forbidden nodes during creation 2026-06-04 10:19:27 +08:00
446dfae257 feat: allow duplicate snippet names and enhance snippet import handling 2026-06-04 10:19:27 +08:00
45c1b4b692 feat: add function to store node inputs under aliases in variable pool 2026-06-04 10:19:27 +08:00
b15f15fa06 feat: enhance snippet workflow handling with start node injection 2026-06-04 10:19:27 +08:00
ec4dd8a2f3 feat: add support for excluding node IDs in variable listing 2026-06-04 10:18:50 +08:00
710ffea9fc feat: snippet supports tags. 2026-06-04 10:18:50 +08:00
5e5871c16b feat: snippet supports tags. 2026-06-04 10:18:50 +08:00
226c232e80 feat: lite snippet. 2026-06-04 10:18:50 +08:00
ac18ba4a04 Merge branch 'main' into feat/snippet-fe 2026-06-04 09:19:22 +08:00
caa727d312 Merge branch 'feat/snippet-fe' into deploy/dev 2026-06-03 17:56:39 +08:00
409ac5b0ed feat(web): tip of deleting snippet 2026-06-03 17:55:45 +08:00
374a418b0d Merge remote-tracking branch 'origin/deploy/dev' into deploy/dev 2026-06-03 17:36:48 +08:00
81c81de04a Merge branch 'main' into feat/snippet-fe 2026-06-03 17:30:22 +08:00
89bb58204a Merge branch 'feat/snippet-fe' into deploy/dev 2026-06-03 17:09:24 +08:00
f39f71a6a6 fix(web): create snippet from workflow 2026-06-03 17:05:06 +08:00
7dc57d0973 Merge branch 'feat/snippet-fe' into deploy/dev 2026-06-03 16:30:40 +08:00
bb3f11c465 Merge branch 'main' into feat/snippet-fe 2026-06-03 16:29:07 +08:00
1392945f69 feat(web): snippet readonly judgement 2026-06-03 16:27:05 +08:00
abc02921c8 fix(web): empty snippet can not save 2026-06-03 16:15:44 +08:00
ad5951e91b Merge branch 'main' into feat/snippet-fe 2026-06-03 15:52:42 +08:00
10a14eb2fa Merge remote-tracking branch 'upstream/main' into deploy/dev 2026-06-03 15:48:22 +08:00
eead4f9260 fix(web): empty graph sync caused by input fields init 2026-06-03 15:46:46 +08:00
9a42969577 feat(web): modify leaving modal 2026-06-03 15:09:32 +08:00
f0b238c180 feat(web): modify leaving modal 2026-06-03 15:06:24 +08:00
e53969a73a fix(web): snippet draft sync 2026-06-03 14:53:06 +08:00
9d0f1819ec fix(web): do not sync draft when readonly 2026-06-03 14:41:13 +08:00
eff1586e69 feat: add customized snippets table and update workflows schema 2026-06-03 14:26:49 +08:00
ea9063284e Merge branch 'feat/snippet-fe' into deploy/dev 2026-06-03 11:23:28 +08:00
17a79788e3 fix(web): local draft update correctly 2026-06-03 11:22:20 +08:00
6cbc82fe4e feat: add author name property to snippet and update fields 2026-06-03 11:07:20 +08:00
b5797ce5ba fix(web): fix prompt editor readonly state change 2026-06-03 10:50:34 +08:00
7afc71e149 Merge remote-tracking branch 'origin/deploy/dev' into deploy/dev 2026-06-03 10:46:12 +08:00
d4b2bd9aac feat: support indexed creator IDs in snippet list query normalization 2026-06-03 10:45:39 +08:00
bc226fc54b Merge branch 'feat/snippet-fe' into deploy/dev 2026-06-02 21:12:08 +08:00
8c4e6b962f fix(web): refresh histories after snippet saved 2026-06-02 21:08:43 +08:00
e24d5b5c4f fix(web): fix error toast of snippet creation 2026-06-02 20:58:45 +08:00
e71fe27fe0 fix(web): creator filter supports multiple member ids 2026-06-02 20:49:22 +08:00
31711c7bd0 feat: add alias support for creator ID in snippet list query 2026-06-02 17:47:17 +08:00
9d6ba8ea69 Merge remote-tracking branch 'origin/deploy/dev' into deploy/dev 2026-06-02 15:18:49 +08:00
48cce619a2 feat: enhance variable pool initialization with compatible start aliases 2026-06-02 15:15:30 +08:00
baff73e2e1 Merge branch 'feat/snippet-fe' into deploy/dev 2026-06-02 15:15:23 +08:00
c05d6b1ebb fix(web): input fields update in test run panel 2026-06-02 15:14:36 +08:00
cb6fbbc5df fix(web): fix filter for snippet available variables 2026-06-02 14:38:30 +08:00
ab0eedd78c merge snippet fe 2026-06-02 12:56:06 +08:00
b0d623dde9 Merge branch 'main' into feat/snippet-fe 2026-06-02 12:49:05 +08:00
3ac832e3e7 fix(web): first draft post of snippet 2026-06-02 12:48:40 +08:00
c594ccd238 fix(web): fix merge error 2026-06-02 12:24:55 +08:00
529686ae6f Merge branch 'main' into feat/snippet-fe 2026-06-02 12:20:12 +08:00
bc8b069f90 fix(web): fix merge error 2026-06-02 12:16:08 +08:00
6a34cc1491 fix(web): fix merge error 2026-06-02 11:32:05 +08:00
d0df0172bc merge snippet fe 2026-06-01 15:53:31 +08:00
5ca88518b8 Merge branch 'main' into feat/snippet-fe 2026-06-01 15:51:32 +08:00
3e32c4d3ca fix(web): remove creatorID in queryin apps / snippets 2026-06-01 15:51:16 +08:00
7f14c02243 Merge branch 'main' into feat/snippet-fe 2026-06-01 15:04:08 +08:00
5636077c1a Merge branch 'main' into feat/snippet-fe 2026-06-01 13:57:30 +08:00
43cd83284a fix(api): fix merge error 2026-06-01 13:57:03 +08:00
502591e838 Merge branch 'feat/snippet-fe' into deploy/dev 2026-06-01 13:49:59 +08:00
bccc948031 Merge branch 'main' into feat/snippet-fe 2026-06-01 12:56:52 +08:00
3c1bc6ac36 fix(web): checkValid of snippet 2026-06-01 12:55:57 +08:00
00cf21a1b7 Merge branch 'main' into feat/snippet-fe 2026-06-01 10:48:01 +08:00
705d317f57 merge main 2026-05-29 17:04:05 +08:00
e0158fe9fd fix(web): snippet draft sync 2026-05-29 16:47:48 +08:00
48e6902f5f fix(web): create snippet from workflow 2026-05-29 15:15:43 +08:00
efe98b1e52 fix(web): create snippet from workflow 2026-05-29 14:49:00 +08:00
a62c616664 fix(web): show draft in snippet in default 2026-05-29 14:29:39 +08:00
307611d16e feat: dev snippet backend (#36812) 2026-05-29 14:14:33 +08:00
41ba73835d fix(web): create snippet from workflow 2026-05-29 13:47:14 +08:00
e7afd1e596 feat: dev snippet backend (#36804) 2026-05-29 11:00:26 +08:00
ed442771f4 Merge branch 'main' into feat/snippet-fe 2026-05-29 10:26:41 +08:00
5730ede96f feat: dev snippet fronted (#36785)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: EvanYao826 <155432245+EvanYao826@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Tianle <40735546+Tianlel@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
Co-authored-by: zyssyz123 <916125788@qq.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Nian <11332799+Lillian68@users.noreply.github.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: Carmen Fernández Ruiz <279459669+zeus1959@users.noreply.github.com>
Co-authored-by: wangxiaolei <fatelei@gmail.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: Evan <2869018789@qq.com>
Co-authored-by: Escape0707 <tothesong@gmail.com>
Co-authored-by: Jingyi <jingyi.qi@dify.ai>
Co-authored-by: Amr Sherif <140330826+amr-sheriff@users.noreply.github.com>
Co-authored-by: ZHOU ZHICHEN <118870511+zhuiguangzhe2003@users.noreply.github.com>
Co-authored-by: unknown <EI05187@apwx.com>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-28 18:01:08 +08:00
107bba0116 feat: dev snippet fronted (#36784)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: EvanYao826 <155432245+EvanYao826@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Tianle <40735546+Tianlel@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
Co-authored-by: zyssyz123 <916125788@qq.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Nian <11332799+Lillian68@users.noreply.github.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: Carmen Fernández Ruiz <279459669+zeus1959@users.noreply.github.com>
Co-authored-by: wangxiaolei <fatelei@gmail.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: Evan <2869018789@qq.com>
Co-authored-by: Escape0707 <tothesong@gmail.com>
Co-authored-by: Jingyi <jingyi.qi@dify.ai>
Co-authored-by: Amr Sherif <140330826+amr-sheriff@users.noreply.github.com>
Co-authored-by: ZHOU ZHICHEN <118870511+zhuiguangzhe2003@users.noreply.github.com>
Co-authored-by: unknown <EI05187@apwx.com>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-28 17:34:14 +08:00
c46a313d78 Merge branch 'main' into feat/snippet-fe 2026-05-28 14:31:54 +08:00
e1fec86a2a Merge branch 'main' into feat/evaluation-fe 2026-05-28 13:11:45 +08:00
6ec893cb0e feat(web): new interactions in snippet detail graph 2026-05-28 13:11:07 +08:00
8be34ee000 feat(web): support create snippet by import DSL 2026-05-28 11:42:57 +08:00
458f669883 Merge branch 'main' into feat/evaluation-fe 2026-05-28 11:04:59 +08:00
94fd4e9c67 Merge branch 'main' into feat/evaluation-fe 2026-05-27 22:34:15 +08:00
f5da3ce499 fix(web): refresh list after snippet created 2026-05-27 22:33:45 +08:00
08f2971f72 fix(web): snippet draft sync 2026-05-27 22:29:49 +08:00
54ac42fbc4 Merge branch 'main' into feat/evaluation-fe 2026-05-27 22:08:12 +08:00
1d1d571213 fix(web): error message of snippet creation 2026-05-27 22:07:06 +08:00
056caa8b2f fix(web): style of tag filter of snippets 2026-05-27 22:00:05 +08:00
1cf6cdb764 fix(web): stop snippet run 2026-05-27 21:39:14 +08:00
ac9083fbf1 fix(web): tooltip of snippet saved 2026-05-27 21:24:07 +08:00
fdfc9ab3d3 fix(web): sys variables not supported in snippet 2026-05-27 21:13:19 +08:00
83cd1a8d7a fix(web): snippet restore 2026-05-27 21:06:36 +08:00
a3dfd670b0 fix(web): sys variables not supported in snippet 2026-05-27 21:03:00 +08:00
facace019b fix(web): merge error fix 2026-05-27 20:58:03 +08:00
fd9543868d Merge branch 'main' into feat/evaluation-fe 2026-05-27 20:33:59 +08:00
0e6cb87f08 fix(workflow): keep pointer position out of reactive store 2026-05-27 17:30:02 +08:00
ef9c607f04 fix(api): raise BadRequest when retrieving parameters for disabled web app 2026-05-27 15:49:32 +08:00
9d082489c9 feat: snippet new (#36684) 2026-05-26 17:24:17 +08:00
c550d6b085 feat: snippet new (#36682) 2026-05-26 16:40:15 +08:00
db5d5bfffe Merge branch 'p428' into deploy/dev 2026-05-26 09:52:44 +08:00
0f2cbc2968 fix: when exclude_vector_space should feature.vector_space not has default value 2026-05-26 09:50:08 +08:00
892387ea38 fix merge error 2026-05-26 09:34:04 +08:00
2f351641e4 Merge remote-tracking branch 'myori/main' into p428 2026-05-26 09:32:21 +08:00
36a51dca8b chore: backend feature api exclude_vector_space 2026-05-26 09:30:17 +08:00
93d9423c95 fix: member invite limits with dedup, locking, and accurate new-member counting (#36512) 2026-05-25 17:18:53 +08:00
3cabe9058b fix : features get null vector_space 2026-05-25 17:03:46 +08:00
d925ed2f28 feat: snippet new (#36617) 2026-05-25 16:36:35 +08:00
d859728dd7 Merge branch 'feat/evaluation-fe' into deploy/dev 2026-05-25 15:45:26 +08:00
89188256e1 Merge branch 'main' into feat/evaluation-fe 2026-05-25 15:44:50 +08:00
bba3a1bcee fix(web): draft sync of snippet 2026-05-25 15:44:27 +08:00
d66bfc7434 Merge branch 'feat/evaluation-fe' into deploy/dev 2026-05-25 14:37:07 +08:00
7c0be7f905 Merge branch 'main' into feat/evaluation-fe 2026-05-25 14:35:41 +08:00
599e3475f2 fix(web): hide snippets tab in block-selector in snippet detail 2026-05-25 14:35:24 +08:00
718fe548e9 fix(web): cancel button in snippet 2026-05-25 14:30:39 +08:00
060ceaffd1 feat(web): snippet info siderbar 2026-05-25 14:18:15 +08:00
00908ca0fb Merge branch 'main' into feat/evaluation-fe 2026-05-25 13:36:05 +08:00
2812d61e24 feat(web): snippet card style 2026-05-25 13:35:59 +08:00
8adcac87a5 fix : features get null vector_space 2026-05-25 12:04:29 +08:00
544d8567c9 Merge branch 'feat/evaluation-fe' into deploy/dev 2026-05-25 11:41:33 +08:00
be1d6520f9 fix(web): icon_info nullable in dataset 2026-05-25 11:41:02 +08:00
eeb1cd19bd Merge branch 'feat/evaluation-fe' into deploy/dev 2026-05-25 11:06:03 +08:00
7fb2e4751f Merge branch 'main' into feat/evaluation-fe 2026-05-25 11:05:26 +08:00
e4620b4b22 feat: new snippet (#36597) 2026-05-25 10:25:13 +08:00
8af1766081 Merge remote-tracking branch 'origin/main' 2026-05-25 10:12:24 +08:00
5441992604 Merge branch 'main' into feat/evaluation-fe 2026-05-25 10:00:09 +08:00
9d0597c22d feat(web): snippet layout update 2026-05-23 11:06:57 +08:00
5d489ab92d chore(web): remove snippet plan guard 2026-05-23 10:28:22 +08:00
930da499d1 feat(web): add snippet 2026-05-23 10:20:10 +08:00
f1527ef7c1 feat(web): create snippet from workflow 2026-05-23 10:01:25 +08:00
20f89b6e90 feat(web): operations of snippet card 2026-05-23 09:12:43 +08:00
05e69b104a fix(web): label of snippet creation modal 2026-05-23 08:59:46 +08:00
f39b1b6731 Merge branch 'main' into feat/evaluation-fe 2026-05-23 08:54:10 +08:00
a7005efab3 chore(web): remove icon info of snippet 2026-05-22 18:27:37 +08:00
f605288429 feat(web): snippets header nav 2026-05-22 18:13:27 +08:00
2bb3b439e0 refactor(web): snippet list page 2026-05-22 18:06:29 +08:00
75daf8e61b feat(web): update filters in apps 2026-05-22 17:43:24 +08:00
bf30b11d0d merge main 2026-05-22 16:12:02 +08:00
778e472173 merge main 2026-05-22 10:23:30 +08:00
b91de7e54b Merge remote-tracking branch 'origin/main' 2026-05-20 18:44:07 +08:00
2885ba8519 Merge branch 'main' into feat/evaluation-fe 2026-05-20 11:32:08 +08:00
e23c3d1491 chore(web): remove evaluation frontend 2026-05-19 17:39:28 +08:00
888292564b merge main 2026-05-19 13:58:23 +08:00
1a0c8f6173 fix(api): avoid committing inside RAG pipeline DSL service. 2026-05-14 16:54:50 +08:00
d8851a4994 fix(api): avoid committing inside RAG pipeline DSL service. 2026-05-14 11:36:02 +08:00
8a21679ea8 Merge remote-tracking branch 'origin/main' 2026-05-14 11:00:27 +08:00
b8a594def0 fix: add unit tests. 2026-05-13 15:18:02 +08:00
69a77ad9ce Merge remote-tracking branch 'origin/fix/issue-36090' 2026-05-13 14:56:33 +08:00
93728bb39f Merge remote-tracking branch 'origin/main' 2026-05-13 14:54:53 +08:00
c4da7a0bed fix: add unit tests. 2026-05-13 14:54:15 +08:00
05fd412670 Merge branch 'main' into fix/issue-36090 2026-05-13 14:34:17 +08:00
a4821288cc fix: When hit-testing, an empty document dict is returned due to DocumentSegment type modification. 2026-05-13 14:33:33 +08:00
fc0a4a6b56 fix: When hit-testing, an empty document dict is returned due to DocumentSegment type modification. 2026-05-13 14:06:27 +08:00
0a3bb67778 Merge remote-tracking branch 'origin/main' 2026-05-13 10:41:30 +08:00
5e9f419154 fix: For core.tools.signature.sign_upload_file function, the file URL is generated for external preview or download only. 2026-05-09 16:25:44 +08:00
6b84383590 Merge remote-tracking branch 'origin/fix/issue-35910' 2026-05-09 10:57:42 +08:00
d7f99d6458 fix: Using CONSOLE_API_URL to generate an image preview URL causes the image preview to fail on the chunks details page of the knowledge base. 2026-05-09 10:55:59 +08:00
6c80ee8f48 Merge branch 'main' into fix/issue-35910 2026-05-09 10:28:37 +08:00
ea71990388 fix: Using CONSOLE_API_URL to generate an image preview URL causes the image preview to fail on the chunks details page of the knowledge base. 2026-05-09 10:09:54 +08:00
36e8677b1a Merge remote-tracking branch 'origin/fix/issue-35910' 2026-05-08 14:24:37 +08:00
5c31a774ea Merge remote-tracking branch 'origin/main' 2026-05-08 14:22:14 +08:00
9e137e12ab fix: Image rendering in the knowledge base failed. 2026-05-08 14:16:25 +08:00
18e2ecd6c5 Merge branch 'main' into fix/issue-35910 2026-05-08 14:02:27 +08:00
8a23126f29 fix: Image rendering in the knowledge base failed. 2026-05-08 13:55:05 +08:00
6c5f6699d2 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py
2026-05-08 11:32:47 +08:00
124b786dfb fix(web): fix app list 2026-05-05 21:34:55 +08:00
dd54ca0cab feat(web): fix template 2026-05-05 20:41:53 +08:00
8a72e46ce8 feat(web): use api to fetch template 2026-05-05 20:27:35 +08:00
f00f8e020f Merge branch 'main' into jzh 2026-05-05 19:55:21 +08:00
aa078a854c fix(web): evaluation detail in workflow log 2026-04-30 17:19:15 +08:00
712aae4d98 fix(web): workflow log crash 2026-04-30 17:06:56 +08:00
bacadc4d35 Merge branch 'main' into jzh 2026-04-30 16:51:33 +08:00
b060e81824 fix(web): test history of batch test 2026-04-30 16:51:16 +08:00
b45f83492e Merge branch 'main' into jzh 2026-04-30 15:31:04 +08:00
d1e1a4a8ab fix(web): correct templates 2026-04-30 15:30:37 +08:00
4519847e81 Merge branch 'main' into jzh 2026-04-30 14:19:21 +08:00
3763efbc7c fix(web): selected workflow name fix 2026-04-30 14:18:32 +08:00
552f202ca8 Merge branch 'main' into jzh 2026-04-30 13:00:41 +08:00
dc76f4082f fix(web): i18n of switch modal 2026-04-30 12:53:52 +08:00
6d01095586 fix(web): only workflow can switch 2026-04-30 12:41:37 +08:00
b914e48a41 fix(web): add toast for workflow switch 2026-04-30 12:30:22 +08:00
da482ec455 fix(web): custom metric display 2026-04-30 12:20:56 +08:00
48c38ace54 fix(web): rag publisher 2026-04-30 11:02:23 +08:00
2b1496c857 Merge branch 'main' into jzh 2026-04-30 08:54:42 +08:00
c15e437ff7 Merge branch 'main' into jzh 2026-04-30 00:07:30 +08:00
0ac0eccce4 fix(web): correct template of dataset evaluation 2026-04-30 00:06:59 +08:00
678327e994 fix(wbe): style of switch button 2026-04-29 22:48:38 +08:00
b0478f4df7 fix(web): workflow switch to evaluation 2026-04-29 22:25:47 +08:00
00319f0e43 fix(web): snippet run logs 2026-04-29 21:25:50 +08:00
55eb894d8e fix(web): scroll of dataset evaluation page 2026-04-29 20:48:39 +08:00
c59a80a41f fix(web): test detail of dataset 2026-04-29 20:38:54 +08:00
24b482893d fix(web): pipeline batch test template 2026-04-29 20:36:07 +08:00
ad58895b25 fix(web): template of dataset evaluation template 2026-04-29 20:30:43 +08:00
25fc518c5d fix(web): style of config 2026-04-29 20:28:41 +08:00
d92722e7ab fix(web): default metrics for dataset 2026-04-29 20:16:42 +08:00
4041fd7e5c fix(web): auto select model in evaluation 2026-04-29 20:10:37 +08:00
06ea73a19b Merge branch 'main' into jzh 2026-04-29 18:40:11 +08:00
7384a3c121 fix(web): template generate 2026-04-29 18:05:34 +08:00
c18c953a7c fix(web): style of batch test 2026-04-29 17:46:29 +08:00
ae2df0c35e fix(web): style of batch test 2026-04-29 17:28:53 +08:00
dacc7fc740 fix(web): style of conditions 2026-04-29 17:15:32 +08:00
9af2c1252c fix(web): remove node 2026-04-29 16:17:31 +08:00
35bfe26a3a fix(web): default metrics 2026-04-29 16:07:42 +08:00
8686362aeb Merge branch 'main' into jzh 2026-04-29 16:06:27 +08:00
f5955489ec Merge branch 'main' into jzh 2026-04-29 15:34:27 +08:00
aaa15770d5 fix(web): metric descriptions 2026-04-29 15:34:00 +08:00
08c01c4f3f chore(web): remove mock data of evaluation 2026-04-29 15:28:10 +08:00
0903c30060 fix(web): metric icon & tooltips 2026-04-29 15:10:51 +08:00
b420298398 fix(web): style of default metric 2026-04-29 14:31:23 +08:00
2607eb8d32 feat(web): default metrics 2026-04-29 13:56:33 +08:00
d8173b1cda feat(web): add reset button 2026-04-29 13:32:46 +08:00
c56f1a8216 fix(web): detail info in snippet evaluation 2026-04-29 13:11:09 +08:00
31e74371ef Merge branch 'main' into jzh 2026-04-29 12:06:29 +08:00
e48f13f173 Merge branch 'main' into jzh 2026-04-28 16:34:20 +08:00
c574363cf6 chore(web): remove unused data 2026-04-28 16:34:00 +08:00
70fd4a5c88 feat(web): support snippet nav 2026-04-28 16:26:39 +08:00
e62a67c719 fix: hit-testing response failed because of Pydantic check. 2026-04-28 16:22:27 +08:00
57c1195253 Merge remote-tracking branch 'origin/main' 2026-04-28 16:19:14 +08:00
42889d23e5 fix(web): snippet card 2026-04-28 16:05:26 +08:00
3a7f09a250 fix(web): snippet publish check 2026-04-28 15:42:31 +08:00
d95d4335bf fix(web): snippet can use input fields 2026-04-28 15:30:35 +08:00
735e88f673 fix(web): snippet usage count 2026-04-28 15:03:03 +08:00
c55105bff3 fix(web): snippet add 2026-04-28 14:58:43 +08:00
77afc805e1 fix(web): snippet draft sync 2026-04-28 14:42:51 +08:00
9dd73b4d47 Merge branch 'main' into jzh 2026-04-28 11:02:44 +08:00
f2b12bfef7 Merge branch 'main' into jzh 2026-04-27 15:49:05 +08:00
dbeaf79d77 fix(web): snippet init 2026-04-27 15:45:13 +08:00
63dcb4dd6c refactor(web): remove mock data of snippet detail 2026-04-27 15:01:57 +08:00
9df3a7bcf9 Merge branch 'main' into jzh 2026-04-27 14:47:53 +08:00
89163edd16 Merge branch 'main' into jzh 2026-04-27 13:48:47 +08:00
eaa55aab1e Merge branch 'main' into jzh 2026-04-27 11:45:30 +08:00
8d3a690c0a Merge branch 'main' into jzh 2026-04-24 18:22:52 +08:00
5263a65ed6 Merge branch 'main' into jzh 2026-04-24 17:52:27 +08:00
24d3e8edba Merge branch 'main' into jzh 2026-04-23 16:18:36 +08:00
b371dd2cdf Merge branch 'main' into jzh 2026-04-22 20:44:13 +08:00
597ad8c425 Merge branch 'main' into jzh 2026-04-21 21:01:05 +08:00
33f9d96caa Merge branch 'main' into jzh 2026-04-21 11:06:45 +08:00
689571df22 Merge branch 'main' into jzh 2026-04-20 18:01:17 +08:00
a3242f0634 Merge branch 'main' into jzh 2026-04-20 16:42:16 +08:00
f5112928b3 fix(web): snippet graph view port 2026-04-20 16:39:38 +08:00
bcd87ddc58 fix: publish as evaluation 2026-04-20 16:24:53 +08:00
7c8a87af05 Merge branch 'main' into jzh 2026-04-20 16:11:07 +08:00
8e2d507e5c fix(web): fix selection menu hide 2026-04-20 16:08:58 +08:00
b6fbec066d fix(web): nav icons 2026-04-20 15:47:34 +08:00
bd136cadce fix: evaluation switch button 2026-04-20 14:37:13 +08:00
0a934e1143 fix merge error 2026-04-20 13:46:31 +08:00
c44ba62da3 Merge branch 'main' into jzh 2026-04-20 12:06:18 +08:00
76c0aed05c fix merge error 2026-04-20 11:58:17 +08:00
e7fc22c6b3 Merge branch 'main' into jzh 2026-04-20 10:24:40 +08:00
b91727b804 Merge branch 'main' into jzh 2026-04-20 10:23:09 +08:00
534fd79377 Merge branch 'main' into jzh 2026-04-17 16:46:10 +08:00
3ea4742b29 Merge branch 'main' into jzh 2026-04-17 16:26:19 +08:00
364c0eb6e2 Merge branch 'main' into jzh 2026-04-17 14:57:13 +08:00
322b3ff641 fix(web): fix merge error 2026-04-17 14:49:08 +08:00
38736c154b Merge branch 'main' into jzh 2026-04-17 14:05:23 +08:00
129f681c59 fix(web): slient snippet draft fetching 2026-04-16 12:29:07 +08:00
d776fc0827 fix(web): icon missing 2026-04-16 12:18:48 +08:00
7af6074cb5 Merge branch 'main' into jzh 2026-04-16 12:09:59 +08:00
7aa700bf2b fix(web): fix merge 2026-04-16 12:06:53 +08:00
0d47750b15 Merge branch 'main' into jzh 2026-04-16 11:48:48 +08:00
a9dc57eeef fix(web): input fields form & graph publish 2026-04-16 11:07:26 +08:00
5bfebd371d fix(web): snippet draft sync 2026-04-16 10:31:35 +08:00
f1da2c76d1 fix(web): add page title for snippet 2026-04-15 18:22:20 +08:00
b5dc774093 feat(web): empty list of snippet 2026-04-15 16:53:37 +08:00
b7fe45d800 Merge branch 'main' into jzh 2026-04-15 15:57:48 +08:00
7f5bbe0ee3 fix(web): button import 2026-04-15 15:23:50 +08:00
40632589a2 Merge branch 'main' into jzh 2026-04-15 15:01:33 +08:00
e6e063138e Merge branch 'main' into jzh 2026-04-15 10:20:27 +08:00
605af8d60e Merge branch 'main' into jzh 2026-04-14 14:05:44 +08:00
03660c19ef Merge remote-tracking branch 'origin/main'
# Conflicts:
#	api/providers/vdb/vdb-weaviate/src/dify_vdb_weaviate/weaviate_vector.py
2026-04-14 13:54:31 +08:00
8747e3a2d3 Merge branch 'main' into jzh 2026-04-14 10:14:13 +08:00
7fd549fd39 fix: Compatibility issues with the summary index feature when using the weaviate vector database. 2026-04-13 18:44:53 +08:00
1712a2732a Merge branch 'main' into jzh 2026-04-13 17:22:27 +08:00
46bc76bae3 feat(web): add evaluation cell in workflow log 2026-04-13 17:22:04 +08:00
e24b6c27b0 Merge remote-tracking branch 'origin/main' 2026-04-13 14:29:52 +08:00
8c6dda125f Merge branch 'main' into jzh 2026-04-13 14:13:32 +08:00
f6047aafe8 feat(web): metric descriptions 2026-04-13 14:13:04 +08:00
dce5715982 Merge branch 'main' into jzh 2026-04-13 13:35:13 +08:00
ea910b8e7d Merge branch 'main' into jzh 2026-04-13 10:29:55 +08:00
e51af66d95 feat(web): support creator filtering in apps & snippets 2026-04-12 12:19:17 +08:00
f93b287949 feat(web): billing for evaluation & snippets 2026-04-12 11:09:15 +08:00
627fbd2e86 feat(web): rag-pipeline evaluation configuration 2026-04-12 10:36:20 +08:00
e4c056a57a Merge branch 'main' into jzh 2026-04-12 10:16:13 +08:00
23291398ec feat(web): switch warining dialog 2026-04-10 18:13:13 +08:00
79fc352a5a feat(web): evaluation run detail 2026-04-10 17:48:28 +08:00
8b6b3cddea refactor(web): rag-pipeline evaluation 2026-04-10 17:31:10 +08:00
d1ca468c1e Merge branch 'main' into jzh 2026-04-10 17:12:57 +08:00
ce28ad771c feat(web): test history of rag-pipeline evaluation 2026-04-10 17:12:27 +08:00
ba951b01de feat(web): template download 2026-04-10 17:05:14 +08:00
670ab16ea1 feat(web): template download & upload & run in rag-pipeline 2026-04-10 16:51:06 +08:00
4680535ecd Merge branch 'main' into jzh 2026-04-10 13:49:26 +08:00
f96e63460e feat(web): save configuration 2026-04-10 13:49:05 +08:00
2df79c0404 refactor: input fields 2026-04-10 12:04:51 +08:00
acef9630d5 feat(web): input fields display 2026-04-10 11:39:35 +08:00
12c3b2e0cd feat(web): start run 2026-04-10 11:26:12 +08:00
577707ae50 refactor(web): input fields 2026-04-10 11:10:24 +08:00
03325e9750 feat(web): run history of batch test 2026-04-10 10:58:40 +08:00
a7ef8f9c12 Merge branch 'main' into jzh 2026-04-10 10:36:45 +08:00
40284d9f95 refactor(web): batch test of evaluation 2026-04-09 21:19:51 +08:00
5efe8b8bd7 feat(web): add condition 2026-04-09 21:08:45 +08:00
8dc6d736ee refactor(web): store of evaluation 2026-04-09 20:32:53 +08:00
5316372772 feat(web): judgement condition 2026-04-09 20:18:25 +08:00
4d1499ef75 refactor(web): refactor condition group 2026-04-09 19:46:18 +08:00
0438285277 Merge branch 'main' into jzh 2026-04-09 19:24:10 +08:00
4879ea5cd5 feat(web): support variable selecting in variable mapping 2026-04-09 19:23:22 +08:00
2a1761ac06 feat(web): add output 2026-04-09 18:16:30 +08:00
c29245c1cb feat(web): only one evaluation workflow can be added 2026-04-09 17:43:34 +08:00
5069694bba refactor(web): remove unused metric property 2026-04-09 17:30:10 +08:00
d1a80a85c0 refactor(web): evaluation configure schema update 2026-04-09 17:17:15 +08:00
5c93d74dec Merge branch 'main' into jzh 2026-04-09 15:36:00 +08:00
e52dbd49be feat(web): dataset evaluation configure 2026-04-09 15:34:59 +08:00
ccc8a5f278 refactor(web): dataset evaluation 2026-04-09 14:56:22 +08:00
cfb5b9dfea feat(web): dataset evaluation configure fetch 2026-04-09 14:21:01 +08:00
73d95245f8 feat(web): dataset evaluation layout 2026-04-09 13:44:29 +08:00
fb91984fcb feat(web): add evaluation navigation for rag-pipeline 2026-04-09 13:26:43 +08:00
29cb1fa12e Merge branch 'main' into jzh 2026-04-09 13:15:20 +08:00
78240ed199 Merge branch 'main' into jzh 2026-04-09 09:07:12 +08:00
8f8707fd77 Merge branch 'main' into jzh 2026-04-07 16:57:37 +08:00
ed3db06154 feat(web): restrictions of evalution workflow available nodes 2026-04-07 16:12:25 +08:00
7c05a68876 Merge branch 'main' into jzh 2026-04-07 14:41:42 +08:00
6cfc0dd8e1 Merge branch 'main' into jzh 2026-04-07 12:52:13 +08:00
81baeae5c4 fix(web): evaluation workflow switch 2026-04-03 18:22:44 +08:00
a3010bdc0b Merge branch 'main' into jzh 2026-04-03 18:05:54 +08:00
8133e550ed chore: fix pre-hook of web 2026-04-03 16:21:32 +08:00
2bb0eab636 chore(web): mapping row refactor 2026-04-03 16:10:41 +08:00
5311b5d00d feat(web): available evaluation workflow selector 2026-04-03 16:06:33 +08:00
9b02ccdd12 Merge branch 'main' into jzh 2026-04-03 15:15:11 +08:00
231783eebe chore(web): fix lint 2026-04-03 15:13:52 +08:00
756606f478 feat(web): hide card view in evaluation 2026-04-03 14:39:41 +08:00
6651c1c5da feat(web): workflow switch 2026-04-03 14:22:50 +08:00
61e257b2a8 feat(web): app switch api 2026-04-03 13:56:00 +08:00
3ac4caf735 Merge branch 'main' into jzh 2026-04-03 11:28:22 +08:00
268ae1751d Merge branch 'main' into jzh 2026-04-01 09:26:13 +08:00
015cbf850b Merge branch 'main' into jzh 2026-03-31 18:08:24 +08:00
873e13c2fb feat(web): support select node in metric card 2026-03-31 18:07:52 +08:00
688bf7e7a1 feat(web): metric card style 2026-03-31 17:43:56 +08:00
a6ffff3b39 fix(web): fix style of metric selector 2026-03-31 17:22:07 +08:00
023fc55bd5 fix(web): empty state of metric 2026-03-31 17:11:44 +08:00
351b909a53 feat(web): metric card 2026-03-31 17:00:37 +08:00
6bec4f65c9 refactor(web): metric section refactor 2026-03-31 16:28:48 +08:00
74f87ce152 Merge branch 'main' into jzh 2026-03-31 16:13:04 +08:00
92c472ccc7 Merge branch 'main' into jzh 2026-03-30 15:40:23 +08:00
b92b8becd1 feat(web): metric selector 2026-03-30 15:39:52 +08:00
23d0d6a65d chore(web): i18n of metrics 2026-03-30 14:20:43 +08:00
1660067d6e feat(web): judgement model selector 2026-03-30 14:03:37 +08:00
0642475b85 Merge branch 'main' into jzh 2026-03-30 13:30:10 +08:00
8cb634c9bc feat(web): evaluation layout 2026-03-30 11:27:06 +08:00
768b41c3cf Merge branch 'main' into jzh 2026-03-30 11:07:42 +08:00
ca88516d54 refactor(web): refactor evaluation page 2026-03-30 11:06:41 +08:00
871a2a149f refactor(web): split snippet index 2026-03-30 10:32:59 +08:00
60e381eff0 Merge branch 'main' into jzh 2026-03-30 09:48:58 +08:00
768b3eb6f9 feat(web): test run of snippet 2026-03-29 20:55:11 +08:00
2f88da4a6d feat(web): add variable inspect for snippet 2026-03-29 20:23:24 +08:00
a8cdf6964c feat(web): test run button 2026-03-29 20:02:59 +08:00
985c3db4fd feat(web): snippet input field panel layout 2026-03-29 18:02:27 +08:00
9636472db7 refactor(web): snippet main 2026-03-29 17:50:30 +08:00
0ad268aa7d feat(web): snippet publish 2026-03-29 17:29:37 +08:00
a4ea33167d feat(web): block selector in snippet 2026-03-29 17:01:32 +08:00
0f13aabea8 feat(web): input fields in snippet 2026-03-29 16:31:38 +08:00
1e76ef5ccb chore(web): ignore system vars & conversation vars in rag-pipeline and snippet 2026-03-29 15:56:24 +08:00
e6e3229d17 feat(web): input field button style 2026-03-29 15:45:05 +08:00
dccf8e723a feat(web): snippet version panel 2026-03-29 15:26:59 +08:00
c41ba7d627 feat(web): snippet header in graph 2026-03-29 15:02:34 +08:00
a6e9316de3 Merge branch 'main' into jzh 2026-03-29 14:07:49 +08:00
559d326cbd chore(web): mock data of snippet 2026-03-27 17:24:01 +08:00
abedf2506f Merge branch 'main' into jzh 2026-03-27 17:01:27 +08:00
d01428b5bc feat(web): snippet graph draft sync 2026-03-27 16:02:47 +08:00
0de1f17e5c Merge branch 'main' into jzh 2026-03-27 15:23:49 +08:00
17d07a5a43 feat(web): init snippet graph 2026-03-27 15:23:03 +08:00
3bdbea99a3 Merge branch 'main' into jzh 2026-03-27 14:04:10 +08:00
b7683aedb1 Merge branch 'main' into jzh 2026-03-26 21:38:48 +08:00
515036e758 test(web): add tests for snippets 2026-03-26 21:38:22 +08:00
22b382527f feat(web): add snippet to workflow 2026-03-26 21:26:29 +08:00
2cfe4b5b86 feat(web): snippet graph data fetching 2026-03-26 21:11:09 +08:00
6876c8041c feat(web): snippet list data fetching in block selector 2026-03-26 20:58:42 +08:00
7de45584ce refactor: snippets list 2026-03-26 20:41:51 +08:00
5572d7c7e8 Merge branch 'main' into jzh 2026-03-26 20:10:47 +08:00
db0a2fe52e Merge branch 'main' into jzh 2026-03-26 16:29:44 +08:00
f0ae8d6167 fix(web): unused imports caused by merge 2026-03-26 16:28:56 +08:00
2514e181ba Merge branch 'main' into jzh 2026-03-26 16:16:10 +08:00
be2e6e9a14 Merge branch 'main' into jzh 2026-03-26 14:23:29 +08:00
875e2eac1b Merge branch 'main' into jzh 2026-03-26 08:38:57 +08:00
c3c73ceb1f Merge branch 'main' into jzh 2026-03-25 23:02:18 +08:00
6318bf0a2a feat(web): create snippet from workflow 2026-03-25 22:57:48 +08:00
5e1f252046 feat(web): selection context menu style update 2026-03-25 22:36:27 +08:00
df3b960505 fix(web): position of selection context menu in workflow graph 2026-03-25 22:02:50 +08:00
26bc108bf1 chore(web): tests for snippet info 2026-03-25 21:35:36 +08:00
a5cff32743 feat(web): snippet info operations 2026-03-25 21:29:06 +08:00
d418dd8eec Merge branch 'main' into jzh 2026-03-25 20:17:32 +08:00
61702fe346 Merge branch 'main' into jzh 2026-03-25 18:17:03 +08:00
43f0c780c3 Merge branch 'main' into jzh 2026-03-25 15:30:21 +08:00
30ebf2bfa9 Merge branch 'main' into jzh 2026-03-24 07:25:22 +08:00
7e3027b5f7 feat(web): snippet card usage info 2026-03-23 17:02:00 +08:00
b3acf83090 Merge branch 'main' into jzh 2026-03-23 16:46:26 +08:00
36c3d6e48a feat(web): snippet list fetching & display 2026-03-23 16:37:05 +08:00
f782ac6b3c feat(web): create snippets by DSL import 2026-03-23 14:55:36 +08:00
feef2dd1fa feat(web): add snippet creation dialog flow 2026-03-23 11:29:41 +08:00
a716d8789d refactor: extract snippet list components 2026-03-23 10:48:15 +08:00
6816f89189 Merge branch 'main' into jzh 2026-03-23 10:13:45 +08:00
bfcac64a9d Merge branch 'main' into jzh 2026-03-20 15:33:49 +08:00
664eb601a2 feat(web): add api of snippet worfklows 2026-03-20 15:29:53 +08:00
8e5cc4e0aa feat(web): add evaluation api 2026-03-20 15:23:03 +08:00
9f28575903 feat(web): add snippets api 2026-03-20 15:11:33 +08:00
4b9a26a5e6 Merge branch 'main' into jzh 2026-03-20 14:01:34 +08:00
7b85adf1cc Merge branch 'main' into jzh 2026-03-20 10:46:45 +08:00
917d362a58 fix: Querying document list based on hit_count caused slow SQL. 2026-03-19 18:08:00 +08:00
3c27a90eb9 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py
2026-03-19 18:04:33 +08:00
c964708ebe Merge branch 'main' into jzh 2026-03-18 18:07:20 +08:00
883eb498c0 Merge branch 'main' into jzh 2026-03-18 17:40:51 +08:00
b85af2ec47 fix: When can not obtain pipeline template detail failed from upstream service including remote template service and database, return responding error message. 2026-03-18 11:20:50 +08:00
2f0f97aa66 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 11:09:28 +08:00
a6e03c6735 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 11:07:00 +08:00
e7cbfb89d6 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 11:05:39 +08:00
6c2decfbfb Apply suggestion from @gemini-code-assist[bot]
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-18 11:00:47 +08:00
b33d6d1d4a Merge branch 'main' into fix/issue-33624 2026-03-18 10:59:28 +08:00
1b32e70dc5 fix: When can not obtain pipeline template detail failed from upstream service including remote template service and database, return responding error message. 2026-03-18 10:51:07 +08:00
4d3738d225 Merge branch 'main' into feat/evaluation-fe 2026-03-17 10:42:44 +08:00
b5e90e77aa Merge remote-tracking branch 'origin/main' 2026-03-17 10:33:00 +08:00
dd0dee739d Merge branch 'main' into jzh 2026-03-16 15:43:20 +08:00
4d19914fcb Merge branch 'main' into feat/evaluation-fe 2026-03-16 10:47:37 +08:00
887c7710e9 feat: evaluation 2026-03-16 10:46:33 +08:00
7a722773c7 feat: snippet canvas 2026-03-13 17:45:04 +08:00
a763aff58b feat: snippets list 2026-03-13 16:12:42 +08:00
c1011f4e5c feat: add to snippet 2026-03-13 14:29:59 +08:00
f7afa103a5 feat: select snippets 2026-03-13 13:43:29 +08:00
d0bd5b473b Merge remote-tracking branch 'origin/main' 2026-03-03 15:37:42 +08:00
08b28b4029 fix: Add the validation of doc_form in the Document-related service APIs. 2026-03-03 14:59:51 +08:00
269bf883c2 fix: Add the validation of doc_form in the Document-related service APIs. 2026-03-03 14:31:51 +08:00
299 changed files with 20045 additions and 910 deletions

View File

@ -1,73 +1,93 @@
---
name: frontend-code-review
description: "Trigger when the user requests a review of frontend files (e.g., `.tsx`, `.ts`, `.js`). Support both pending-change reviews and focused file reviews while applying the checklist rules."
description: Review Dify frontend code for correctness, accessibility, component design, dify-ui usage, data/query boundaries, performance, and tests. Trigger for `.tsx`, `.ts`, `.js`, UI, React, Next.js, pending-change, or focused frontend review requests.
---
# Frontend Code Review
## Intent
Use this skill whenever the user asks to review frontend code (especially `.tsx`, `.ts`, or `.js` files). Support two review modes:
## When To Use
1. **Pending-change review** inspect staged/working-tree files slated for commit and flag checklist violations before submission.
2. **File-targeted review** review the specific file(s) the user names and report the relevant checklist findings.
Use this skill when the user asks to review, audit, analyze, or sanity-check frontend code under `web/`, `packages/dify-ui/`, or frontend-adjacent TypeScript files.
Stick to the checklist below for every applicable file and mode.
Supported modes:
## Checklist
See [references/code-quality.md](references/code-quality.md), [references/performance.md](references/performance.md), [references/business-logic.md](references/business-logic.md) for the living checklist split by category—treat it as the canonical set of rules to follow.
- **Pending-change review**: inspect staged and working-tree changes.
- **File-focused review**: inspect explicitly named files or paths.
- **Diff/snippet review**: review pasted diffs or snippets using best-effort references.
Flag each rule violation with urgency metadata so future reviewers can prioritize fixes.
Do not use this skill for backend-only code under `api/`; use `backend-code-review` instead.
## Required Context
Before reviewing, read the relevant local contracts:
- `web/AGENTS.md` for Dify frontend workflow, overlays, design tokens, state, and tests.
- `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md` when code uses or changes `@langgenius/dify-ui/*`.
- `web/docs/overlay.md` when reviewing dialogs, drawers, popovers, tooltips, menus, selects, comboboxes, or other floating UI.
- `web/docs/test.md` and the `frontend-testing` skill when reviewing tests or testability.
- `how-to-write-component` when reviewing React component structure, ownership, effects, query/mutation contracts, or memoization.
For any UI, UX, or accessibility review, fetch the latest Web Interface Guidelines before finalizing findings. Treat them as a required baseline, not the complete source of accessibility truth:
```text
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
If the review depends on a current framework, SDK, browser API, or accessibility behavior and local code does not settle it, check the current official docs first. For browser compatibility, deprecation, or behavior-sensitive frontend APIs, verify MDN or the relevant standard.
## Rule Packs
Apply every relevant rule pack:
- [references/accessibility-ui.md](references/accessibility-ui.md) — accessibility, semantic HTML, focus, forms, keyboard, disabled states, copy, and long-content behavior. Combines Web Interface Guidelines with Dify UI, Base UI, MDN, and local primitive contracts.
- [references/dify-ui.md](references/dify-ui.md) — Dify UI primitive usage, Base UI semantics, overlays, forms, tokens, radius mapping, and primitive boundaries.
- [references/component-architecture.md](references/component-architecture.md) — component ownership, props, state, effects, exports, wrappers, and feature organization.
- [references/data-query-contracts.md](references/data-query-contracts.md) — generated contracts, TanStack Query, mutations, workspace/auth/SSR boundaries, URL/local storage state.
- [references/performance.md](references/performance.md) — React/Next performance review rules from Vercel guidance, scoped to real risk.
- [references/testing.md](references/testing.md) — frontend test review rules.
- [references/dify-invariants.md](references/dify-invariants.md) — stable Dify-specific runtime invariants that generic React/a11y rules will not catch.
- [references/code-quality.md](references/code-quality.md) — general TypeScript, styling, naming, and maintainability rules.
## Review Process
1. Open the relevant component/module. Gather lines that relate to class names, React Flow hooks, prop memoization, and styling.
2. For each rule in the review point, note where the code deviates and capture a representative snippet.
3. Compose the review section per the template below. Group violations first by **Urgent** flag, then by category order (Code Quality, Performance, Business Logic).
## Required output
When invoked, the response must exactly follow one of the two templates:
1. Identify the review scope. For pending changes, inspect `git diff --stat`, `git diff`, and staged diff if relevant. For file-focused reviews, stay within the named files unless a referenced owner/contract must be read.
2. Read code around the changed lines and the owning module. Do not review by isolated snippets when nearby ownership, labels, query inputs, or overlay structure decide correctness.
3. Check user-visible regressions first: accessibility, broken interaction, auth/permission leaks, query/hydration errors, data loss, navigation mistakes, and impossible states.
4. Then check maintainability and performance: ownership, effects, wrappers, memoization, bundle/waterfall risks, tests, and design-system drift.
5. Report only actionable findings. Do not list speculative risks, style preferences, or broad refactors unless they are directly tied to a reproducible issue in scope.
### Template A (any findings)
```
# Code review
Found <N> urgent issues need to be fixed:
## Severity
## 1 <brief description of bug>
FilePath: <path> line <line>
<relevant code snippet or pointer>
- **P0**: security/privacy/auth leak, data loss, production crash, inaccessible critical flow, or broken primary workflow.
- **P1**: user-visible regression, hydration/SSR failure, invalid API/query contract, broken keyboard/focus behavior, or serious design-system/a11y violation.
- **P2**: maintainability or performance issue likely to cause bugs, duplicated state, incorrect ownership, missing tests for risky behavior, or non-critical a11y issue.
- **P3**: minor cleanup with clear value. Omit unless the user asked for a thorough audit.
## Output Format
### Suggested fix
<brief description of suggested fix>
Lead with findings, ordered by severity. Use this structure:
---
... (repeat for each urgent issue) ...
```markdown
## Findings
Found <M> suggestions for improvement:
- [P1] Short issue title
File: `path/to/file.tsx:123`
Why it matters and how to reproduce or reason about it.
Suggested fix: concrete fix direction.
## 1 <brief description of suggestion>
FilePath: <path> line <line>
<relevant code snippet or pointer>
## Open Questions
- Question or assumption, if any.
### Suggested fix
<brief description of suggested fix>
## Summary
---
... (repeat for each suggestion) ...
Brief secondary context. Mention tests not run or residual risk.
```
If there are no urgent issues, omit that section. If there are no suggestions, omit that section.
If the issue number is more than 10, summarize as "10+ urgent issues" or "10+ suggestions" and just output the first 10 issues.
Don't compress the blank lines between sections; keep them as-is for readability.
If you use Template A (i.e., there are issues to fix) and at least one issue requires code changes, append a brief follow-up question after the structured output asking whether the user wants you to apply the suggested fix(es). For example: "Would you like me to use the Suggested fix section to address these issues?"
### Template B (no issues)
```
## Code review
No issues found.
```
Rules:
- If there are no findings, say `No issues found.` and mention any test gaps or residual risk.
- Always include file and line when available.
- Keep findings concrete and reproducible.
- Do not include praise sections by default.
- Do not ask to apply fixes unless the user explicitly wants review plus implementation.

View File

@ -0,0 +1,109 @@
# Accessibility And UI Rules
Accessibility findings are first-class review findings. Treat broken keyboard access, missing accessible names, focus loss, and unreachable popup content as correctness bugs, not polish.
Before finalizing UI or accessibility findings, fetch the latest Web Interface Guidelines as a required baseline:
```text
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Do not treat that document as the complete accessibility rule set. Combine it with:
- `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, and the relevant primitive implementation when code uses `@langgenius/dify-ui/*`.
- Base UI docs and local `.d.ts` contracts when primitive semantics, focus target, labels, or popup reachability are unclear.
- MDN or relevant WAI-ARIA/browser standards when behavior, compatibility, or deprecation status matters.
- The current feature's product semantics, because an accessible primitive can still be used in an inaccessible workflow.
## Semantic HTML
Flag:
- Clickable `div` or `span` used for actions.
- Router navigation implemented with button or `onClick` when a `Link` / `<a>` is the real semantic element.
- Icon-only buttons without `aria-label` or `aria-labelledby`.
- Decorative icons missing `aria-hidden="true"`.
- Images without `alt`; use `alt=""` only when truly decorative.
- Heading levels that skip hierarchy in page-level content.
Prefer semantic HTML before ARIA.
## Keyboard And Focus
Flag:
- Interactive elements without visible `focus-visible` treatment.
- `outline-none` / `outline-hidden` without an equivalent focus-visible ring or state.
- Custom interactive elements missing keyboard handling.
- Focus trapped, lost, or sent to the wrong surface after dialog/popover/menu close.
- Focus ring applied to the wrong DOM node. Verify the actual focus target, especially with Base UI controls such as Slider.
Use `focus-visible` for keyboard focus. Use `focus-within` or `has-[:focus-visible]` when the visual wrapper is not the focused element.
## Forms
Flag:
- Inputs, selects, switches, checkboxes, radios, comboboxes, or sliders without a label relationship.
- Missing stable `name` on form fields that submit or validate.
- Incorrect input `type`, `inputMode`, `autoComplete`, or `spellCheck` for email, token, URL, number, search, code, or username fields.
- Labels that are not clickable.
- Submit buttons disabled before a request starts, preventing normal submit behavior.
- Non-submit buttons inside forms missing `type="button"`.
- Errors not associated with fields or not reachable by screen readers.
- Error recovery that does not focus or expose the first invalid field.
- `onPaste` blocking paste.
- Placeholder text used as the only label.
- Password managers accidentally triggered on non-auth fields because autocomplete is missing or wrong.
Prefer visible labels. If visible surrounding text already labels the control, use a visually hidden label or a precise `aria-label`.
## Disabled, Loading, And Async States
Flag:
- Loading state without `aria-busy`, `role="status"`, or another accessible update path when it changes user interaction.
- Spinner or decorative loading icon exposed to screen readers.
- Disabled controls that hide the reason users cannot proceed.
- `aria-disabled` used without manually blocking click, Space, and Enter.
- Toasts, inline validation, or async status changes that are not announced when users need the update to continue.
- Icon-only loading/error affordances without text or accessible status where the state matters.
Use native `disabled` when the control must not be interactive. Use `aria-disabled` only when the element must remain focusable and the code handles all blocked interactions.
For repeated shared disabled reasons, prefer a visible group message or badge plus native disabled controls. Use per-control popover/info only when the reason is item-specific.
## Overlays And Popup Reachability
Flag:
- Tooltip used for long, structured, interactive, or unique information.
- Tooltip content required to understand or complete a flow.
- PreviewCard content that touch or screen-reader users cannot reach through the trigger's click destination.
- Popover/dialog/menu triggers without accessible names.
- Popup content without title/description where the primitive requires them.
Use Popover for explanatory content, rich help, and infotips. Use Tooltip only as a short visual label for a trigger that already has an accessible name.
## Long Content And Layout
Flag:
- Text in flex/grid children without `min-w-0` when it can overflow.
- Names, labels, file names, model names, workspace names, or user content lacking `truncate`, `line-clamp`, or `break-words`.
- Right-side icons, badges, checks, or actions that shrink before the text area.
- Empty arrays or empty strings rendering broken layout instead of an empty state.
- Button, tab, badge, chip, menu item, or card text that can overlap sibling controls at common viewport widths.
The usual Dify layout chain is: container has width constraints, text region uses `min-w-0 flex-1 truncate`, adornments use `shrink-0`.
## Motion, Images, And Copy
Flag:
- `transition-all`.
- Animations that do not respect reduced motion.
- Layout-affecting animation where transform/opacity would work.
- Images without dimensions.
- Loading copy using `...` instead of `…`.
- Hardcoded dates, times, numbers, or currency formats instead of `Intl.*`.

View File

@ -1,15 +0,0 @@
# Rule Catalog — Business Logic
## Can't use workflowStore in Node components
IsUrgent: True
### Description
File path pattern of node components: `web/app/components/workflow/nodes/[nodeName]/node.tsx`
Node components are also used when creating a RAG Pipe from a template, but in that context there is no workflowStore Provider, which results in a blank screen. [This Issue](https://github.com/langgenius/dify/issues/29168) was caused by exactly this reason.
### Suggested Fix
Use `import { useNodes } from 'reactflow'` instead of `import useNodes from '@/app/components/workflow/store/workflow/use-nodes'`.

View File

@ -1,44 +1,66 @@
# Rule Catalog — Code Quality
# Code Quality Rules
## Conditional class names use utility function
## Scope Control
IsUrgent: True
Category: Code Quality
Flag changes that expand beyond the requested feature or review scope:
### Description
- Repo-wide cleanup mixed into a targeted fix.
- Compatibility exports, aliases, shims, or wrapper layers added without an explicit migration requirement.
- Shared abstractions created before there is stable cross-feature reuse.
- Business components moved into generic shared locations without a clear ownership boundary.
Ensure conditional CSS is handled via the shared `classNames` instead of custom ternaries, string concatenation, or template strings. Centralizing class logic keeps components consistent and easier to maintain.
## TypeScript
### Suggested Fix
Flag:
```ts
import { cn } from '@/utils/classnames'
const classNames = cn(isActive ? 'text-primary-600' : 'text-gray-500')
```
- `any` or broad `Record<string, any>` where generated/API types or local domain types exist.
- Re-declared API shapes instead of importing generated or returned types.
- Weak route/query param typing that leaks `string | string[] | undefined` deep into components.
- Runtime wrappers added only to satisfy TypeScript when a narrower type boundary would preserve the existing runtime shape.
## Tailwind-first styling
Prefer:
IsUrgent: True
Category: Code Quality
- Explicit domain names that match the API contract.
- Type narrowing at route/API boundaries.
- Small conversion helpers colocated with the component that needs them.
### Description
## Styling
Favor Tailwind CSS utility classes instead of adding new `.module.css` files unless a Tailwind combination cannot achieve the required styling. Keeping styles in Tailwind improves consistency and reduces maintenance overhead.
Flag:
Update this file when adding, editing, or removing Code Quality rules so the catalog remains accurate.
- New CSS modules or ad hoc CSS when Tailwind utilities and Dify tokens cover the need.
- Generic color utilities where Dify semantic tokens exist.
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or documented radius mappings exist.
- `!` important modifiers or important CSS overrides without a narrow, documented reason.
- Manual string concatenation for conditional classes.
- JS conditional class branches for primitive visual states already exposed by Dify UI/Base UI `data-*` selectors.
- Incoming `className` placed before default classes in `cn(...)`, preventing call-site overrides.
- Arbitrary z-index or one-off layering fixes on overlays.
## Classname ordering for easy overrides
Use:
### Description
- `cn(...)` from the local package or utility already used by the file.
- Dify semantic tokens and Tailwind v4 utilities.
- Existing component variants before one-off class forks.
- Primitive selectors such as `data-disabled:*`, `data-checked:*`, `data-highlighted:*`, `group-data-*`, `peer-data-*`, and `has-[:focus-visible]` before adding React state or boolean props solely for styling.
- Component-level variants, semantic tokens, and normal cascade/order before `!` overrides. Use `!` only for a contained compatibility override that cannot be expressed through the component API or local selector structure.
When writing components, always place the incoming `className` prop after the components own class values so that downstream consumers can override or extend the styling. This keeps your components defaults but still lets external callers change or remove specific styles.
## Imports
Example:
Flag:
```tsx
import { cn } from '@/utils/classnames'
- Barrel imports from `@langgenius/dify-ui`; consumers must use subpath exports.
- New overlay imports from legacy `@/app/components/base/modal`, `dialog`, or `drawer`.
- Cross-feature imports that bypass explicit top-level public files.
- Direct imports from generated/internal implementation files when a feature contract already exposes the intended surface.
const Button = ({ className }) => {
return <div className={cn('bg-primary-600', className)}></div>
}
```
## Copy And i18n
Flag:
- User-facing hardcoded strings in `web/`.
- Translation namespace drift, especially using unrelated module namespaces for local feature copy.
- Generic button labels like `Continue` where the action is specific.
- Error messages that state only the failure and not the next step.
Use feature-local translation keys by default. Alias only when crossing namespaces.

View File

@ -0,0 +1,85 @@
# Component Architecture Rules
Use these rules for React component structure, ownership, state, props, effects, and module organization.
## Ownership
Flag:
- State, query, mutation, or handlers hoisted above the lowest component that actually uses them.
- Parent components owning row/item actions that do not coordinate a workflow.
- Prop drilling through multiple pass-through layers.
- A page/tab-level section component becoming the data owner without needing a shared snapshot or shared loading/error/empty UI.
- Feature code promoted to shared only because it appears once or might be reused later.
Accept repeated TanStack Query calls in siblings when each component independently consumes the data. Cache deduplication is not a reason to hoist by itself.
## Component Boundaries
Flag:
- Shallow wrappers that only rename props or hide the real primitive.
- Extra DOM wrappers that do not provide layout, semantics, accessibility, state ownership, or library integration.
- Dialog/dropdown/popover hidden surfaces that obscure the parent flow when they should be extracted into a small local component.
- Business forms, menu bodies, or one-off helpers moved away from their owner without reuse or semantic value.
Prefer colocated components split by actual data and state needs.
## Bad Component Design Patterns
Flag:
- Components that mix data fetching, mutation side effects, popup state, form validation, layout, and row rendering without a clear owner.
- Generic components with many boolean props that encode one feature's workflow.
- A shared component that imports feature-specific copy, routes, or API contracts.
- A feature component that accepts pre-rendered fragments only to avoid placing ownership correctly.
- A child component that receives both raw server data and separately derived flags for the same concept.
- A wrapper that changes accessible semantics of the primitive it wraps.
- A component that exposes controlled props but still keeps a competing private state for the same value.
- A component that cannot render empty, loading, or missing optional API fields without caller-side preprocessing.
## Props And Types
Flag:
- `React.FC` / `FC`.
- Default exports outside framework-required files.
- Named `Props` types for trivial one-off props where inline typing is clearer.
- Props named by UI implementation instead of domain/API role.
- API data converted too early or under a generic name that breaks traceability.
- Callers duplicating fallback checks that the lowest rendering component already handles.
Prefer top-level `function` declarations for components and module helpers. Use arrow functions for callbacks and local lambdas.
## Effects
Flag effects that:
- Transform props/state for rendering.
- Copy one state value into another representing the same concept.
- Handle user actions that belong in event handlers.
- Reset state from props when a keyed reset, stable ID, or render-time derivation would work.
- Fetch data that belongs in framework APIs or TanStack Query.
If an effect remains, it must synchronize with a named external system: browser API, subscription, timer, analytics-on-visibility, non-React widget, or imperative DOM integration.
## State Modeling
Flag:
- Storing derived booleans, disabled flags, default tabs, or loading labels that can be calculated from current query/feature state.
- Local state used to fake server data or generated contract fields.
- UI state persisted to localStorage when it is live app state.
- Feature-local mock shells wired to unrelated existing APIs before the real API is confirmed.
Prefer render-time derivation. Keep true local state for user choices, transient input, controlled popups, and feature UI state that has no server source.
## Navigation
Flag:
- Imperative router navigation for ordinary links.
- Button semantics used for navigation.
- Navigation state hidden in component state when URL state is required for shareable filters, tabs, or pagination.
Use `Link` for normal navigation. Use router APIs for mutation success, guarded redirects, command flows, or form submission side effects.

View File

@ -0,0 +1,74 @@
# Data, Query, And Contract Rules
Use these rules for generated contracts, TanStack Query, mutations, auth/SSR boundaries, URL state, and client persistence.
## Generated Contracts
Flag:
- New legacy service/helper wrappers around generated `queryOptions()` or `mutationOptions()`.
- Continuing to use deprecated contract operations when a ready generated contract exists.
- Assuming a generated file means an operation is ready without checking deprecated markers, schema shape, and the actual UI consumer.
- Re-declaring API DTOs in components.
- Adding compatibility layers instead of migrating the pointed line and deleting the old layer.
Use `web/contract/*` as the API shape source of truth. Follow existing `{ params, query?, body? }` input shape.
## Queries
Flag:
- `enabled` used to hide missing required input instead of `input: skipToken`.
- Fake fallback IDs or placeholder inputs used to force a query to run.
- Query results copied into local state for rendering.
- Shared query behavior such as invalidation, stale defaults, or retry rules reimplemented at call sites.
- `prefetchQuery` treated as a hard gate or as returning data/errors to the caller.
Use `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))` directly unless a feature hook performs real orchestration.
## Mutations
Flag:
- Deprecated `useInvalid` or `useReset`.
- `mutateAsync` used without a need for Promise semantics.
- Awaited mutations without `try/catch`.
- Components owning shared cache invalidation that belongs in query defaults.
- Optimistic updates that do not match current list/detail ownership.
Use generated `mutationOptions()` directly when possible. Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`.
## SSR, Auth, And Route Boundaries
Flag:
- Request-time auth, setup, workspace role, or tenant decisions moved into static `next.config redirects()`.
- Dynamic role gates depending on `workspaces.current` implemented as static path redirects.
- Authorization logic depending on soft `prefetchQuery`.
- Removing a client fallback before server API unavailable behavior is defined.
- Global placeholder query contracts introduced to solve a route-local Suspense issue.
- Branding-sensitive UI reading placeholder defaults without checking pending/placeholder state.
Separate hard gates from soft prefetches. `fetchQuery` can be a server decision boundary; `prefetchQuery` is cache warmup.
## Workspace And Tenant
Flag:
- Treating workspace switch as ordinary CRUD invalidation when the current app flow performs server switch plus full reload.
- Query keys that omit workspace/tenant identity when the query truly varies by workspace and no full reload boundary applies.
- Mixing `workspace_id` and `tenant_id` without tracing the current backend/API contract.
Current Dify workspace switch should be reviewed as a tenant cache boundary first.
## URL State And Local Storage
Flag:
- Shareable filters, tabs, pagination, selected panels, or search state hidden only in component state.
- One-shot navigation signals modeled as subscribed persistent state.
- Live app state stored in localStorage.
- Direct `window.localStorage`, `globalThis.localStorage`, or raw storage calls in app code.
- High-frequency interaction state persisted on every change instead of on commit/settle.
Use URL state for shareable UI state, feature/Jotai/store state for live UI state, and `@/hooks/use-local-storage` only for low-frequency client-only preferences, dismissed notices, and UI defaults.

View File

@ -0,0 +1,22 @@
# Dify Invariants
Use these stable Dify-specific runtime rules in addition to the generic review packs.
This file is not a place for active feature notes. Do not add rules for one branch, one PR, or a short-lived product decision such as a specific agent-v2, plugin, model-provider, or onboarding task. Keep a rule here only when all of these are true:
- It is a stable Dify runtime invariant.
- Generic React, TypeScript, accessibility, dify-ui, query, or performance rules would not catch it.
- The failure mode is concrete enough to produce a file-line review finding.
- The rule is likely to remain valid across normal feature work.
## Workflow Nodes And RAG Pipe
Flag:
- Node components under `web/app/components/workflow/nodes/[nodeName]/node.tsx` importing workflow store hooks that are unavailable in RAG Pipe template rendering.
- Node UI relying on provider context that is not mounted in every rendering surface.
- Store reads in render where React Flow `useNodes` / `useEdges` provide the actual node/edge source.
Known failure mode: workflow node components can also render while creating a RAG Pipe from a template. In that context there may be no workflowStore provider, causing a blank screen.
Prefer React Flow hooks for node/edge UI consumption. Use store APIs only where the provider is guaranteed and the code path is workflow-only.

View File

@ -0,0 +1,123 @@
# Dify UI Rules
Use these rules whenever a review touches `packages/dify-ui/` or code consuming `@langgenius/dify-ui/*`.
Before finalizing findings for those files, read the current local docs that apply:
- `packages/dify-ui/README.md`
- `packages/dify-ui/AGENTS.md`
- `web/docs/overlay.md` for floating UI
- `packages/dify-ui/src/<primitive>/index.tsx` for the primitive being changed or consumed
## Package Boundary
Flag in `packages/dify-ui`:
- Imports from `web/`.
- Dependencies on Next.js, i18n, ky, Jotai, Zustand, TanStack Query, oRPC, or business APIs.
- Business-specific component behavior that belongs in `web/`.
- Multiple unrelated primitives in one component folder.
`packages/dify-ui` is a primitive layer: Base UI headless components + `cva` + `cn` + Dify design tokens.
## Imports And Exports
Flag:
- Consumer imports from `@langgenius/dify-ui` without a subpath.
- Missing `package.json#exports` entry for a new primitive.
- Internal package imports using workspace subpaths instead of relative paths.
- Exported props using internal-only types that consumers cannot import from the component subpath.
Consumers use subpath exports such as `@langgenius/dify-ui/button`.
## Props And State
Flag:
- Flattened props where related values need a discriminated union, such as `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`.
- React state used only to mirror Base UI state for class names.
- JavaScript conditional class logic for visual states that the Dify UI/Base UI primitive already exposes through `data-*` attributes or CSS variables.
- Controlled props added when uncontrolled DOM state or CSS variables would be enough.
- Thin wrappers that rename Base UI parts without adding semantics.
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
## Forms
Flag:
- Form-like UI using unrelated `Input` and `Button` pieces without a submit boundary.
- Text-like fields not composed through `FieldRoot`, `FieldLabel`, and `FieldControl` when using Dify UI form semantics.
- Select fields using `FieldLabel` instead of `SelectLabel`.
- Slider fields using a generic label instead of `SliderLabel`.
- Checkbox/radio groups missing `FieldsetRoot` and `FieldsetLegend`.
- Field errors or descriptions rendered without `FieldDescription` / `FieldError` relationships.
`Form` is the submit boundary. Dify UI form primitives are not a form state-management framework; business validation and schema-driven behavior belong in `web/`.
## Overlay Contract
Flag:
- Legacy web overlay imports in new or modified code.
- Manual portals around Dify UI overlay primitives.
- Call-site `z-*` overrides on overlays.
- Missing root `isolation: isolate` assumptions when debugging overlay stacking.
- Repeated backdrop, z-index, or portal chrome at call sites.
- Tooltip used for infotips, long text, or interactive content.
All Dify UI body-portalled overlays use `z-50`. Toast uses `z-60`. DOM order handles stacking between overlays.
## Primitive Selection
Flag:
- `Tabs` used for simple mode/filter/view selection where `SegmentedControl` is the semantic primitive.
- `SegmentedControl` used where `tablist` / `tabpanel` semantics are required.
- `Select` used for searchable or free-form input.
- `Combobox` used for unrestricted search text where no selected option is remembered.
- `Autocomplete` used for closed-list selection.
- Tooltip or PreviewCard used for content that must be reachable on touch or by screen readers.
Use:
- `Autocomplete` for free-form text with optional suggestions.
- `Combobox` for searchable selected values from a collection.
- `Select` for closed, scannable option sets.
- `Popover` for infotips, help text, rich content, or interactions.
## Bad Usage Patterns To Flag
Flag:
- Styling a raw Base UI primitive directly in `web/` when a Dify UI primitive exists.
- Wrapping a Dify UI primitive in a feature component that hides its label, error, disabled, or focus contract.
- Replacing a semantic primitive with a generic `div` plus classes to match a screenshot.
- Using `Tooltip` because it is visually convenient when the content is actually help text or needs touch access.
- Adding a `z-*` override to make a child popup appear over a parent dialog.
- Adding a new app-level wrapper around Dialog, Drawer, Popover, Select, or Combobox that repeats portal/backdrop/positioner logic.
- Using dify-ui `Input` as a drop-in replacement for legacy inputs that include search, clear, copy, unit, localized placeholder, or number normalization behavior.
- Building a form row from loose text and controls instead of the matching Field/Form primitives.
- Adding component state only to style `data-open`, `data-checked`, `data-disabled`, or highlighted states that Base UI already exposes.
- Passing booleans down only so children can toggle classes already expressible with primitive `data-*` selectors.
## Tokens, Radius, And Styling
Flag:
- `radius-*` class names.
- Custom Tailwind `borderRadius` extension for Figma radius values.
- Generic colors where semantic Dify tokens exist.
- Hardcoded design values where Dify tokens, component variants, or documented Figma radius mappings exist.
- `!` important modifiers used to fight primitive styles instead of fixing the variant, selector, or component composition.
- Manual class strings that duplicate primitive variants.
- `min-w-(--anchor-width)` on picker popups when it defeats viewport clamping.
Use the Figma radius mapping from `packages/dify-ui/AGENTS.md`; for example `--radius/sm` maps to `rounded-md`, and `--radius/md` maps to `rounded-lg`.
Use `!` only for a tightly scoped compatibility override after confirming the primitive API, data attributes, and selector structure cannot express the state.
## Focus Details
Flag focus rings attached to the wrong element. For example, Base UI `Slider.Thumb` focuses an internal `input[type=range]`, so the visible thumb wrapper needs `has-[:focus-visible]` rather than direct wrapper `focus-visible`.

View File

@ -1,45 +1,78 @@
# Rule Catalog — Performance
# Performance Rules
## React Flow data usage
Review performance only where there is realistic impact. Do not request `memo`, `useMemo`, `useCallback`, virtualization, or caching as style preferences.
IsUrgent: True
Category: Performance
## Async Waterfalls
### Description
Flag:
When rendering React Flow, prefer `useNodes`/`useEdges` for UI consumption and rely on `useStoreApi` inside callbacks that mutate or read node/edge state. Avoid manually pulling Flow data outside of these hooks.
- Awaiting remote feature flags or fetches before checking cheap synchronous conditions.
- Sequential awaits for independent operations.
- API routes or server components starting requests late when they could start early.
- Nested per-item fetches running serially when each item can fetch in parallel.
- Suspense boundaries that force the whole page to wait when a lower boundary could stream or isolate loading.
## Complex prop stability
Prefer `Promise.all` for independent work and branch-local awaits for conditionally needed data.
IsUrgent: False
Category: Performance
## Bundle Size
### Description
Flag:
Only require stable object, array, or map props when there is a clear reason: the child is memoized, the value participates in effect/query dependencies, the value is part of a stable-reference API contract, or profiling/local behavior shows avoidable re-renders. Do not request `useMemo` for every inline object by default; `how-to-write-component` treats memoization as a targeted optimization.
- Barrel imports from heavy libraries or `@langgenius/dify-ui`.
- Dynamic paths that prevent static trace analysis.
- Heavy components loaded eagerly when hidden behind a dialog, tab, command, or feature activation.
- Analytics, logging, editor, visualization, or third-party SDK code loaded before it is needed.
- Feature-local optional modules imported at top level only for rare flows.
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
Use direct imports and `next/dynamic` where the user-visible path benefits.
Risky:
## Server Rendering
```tsx
<HeavyComp
config={{
provider: ...,
detail: ...
}}
/>
```
Flag:
Better when stable identity matters:
- Request-specific mutable state stored at module scope in SSR/RSC paths.
- Large duplicate data serialized across RSC/client boundaries.
- Static I/O repeated per request when it could be hoisted safely.
- Cross-request cache without a bounded invalidation strategy.
- Server actions lacking API-route-equivalent auth checks.
```tsx
const config = useMemo(() => ({
provider: ...,
detail: ...
}), [provider, detail]);
Use request-scoped deduplication such as `React.cache()` when repeated server reads in one request are the problem.
<HeavyComp
config={config}
/>
```
## Re-rendering
Flag:
- Effects or subscriptions reading broad state when a derived boolean or narrower selector is enough.
- Components defined inside components.
- Derived rendering state stored in state/effects.
- Non-primitive default props recreated for memoized children.
- Expensive work recalculated on every render where it affects real interaction cost.
- High-frequency transient values stored in state when refs or CSS variables would avoid render loops.
Do not flag simple primitive expressions wrapped or not wrapped in `useMemo`; prefer no memo for simple work.
Require stable object/array/function identity only when:
- The child is memoized and identity affects renders.
- The value is an effect/query dependency.
- A library API requires stable references.
- Profiling or local behavior shows avoidable re-rendering.
## DOM, Lists, And Rendering
Flag:
- Layout reads in render (`getBoundingClientRect`, `offset*`, `scrollTop`).
- Interleaved DOM reads/writes that can cause layout thrashing.
- Large lists rendering without virtualization, pagination, or `content-visibility`.
- SVG/animation code animating expensive properties when transform/opacity would work.
- `transition-all`.
- Long-running non-critical browser work performed immediately instead of idle/deferred scheduling.
## React Flow
For workflow React Flow components, keep this Dify-specific rule:
- UI consumption should use React Flow hooks such as `useNodes` / `useEdges`.
- Callback-only reads or mutations can use `useStoreApi`.
- Node components under `web/app/components/workflow/nodes/[nodeName]/node.tsx` must not depend on workflow stores that are absent in RAG Pipe template rendering.

View File

@ -0,0 +1,72 @@
# Testing Review Rules
Use these rules when reviewing test files, testability of changed code, or risky frontend changes that should have tests.
## Missing Coverage
Flag missing tests when the change affects:
- User-visible behavior, navigation, form submission, validation, permissions, or loading/error/empty states.
- Query/mutation cache behavior.
- Accessibility-critical behavior such as labels, keyboard flow, focus, disabled state, or popup reachability.
- URL state parsing/serialization.
- Storage persistence or one-shot signals.
- Regression-prone workflow or generated contract migration paths.
Do not request tests for purely mechanical renames or styling-only changes unless the styling affects layout, focus, or interaction.
## Selectors
Flag:
- `getByTestId` used where role, label, text, placeholder, landmark, or scoped dialog/menu queries are available.
- Production `data-testid` added only to satisfy tests.
- Assertions against decorative icons rather than the named control.
- Tests that cannot find controls semantically but leave broken markup unchanged.
Prefer `getByRole` with accessible name, then `getByLabelText`, `getByPlaceholderText`, `getByText`, and `within(...)`.
## Mocking
Flag:
- Mocking `@langgenius/dify-ui/*` primitives.
- Mocking `@/app/components/base/*` components when the real component is practical.
- Mocking sibling or child components in the same directory for integration behavior.
- Mocks that do not match the real component's conditional rendering.
- Module-level mock state not reset in `beforeEach`.
- `vi.clearAllMocks()` in `afterEach` instead of `beforeEach`.
Use real project components for integration behavior. Mock APIs, `next/navigation`, browser shims, or complex providers only when setup would dominate the test.
## Behavior
Flag:
- Tests inspecting implementation details instead of user-observable behavior.
- Assertions that hardcode brittle copy when pattern matching or semantic roles would express behavior better.
- Fake timers used without real timing behavior.
- Async assertions missing `await`, `findBy*`, or `waitFor`.
- Test data missing required fields because inline partial objects bypass real types.
Use typed factory functions with complete defaults and partial overrides.
## URL State
For `nuqs` or query-state hooks, flag tests that:
- Mock URL state when URL synchronization is the behavior under review.
- Do not test parser serialize/parse round trips for custom parsers.
- Do not assert default-clearing behavior when defaults should be removed from the URL.
Prefer shared `NuqsTestingAdapter` helpers when available.
## Organization
Flag:
- Component/hook/util tests outside sibling `__tests__/` directories.
- Directory-level reviews that test only `index.tsx` while other files in scope contain behavior.
- Large test files with repeated setup that should use local builders.
When a component is very complex, prefer a refactor finding before asking for exhaustive tests.

View File

@ -97,6 +97,7 @@ RUN \
# Copy Python environment and packages
ENV VIRTUAL_ENV=/app/api/.venv
COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --from=packages --chown=dify:dify /app/dify-agent /app/dify-agent
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
# Download nltk data

View File

@ -949,6 +949,11 @@ class AuthConfig(BaseSettings):
default=60,
)
DEVICE_FLOW_APPROVE_RATE_LIMIT_PER_HOUR: PositiveInt = Field(
description="Max device-flow approve requests per session per hour on /openapi/oauth/device/approve.",
default=10,
)
class ModerationConfig(BaseSettings):
"""

View File

@ -122,6 +122,7 @@ from .explore import (
saved_message,
trial,
)
from .snippets import snippet_workflow, snippet_workflow_draft_variable
from .socketio import workflow as socketio_workflow
# Import tag controllers
@ -137,6 +138,7 @@ from .workspace import (
model_providers,
models,
plugin,
snippets,
tool_providers,
trigger_providers,
workspace,
@ -212,6 +214,9 @@ __all__ = [
"saved_message",
"setup",
"site",
"snippet_workflow",
"snippet_workflow_draft_variable",
"snippets",
"socketio_workflow",
"spec",
"statistic",

View File

@ -0,0 +1,164 @@
import uuid
from typing import Any, Literal
from pydantic import AliasChoices, BaseModel, Field, field_validator
class SnippetListQuery(BaseModel):
"""Query parameters for listing snippets."""
page: int = Field(default=1, ge=1, le=99999)
limit: int = Field(default=20, ge=1, le=100)
keyword: str | None = None
is_published: bool | None = Field(default=None, description="Filter by published status")
creators: list[str] | None = Field(
default=None,
description="Filter by creator account IDs",
validation_alias=AliasChoices("creators", "creator_id"),
)
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
@field_validator("creators", mode="before")
@classmethod
def parse_creators(cls, value: object) -> list[str] | None:
"""Normalize creators filter from query string or list input."""
return cls._normalize_string_list(value)
@field_validator("tag_ids", mode="before")
@classmethod
def parse_tag_ids(cls, value: object) -> list[str] | None:
"""Normalize and validate tag IDs from query string or list input."""
items = cls._normalize_string_list(value)
if not items:
return None
try:
return [str(uuid.UUID(item)) for item in items]
except ValueError as exc:
raise ValueError("Invalid UUID format in tag_ids.") from exc
@staticmethod
def _normalize_string_list(value: object) -> list[str] | None:
if value is None:
return None
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()] or None
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()] or None
return None
class IconInfo(BaseModel):
"""Icon information model."""
icon: str | None = None
icon_type: Literal["emoji", "image"] | None = None
icon_background: str | None = None
icon_url: str | None = None
class InputFieldDefinition(BaseModel):
"""Input field definition for snippet parameters."""
default: str | None = None
hint: bool | None = None
label: str | None = None
max_length: int | None = None
options: list[str] | None = None
placeholder: str | None = None
required: bool | None = None
type: str | None = None # e.g., "text-input"
class CreateSnippetPayload(BaseModel):
"""Payload for creating a new snippet."""
name: str = Field(..., min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=2000)
type: Literal["node", "group"] = "node"
icon_info: IconInfo | None = None
graph: dict[str, Any] | None = None
input_fields: list[InputFieldDefinition] | None = Field(default_factory=list)
class UpdateSnippetPayload(BaseModel):
"""Payload for updating a snippet."""
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=2000)
icon_info: IconInfo | None = None
class SnippetDraftSyncPayload(BaseModel):
"""Payload for syncing snippet draft workflow."""
graph: dict[str, Any]
hash: str | None = None
conversation_variables: list[dict[str, Any]] | None = Field(
default=None,
description="Ignored. Snippet workflows do not persist conversation variables.",
)
input_fields: list[dict[str, Any]] | None = None
class SnippetWorkflowListQuery(BaseModel):
"""Query parameters for listing snippet published workflows."""
page: int = Field(default=1, ge=1, le=99999)
limit: int = Field(default=10, ge=1, le=100)
class WorkflowRunQuery(BaseModel):
"""Query parameters for workflow runs."""
last_id: str | None = None
limit: int = Field(default=20, ge=1, le=100)
class SnippetDraftRunPayload(BaseModel):
"""Payload for running snippet draft workflow."""
inputs: dict[str, Any]
files: list[dict[str, Any]] | None = None
class SnippetDraftNodeRunPayload(BaseModel):
"""Payload for running a single node in snippet draft workflow."""
inputs: dict[str, Any]
query: str = ""
files: list[dict[str, Any]] | None = None
class SnippetIterationNodeRunPayload(BaseModel):
"""Payload for running an iteration node in snippet draft workflow."""
inputs: dict[str, Any] | None = None
class SnippetLoopNodeRunPayload(BaseModel):
"""Payload for running a loop node in snippet draft workflow."""
inputs: dict[str, Any] | None = None
class PublishWorkflowPayload(BaseModel):
"""Payload for publishing snippet workflow."""
knowledge_base_setting: dict[str, Any] | None = None
class SnippetImportPayload(BaseModel):
"""Payload for importing snippet from DSL."""
mode: str = Field(..., description="Import mode: yaml-content or yaml-url")
yaml_content: str | None = Field(default=None, description="YAML content (required for yaml-content mode)")
yaml_url: str | None = Field(default=None, description="YAML URL (required for yaml-url mode)")
name: str | None = Field(default=None, description="Override snippet name")
description: str | None = Field(default=None, description="Override snippet description")
snippet_id: str | None = Field(default=None, description="Snippet ID to update (optional)")
class IncludeSecretQuery(BaseModel):
"""Query parameter for including secret variables in export."""
include_secret: str = Field(default="false", description="Whether to include secret variables")

View File

@ -0,0 +1,638 @@
import logging
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
from flask import request
from flask_restx import Resource
from pydantic import Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import DraftWorkflowNotExist, DraftWorkflowNotSync
from controllers.console.app.workflow import (
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE,
WorkflowPaginationResponse,
WorkflowResponse,
)
from controllers.console.snippets.payloads import (
PublishWorkflowPayload,
SnippetDraftNodeRunPayload,
SnippetDraftRunPayload,
SnippetDraftSyncPayload,
SnippetIterationNodeRunPayload,
SnippetLoopNodeRunPayload,
SnippetWorkflowListQuery,
WorkflowRunQuery,
)
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
)
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.workflow_run_fields import (
WorkflowRunDetailResponse,
WorkflowRunNodeExecutionListResponse,
WorkflowRunNodeExecutionResponse,
WorkflowRunPaginationResponse,
)
from graphon.graph_engine.manager import GraphEngineManager
from libs import helper
from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required
from models.snippet import CustomizedSnippet
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.snippet_generate_service import SnippetGenerateService
from services.snippet_service import SnippetService
logger = logging.getLogger(__name__)
P = ParamSpec("P")
R = TypeVar("R")
# Register Pydantic models with Swagger
class SnippetWorkflowResponse(WorkflowResponse):
input_fields: list[dict] = Field(default_factory=list)
register_schema_models(
console_ns,
SnippetDraftSyncPayload,
SnippetDraftNodeRunPayload,
SnippetDraftRunPayload,
SnippetIterationNodeRunPayload,
SnippetLoopNodeRunPayload,
SnippetWorkflowListQuery,
WorkflowRunQuery,
PublishWorkflowPayload,
)
register_response_schema_models(
console_ns,
SnippetWorkflowResponse,
WorkflowPaginationResponse,
WorkflowRunPaginationResponse,
WorkflowRunDetailResponse,
WorkflowRunNodeExecutionListResponse,
WorkflowRunNodeExecutionResponse,
)
class SnippetNotFoundError(Exception):
"""Snippet not found error."""
pass
def get_snippet(view_func: Callable[P, R]):
"""Decorator to fetch and validate snippet access."""
@wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs):
if not kwargs.get("snippet_id"):
raise ValueError("missing snippet_id in path parameters")
_, current_tenant_id = current_account_with_tenant()
snippet_id = str(kwargs.get("snippet_id"))
del kwargs["snippet_id"]
snippet = SnippetService.get_snippet_by_id(
snippet_id=snippet_id,
tenant_id=current_tenant_id,
)
if not snippet:
raise NotFound("Snippet not found")
kwargs["snippet"] = snippet
return view_func(*args, **kwargs)
return decorated_view
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft")
class SnippetDraftWorkflowApi(Resource):
@console_ns.doc("get_snippet_draft_workflow")
@console_ns.response(200, "Draft workflow retrieved successfully", console_ns.models[SnippetWorkflowResponse.__name__])
@console_ns.response(404, "Snippet or draft workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def get(self, snippet: CustomizedSnippet):
"""Get draft workflow for snippet."""
snippet_service = SnippetService()
workflow = snippet_service.get_draft_workflow(snippet=snippet)
if not workflow:
raise DraftWorkflowNotExist()
db.session.expunge(workflow)
workflow.conversation_variables = []
workflow.input_fields = snippet.input_fields_list
return SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
@console_ns.doc("sync_snippet_draft_workflow")
@console_ns.expect(console_ns.models.get(SnippetDraftSyncPayload.__name__))
@console_ns.response(200, "Draft workflow synced successfully")
@console_ns.response(400, "Hash mismatch")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def post(self, snippet: CustomizedSnippet):
"""Sync draft workflow for snippet."""
current_user, _ = current_account_with_tenant()
payload = SnippetDraftSyncPayload.model_validate(console_ns.payload or {})
try:
snippet_service = SnippetService()
workflow = snippet_service.sync_draft_workflow(
snippet=snippet,
graph=payload.graph,
unique_hash=payload.hash,
account=current_user,
input_fields=payload.input_fields,
)
except WorkflowHashNotEqualError:
raise DraftWorkflowNotSync()
except ValueError as e:
return {"message": str(e)}, 400
return {
"result": "success",
"hash": workflow.unique_hash,
"updated_at": TimestampField().format(workflow.updated_at or workflow.created_at),
}
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/config")
class SnippetDraftConfigApi(Resource):
@console_ns.doc("get_snippet_draft_config")
@console_ns.response(200, "Draft config retrieved successfully")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def get(self, snippet: CustomizedSnippet):
"""Get snippet draft workflow configuration limits."""
return {
"parallel_depth_limit": 3,
}
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/publish")
class SnippetPublishedWorkflowApi(Resource):
@console_ns.doc("get_snippet_published_workflow")
@console_ns.response(200, "Published workflow retrieved successfully", console_ns.models[SnippetWorkflowResponse.__name__])
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def get(self, snippet: CustomizedSnippet):
"""Get published workflow for snippet."""
if not snippet.is_published:
return None
snippet_service = SnippetService()
workflow = snippet_service.get_published_workflow(snippet=snippet)
if not workflow:
return None
workflow.input_fields = snippet.input_fields_list
return SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
@console_ns.doc("publish_snippet_workflow")
@console_ns.expect(console_ns.models.get(PublishWorkflowPayload.__name__))
@console_ns.response(200, "Workflow published successfully")
@console_ns.response(400, "No draft workflow found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def post(self, snippet: CustomizedSnippet):
"""Publish snippet workflow."""
current_user, _ = current_account_with_tenant()
snippet_service = SnippetService()
with Session(db.engine) as session:
snippet = session.merge(snippet)
try:
workflow = snippet_service.publish_workflow(
session=session,
snippet=snippet,
account=current_user,
)
workflow_created_at = TimestampField().format(workflow.created_at)
session.commit()
except ValueError as e:
return {"message": str(e)}, 400
return {
"result": "success",
"created_at": workflow_created_at,
}
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/default-workflow-block-configs")
class SnippetDefaultBlockConfigsApi(Resource):
@console_ns.doc("get_snippet_default_block_configs")
@console_ns.response(200, "Default block configs retrieved successfully")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def get(self, snippet: CustomizedSnippet):
"""Get default block configurations for snippet workflow."""
snippet_service = SnippetService()
return snippet_service.get_default_block_configs()
@console_ns.route("/snippets/<uuid:snippet_id>/workflows")
class SnippetPublishedAllWorkflowApi(Resource):
@console_ns.expect(console_ns.models[SnippetWorkflowListQuery.__name__])
@console_ns.doc("get_all_snippet_published_workflows")
@console_ns.doc(description="Get all published workflows for a snippet")
@console_ns.doc(params={"snippet_id": "Snippet ID"})
@console_ns.response(200, "Published workflows retrieved successfully", console_ns.models[WorkflowPaginationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def get(self, snippet: CustomizedSnippet):
"""Get all published workflow versions for snippet."""
args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True))
snippet_service = SnippetService()
with Session(db.engine) as session:
workflows, has_more = snippet_service.get_all_published_workflows(
session=session,
snippet=snippet,
page=args.page,
limit=args.limit,
)
return WorkflowPaginationResponse.model_validate(
{
"items": workflows,
"page": args.page,
"limit": args.limit,
"has_more": has_more,
},
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/<string:workflow_id>/restore")
class SnippetDraftWorkflowRestoreApi(Resource):
@console_ns.doc("restore_snippet_workflow_to_draft")
@console_ns.doc(description="Restore a published snippet workflow version into the draft workflow")
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Published workflow ID"})
@console_ns.response(200, "Workflow restored successfully")
@console_ns.response(400, "Source workflow must be published")
@console_ns.response(404, "Workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def post(self, snippet: CustomizedSnippet, workflow_id: str):
"""Restore a published snippet workflow version into the draft workflow."""
current_user, _ = current_account_with_tenant()
snippet_service = SnippetService()
try:
workflow = snippet_service.restore_published_workflow_to_draft(
snippet=snippet,
workflow_id=workflow_id,
account=current_user,
)
except IsDraftWorkflowError as exc:
raise BadRequest(RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE) from exc
except WorkflowNotFoundError as exc:
raise NotFound(str(exc)) from exc
except ValueError as exc:
raise BadRequest(str(exc)) from exc
return {
"result": "success",
"hash": workflow.unique_hash,
"updated_at": TimestampField().format(workflow.updated_at or workflow.created_at),
}
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
class SnippetWorkflowRunsApi(Resource):
@console_ns.doc("list_snippet_workflow_runs")
@console_ns.response(200, "Workflow runs retrieved successfully", console_ns.models[WorkflowRunPaginationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_snippet
def get(self, snippet: CustomizedSnippet):
"""List workflow runs for snippet."""
query = WorkflowRunQuery.model_validate(
{
"last_id": request.args.get("last_id"),
"limit": request.args.get("limit", type=int, default=20),
}
)
args = {
"last_id": query.last_id,
"limit": query.limit,
}
snippet_service = SnippetService()
result = snippet_service.get_snippet_workflow_runs(snippet=snippet, args=args)
return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json")
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs/<uuid:run_id>")
class SnippetWorkflowRunDetailApi(Resource):
@console_ns.doc("get_snippet_workflow_run_detail")
@console_ns.response(200, "Workflow run detail retrieved successfully", console_ns.models[WorkflowRunDetailResponse.__name__])
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
def get(self, snippet: CustomizedSnippet, run_id):
"""Get workflow run detail for snippet."""
run_id = str(run_id)
snippet_service = SnippetService()
workflow_run = snippet_service.get_snippet_workflow_run(snippet=snippet, run_id=run_id)
if not workflow_run:
raise NotFound("Workflow run not found")
return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json")
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs/<uuid:run_id>/node-executions")
class SnippetWorkflowRunNodeExecutionsApi(Resource):
@console_ns.doc("list_snippet_workflow_run_node_executions")
@console_ns.response(
200,
"Node executions retrieved successfully",
console_ns.models[WorkflowRunNodeExecutionListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@get_snippet
def get(self, snippet: CustomizedSnippet, run_id):
"""List node executions for a workflow run."""
run_id = str(run_id)
snippet_service = SnippetService()
node_executions = snippet_service.get_snippet_workflow_run_node_executions(
snippet=snippet,
run_id=run_id,
)
return WorkflowRunNodeExecutionListResponse.model_validate(
{"data": node_executions}, from_attributes=True
).model_dump(mode="json")
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/run")
class SnippetDraftNodeRunApi(Resource):
@console_ns.doc("run_snippet_draft_node")
@console_ns.doc(description="Run a single node in snippet draft workflow (single-step debugging)")
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models.get(SnippetDraftNodeRunPayload.__name__))
@console_ns.response(
200, "Node run completed successfully", console_ns.models[WorkflowRunNodeExecutionResponse.__name__]
)
@console_ns.response(404, "Snippet or draft workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def post(self, snippet: CustomizedSnippet, node_id: str):
"""
Run a single node in snippet draft workflow.
Executes a specific node with provided inputs for single-step debugging.
Returns the node execution result including status, outputs, and timing.
"""
current_user, _ = current_account_with_tenant()
payload = SnippetDraftNodeRunPayload.model_validate(console_ns.payload or {})
user_inputs = payload.inputs
# Get draft workflow for file parsing
snippet_service = SnippetService()
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
if not draft_workflow:
raise NotFound("Draft workflow not found")
files = SnippetGenerateService.parse_files(draft_workflow, payload.files)
workflow_node_execution = SnippetGenerateService.run_draft_node(
snippet=snippet,
node_id=node_id,
user_inputs=user_inputs,
account=current_user,
query=payload.query,
files=files,
)
return WorkflowRunNodeExecutionResponse.model_validate(
workflow_node_execution, from_attributes=True
).model_dump(mode="json")
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/last-run")
class SnippetDraftNodeLastRunApi(Resource):
@console_ns.doc("get_snippet_draft_node_last_run")
@console_ns.doc(description="Get last run result for a node in snippet draft workflow")
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
@console_ns.response(
200, "Node last run retrieved successfully", console_ns.models[WorkflowRunNodeExecutionResponse.__name__]
)
@console_ns.response(404, "Snippet, draft workflow, or node last run not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
def get(self, snippet: CustomizedSnippet, node_id: str):
"""
Get the last run result for a specific node in snippet draft workflow.
Returns the most recent execution record for the given node,
including status, inputs, outputs, and timing information.
"""
snippet_service = SnippetService()
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
if not draft_workflow:
raise NotFound("Draft workflow not found")
node_exec = snippet_service.get_snippet_node_last_run(
snippet=snippet,
workflow=draft_workflow,
node_id=node_id,
)
if node_exec is None:
raise NotFound("Node last run not found")
return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json")
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/iteration/nodes/<string:node_id>/run")
class SnippetDraftRunIterationNodeApi(Resource):
@console_ns.doc("run_snippet_draft_iteration_node")
@console_ns.doc(description="Run draft workflow iteration node for snippet")
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models.get(SnippetIterationNodeRunPayload.__name__))
@console_ns.response(200, "Iteration node run started successfully (SSE stream)")
@console_ns.response(404, "Snippet or draft workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def post(self, snippet: CustomizedSnippet, node_id: str):
"""
Run a draft workflow iteration node for snippet.
Iteration nodes execute their internal sub-graph multiple times over an input list.
Returns an SSE event stream with iteration progress and results.
"""
current_user, _ = current_account_with_tenant()
args = SnippetIterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
try:
response = SnippetGenerateService.generate_single_iteration(
snippet=snippet, user=current_user, node_id=node_id, args=args, streaming=True
)
return helper.compact_generate_response(response)
except ValueError as e:
raise e
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/loop/nodes/<string:node_id>/run")
class SnippetDraftRunLoopNodeApi(Resource):
@console_ns.doc("run_snippet_draft_loop_node")
@console_ns.doc(description="Run draft workflow loop node for snippet")
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models.get(SnippetLoopNodeRunPayload.__name__))
@console_ns.response(200, "Loop node run started successfully (SSE stream)")
@console_ns.response(404, "Snippet or draft workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def post(self, snippet: CustomizedSnippet, node_id: str):
"""
Run a draft workflow loop node for snippet.
Loop nodes execute their internal sub-graph repeatedly until a condition is met.
Returns an SSE event stream with loop progress and results.
"""
current_user, _ = current_account_with_tenant()
args = SnippetLoopNodeRunPayload.model_validate(console_ns.payload or {})
try:
response = SnippetGenerateService.generate_single_loop(
snippet=snippet, user=current_user, node_id=node_id, args=args, streaming=True
)
return helper.compact_generate_response(response)
except ValueError as e:
raise e
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/run")
class SnippetDraftWorkflowRunApi(Resource):
@console_ns.doc("run_snippet_draft_workflow")
@console_ns.expect(console_ns.models.get(SnippetDraftRunPayload.__name__))
@console_ns.response(200, "Draft workflow run started successfully (SSE stream)")
@console_ns.response(404, "Snippet or draft workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def post(self, snippet: CustomizedSnippet):
"""
Run draft workflow for snippet.
Executes the snippet's draft workflow with the provided inputs
and returns an SSE event stream with execution progress and results.
"""
current_user, _ = current_account_with_tenant()
payload = SnippetDraftRunPayload.model_validate(console_ns.payload or {})
args = payload.model_dump(exclude_none=True)
try:
response = SnippetGenerateService.generate(
snippet=snippet,
user=current_user,
args=args,
invoke_from=InvokeFrom.DEBUGGER,
streaming=True,
)
return helper.compact_generate_response(response)
except ValueError as e:
raise e
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs/tasks/<string:task_id>/stop")
class SnippetWorkflowTaskStopApi(Resource):
@console_ns.doc("stop_snippet_workflow_task")
@console_ns.response(200, "Task stopped successfully")
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
def post(self, snippet: CustomizedSnippet, task_id: str):
"""
Stop a running snippet workflow task.
Uses both the legacy stop flag mechanism and the graph engine
command channel for backward compatibility.
"""
# Stop using both mechanisms for backward compatibility
# Legacy stop flag mechanism (without user check)
AppQueueManager.set_stop_flag_no_user_check(task_id)
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
return {"result": "success"}

View File

@ -0,0 +1,319 @@
"""
Snippet draft workflow variable APIs.
Mirrors console app routes under /apps/.../workflows/draft/variables for snippet scope,
using CustomizedSnippet.id as WorkflowDraftVariable.app_id (same invariant as snippet execution).
Snippet workflows do not expose system variables (`node_id == sys`) or conversation variables
(`node_id == conversation`): paginated list queries exclude those rows; single-variable GET/PATCH/DELETE/reset
reject them; `GET .../system-variables` and `GET .../conversation-variables` return empty lists for API parity.
Other routes mirror `workflow_draft_variable` app APIs under `/snippets/...`.
"""
from collections.abc import Callable
from functools import wraps
from typing import Any, ParamSpec, TypeVar
from flask import Response, request
from flask_restx import Resource, marshal, marshal_with
from sqlalchemy.orm import Session
from controllers.console import console_ns
from controllers.console.app.error import DraftWorkflowNotExist
from controllers.console.app.workflow_draft_variable import (
WorkflowDraftVariableListQuery,
WorkflowDraftVariableUpdatePayload,
_ensure_variable_access,
_file_access_controller,
validate_node_id,
workflow_draft_variable_list_model,
workflow_draft_variable_list_without_value_model,
workflow_draft_variable_model,
)
from controllers.console.snippets.snippet_workflow import get_snippet
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from controllers.web.error import InvalidArgumentError, NotFoundError
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db
from factories.file_factory import build_from_mapping, build_from_mappings
from factories.variable_factory import build_segment_with_type
from graphon.variables.types import SegmentType
from libs.login import current_user, login_required
from models.snippet import CustomizedSnippet
from models.workflow import WorkflowDraftVariable
from services.snippet_service import SnippetService
from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService
P = ParamSpec("P")
R = TypeVar("R")
_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: frozenset[str] = frozenset(
{SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID}
)
def _ensure_snippet_draft_variable_row_allowed(
*,
variable: WorkflowDraftVariable,
variable_id: str,
) -> None:
"""Snippet scope only supports canvas-node draft variables; treat sys/conversation rows as not found."""
if variable.node_id in _SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS:
raise NotFoundError(description=f"variable not found, id={variable_id}")
def _snippet_draft_var_prerequisite(f: Callable[P, R]) -> Callable[P, R]:
"""Setup, auth, snippet resolution, and tenant edit permission (same stack as snippet workflow APIs)."""
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
@wraps(f)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return f(*args, **kwargs)
return wrapper
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/variables")
class SnippetWorkflowVariableCollectionApi(Resource):
@console_ns.expect(console_ns.models[WorkflowDraftVariableListQuery.__name__])
@console_ns.doc("get_snippet_workflow_variables")
@console_ns.doc(description="List draft workflow variables without values (paginated, snippet scope)")
@console_ns.response(
200,
"Workflow variables retrieved successfully",
workflow_draft_variable_list_without_value_model,
)
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_list_without_value_model)
def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
snippet_service = SnippetService()
if snippet_service.get_draft_workflow(snippet=snippet) is None:
raise DraftWorkflowNotExist()
with Session(bind=db.engine, expire_on_commit=False) as session:
draft_var_srv = WorkflowDraftVariableService(session=session)
workflow_vars = draft_var_srv.list_variables_without_values(
app_id=snippet.id,
page=args.page,
limit=args.limit,
user_id=current_user.id,
exclude_node_ids=_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS,
)
return workflow_vars
@console_ns.doc("delete_snippet_workflow_variables")
@console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)")
@console_ns.response(204, "Workflow variables deleted successfully")
@_snippet_draft_var_prerequisite
def delete(self, snippet: CustomizedSnippet) -> Response:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id)
db.session.commit()
return Response("", 204)
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/variables")
class SnippetNodeVariableCollectionApi(Resource):
@console_ns.doc("get_snippet_node_variables")
@console_ns.doc(description="Get variables for a specific node (snippet draft workflow)")
@console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model)
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_list_model)
def get(self, snippet: CustomizedSnippet, node_id: str) -> WorkflowDraftVariableList:
validate_node_id(node_id)
with Session(bind=db.engine, expire_on_commit=False) as session:
draft_var_srv = WorkflowDraftVariableService(session=session)
node_vars = draft_var_srv.list_node_variables(snippet.id, node_id, user_id=current_user.id)
return node_vars
@console_ns.doc("delete_snippet_node_variables")
@console_ns.doc(description="Delete all variables for a specific node (snippet draft workflow)")
@console_ns.response(204, "Node variables deleted successfully")
@_snippet_draft_var_prerequisite
def delete(self, snippet: CustomizedSnippet, node_id: str) -> Response:
validate_node_id(node_id)
srv = WorkflowDraftVariableService(db.session())
srv.delete_node_variables(snippet.id, node_id, user_id=current_user.id)
db.session.commit()
return Response("", 204)
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/variables/<uuid:variable_id>")
class SnippetVariableApi(Resource):
@console_ns.doc("get_snippet_workflow_variable")
@console_ns.doc(description="Get a specific draft workflow variable (snippet scope)")
@console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model)
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_model)
def get(self, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
variable = _ensure_variable_access(
variable=draft_var_srv.get_variable(variable_id=variable_id),
app_id=snippet.id,
variable_id=variable_id,
)
_ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id)
return variable
@console_ns.doc("update_snippet_workflow_variable")
@console_ns.doc(description="Update a draft workflow variable (snippet scope)")
@console_ns.expect(console_ns.models[WorkflowDraftVariableUpdatePayload.__name__])
@console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model)
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_model)
def patch(self, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
args_model = WorkflowDraftVariableUpdatePayload.model_validate(console_ns.payload or {})
variable = _ensure_variable_access(
variable=draft_var_srv.get_variable(variable_id=variable_id),
app_id=snippet.id,
variable_id=variable_id,
)
_ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id)
new_name = args_model.name
raw_value = args_model.value
if new_name is None and raw_value is None:
return variable
new_value = None
if raw_value is not None:
if variable.value_type == SegmentType.FILE:
if not isinstance(raw_value, dict):
raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}")
raw_value = build_from_mapping(
mapping=raw_value,
tenant_id=snippet.tenant_id,
access_controller=_file_access_controller,
)
elif variable.value_type == SegmentType.ARRAY_FILE:
if not isinstance(raw_value, list):
raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}")
if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
raw_value = build_from_mappings(
mappings=raw_value,
tenant_id=snippet.tenant_id,
access_controller=_file_access_controller,
)
new_value = build_segment_with_type(variable.value_type, raw_value)
draft_var_srv.update_variable(variable, name=new_name, value=new_value)
db.session.commit()
return variable
@console_ns.doc("delete_snippet_workflow_variable")
@console_ns.doc(description="Delete a draft workflow variable (snippet scope)")
@console_ns.response(204, "Variable deleted successfully")
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
def delete(self, snippet: CustomizedSnippet, variable_id: str) -> Response:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
variable = _ensure_variable_access(
variable=draft_var_srv.get_variable(variable_id=variable_id),
app_id=snippet.id,
variable_id=variable_id,
)
_ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id)
draft_var_srv.delete_variable(variable)
db.session.commit()
return Response("", 204)
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/variables/<uuid:variable_id>/reset")
class SnippetVariableResetApi(Resource):
@console_ns.doc("reset_snippet_workflow_variable")
@console_ns.doc(description="Reset a draft workflow variable to its default value (snippet scope)")
@console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model)
@console_ns.response(204, "Variable reset (no content)")
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
def put(self, snippet: CustomizedSnippet, variable_id: str) -> Response | Any:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
snippet_service = SnippetService()
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
if draft_workflow is None:
raise NotFoundError(
f"Draft workflow not found, snippet_id={snippet.id}",
)
variable = _ensure_variable_access(
variable=draft_var_srv.get_variable(variable_id=variable_id),
app_id=snippet.id,
variable_id=variable_id,
)
_ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id)
resetted = draft_var_srv.reset_variable(draft_workflow, variable)
db.session.commit()
if resetted is None:
return Response("", 204)
return marshal(resetted, workflow_draft_variable_model)
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/conversation-variables")
class SnippetConversationVariableCollectionApi(Resource):
@console_ns.doc("get_snippet_conversation_variables")
@console_ns.doc(
description="Conversation variables are not used in snippet workflows; returns an empty list for API parity"
)
@console_ns.response(200, "Conversation variables retrieved successfully", workflow_draft_variable_list_model)
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_list_model)
def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
return WorkflowDraftVariableList(variables=[])
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/system-variables")
class SnippetSystemVariableCollectionApi(Resource):
@console_ns.doc("get_snippet_system_variables")
@console_ns.doc(
description="System variables are not used in snippet workflows; returns an empty list for API parity"
)
@console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model)
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_list_model)
def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
return WorkflowDraftVariableList(variables=[])
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/environment-variables")
class SnippetEnvironmentVariableCollectionApi(Resource):
@console_ns.doc("get_snippet_environment_variables")
@console_ns.doc(description="Get environment variables from snippet draft workflow graph")
@console_ns.response(200, "Environment variables retrieved successfully")
@console_ns.response(404, "Draft workflow not found")
@_snippet_draft_var_prerequisite
def get(self, snippet: CustomizedSnippet) -> dict[str, list[dict[str, Any]]]:
snippet_service = SnippetService()
workflow = snippet_service.get_draft_workflow(snippet=snippet)
if workflow is None:
raise DraftWorkflowNotExist()
env_vars_list: list[dict[str, Any]] = []
for v in workflow.environment_variables:
env_vars_list.append(
{
"id": v.id,
"type": "env",
"name": v.name,
"description": v.description,
"selector": v.selector,
"value_type": v.value_type.exposed_type().value,
"value": v.value,
"edited": False,
"visible": True,
"editable": True,
}
)
return {"items": env_vars_list}

View File

@ -51,7 +51,7 @@ class TagBindingRemovePayload(BaseModel):
class TagListQueryParam(BaseModel):
type: Literal["knowledge", "app", ""] = Field("", description="Tag type filter")
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
keyword: str | None = Field(None, description="Search keyword")
@ -96,7 +96,10 @@ class TagListApi(Resource):
@login_required
@account_initialization_required
@console_ns.doc(
params={"type": 'Tag type filter. Can be "knowledge" or "app".', "keyword": "Search keyword for tag name."}
params={
"type": 'Tag type filter. Can be "knowledge", "app", or "snippet".',
"keyword": "Search keyword for tag name.",
}
)
@console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__name__]])})
@with_current_tenant_id

View File

@ -0,0 +1,414 @@
import logging
import re
from urllib.parse import quote
from flask import Response, request
from flask_restx import Resource, marshal
from sqlalchemy.orm import Session
from werkzeug.datastructures import MultiDict
from werkzeug.exceptions import NotFound
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.snippets.payloads import (
CreateSnippetPayload,
IncludeSecretQuery,
SnippetImportPayload,
SnippetListQuery,
UpdateSnippetPayload,
)
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
)
from extensions.ext_database import db
from fields.snippet_fields import snippet_fields, snippet_list_fields, snippet_pagination_fields
from libs.login import current_account_with_tenant, login_required
from models.snippet import SnippetType
from services.app_dsl_service import ImportStatus
from services.snippet_dsl_service import SnippetDslService
from services.snippet_service import SnippetService
logger = logging.getLogger(__name__)
_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
_CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
def _normalize_snippet_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
normalized: dict[str, str | list[str]] = {}
indexed_tag_ids: list[tuple[int, str]] = []
indexed_creator_ids: list[tuple[int, str]] = []
for key in query_args:
match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key)
if match:
indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
continue
match = _CREATOR_IDS_BRACKET_PATTERN.fullmatch(key)
if match:
indexed_creator_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
continue
value = query_args.get(key)
if value is not None:
normalized[key] = value
if indexed_tag_ids:
normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)]
if indexed_creator_ids:
normalized["creators"] = [value for _, value in sorted(indexed_creator_ids)]
return normalized
# Register Pydantic models with Swagger
register_schema_models(
console_ns,
SnippetListQuery,
CreateSnippetPayload,
UpdateSnippetPayload,
SnippetImportPayload,
IncludeSecretQuery,
)
# Create namespace models for marshaling
snippet_model = console_ns.model("Snippet", snippet_fields)
snippet_list_model = console_ns.model("SnippetList", snippet_list_fields)
snippet_pagination_model = console_ns.model("SnippetPagination", snippet_pagination_fields)
@console_ns.route("/workspaces/current/customized-snippets")
class CustomizedSnippetsApi(Resource):
@console_ns.doc("list_customized_snippets")
@console_ns.expect(console_ns.models.get(SnippetListQuery.__name__))
@console_ns.response(200, "Snippets retrieved successfully", snippet_pagination_model)
@setup_required
@login_required
@account_initialization_required
def get(self):
"""List customized snippets with pagination and search."""
_, current_tenant_id = current_account_with_tenant()
query = SnippetListQuery.model_validate(_normalize_snippet_list_query_args(request.args))
snippets, total, has_more = SnippetService.get_snippets(
tenant_id=current_tenant_id,
page=query.page,
limit=query.limit,
keyword=query.keyword,
is_published=query.is_published,
creators=query.creators,
tag_ids=query.tag_ids,
)
return {
"data": marshal(snippets, snippet_list_fields),
"page": query.page,
"limit": query.limit,
"total": total,
"has_more": has_more,
}, 200
@console_ns.doc("create_customized_snippet")
@console_ns.expect(console_ns.models.get(CreateSnippetPayload.__name__))
@console_ns.response(201, "Snippet created successfully", snippet_model)
@console_ns.response(400, "Invalid request")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def post(self):
"""Create a new customized snippet."""
current_user, current_tenant_id = current_account_with_tenant()
payload = CreateSnippetPayload.model_validate(console_ns.payload or {})
try:
snippet_type = SnippetType(payload.type)
except ValueError:
snippet_type = SnippetType.NODE
try:
if payload.graph is not None:
SnippetService.validate_snippet_graph_forbidden_nodes(payload.graph)
snippet = SnippetService.create_snippet(
tenant_id=current_tenant_id,
name=payload.name,
description=payload.description,
snippet_type=snippet_type,
icon_info=payload.icon_info.model_dump() if payload.icon_info else None,
input_fields=[f.model_dump() for f in payload.input_fields] if payload.input_fields else None,
account=current_user,
)
except ValueError as e:
return {"message": str(e)}, 400
return marshal(snippet, snippet_fields), 201
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>")
class CustomizedSnippetDetailApi(Resource):
@console_ns.doc("get_customized_snippet")
@console_ns.response(200, "Snippet retrieved successfully", snippet_model)
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
def get(self, snippet_id: str):
"""Get customized snippet details."""
_, current_tenant_id = current_account_with_tenant()
snippet = SnippetService.get_snippet_by_id(
snippet_id=str(snippet_id),
tenant_id=current_tenant_id,
)
if not snippet:
raise NotFound("Snippet not found")
return marshal(snippet, snippet_fields), 200
@console_ns.doc("update_customized_snippet")
@console_ns.expect(console_ns.models.get(UpdateSnippetPayload.__name__))
@console_ns.response(200, "Snippet updated successfully", snippet_model)
@console_ns.response(400, "Invalid request")
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def patch(self, snippet_id: str):
"""Update customized snippet."""
current_user, current_tenant_id = current_account_with_tenant()
snippet = SnippetService.get_snippet_by_id(
snippet_id=str(snippet_id),
tenant_id=current_tenant_id,
)
if not snippet:
raise NotFound("Snippet not found")
payload = UpdateSnippetPayload.model_validate(console_ns.payload or {})
update_data = payload.model_dump(exclude_unset=True)
if "icon_info" in update_data and update_data["icon_info"] is not None:
update_data["icon_info"] = payload.icon_info.model_dump() if payload.icon_info else None
if not update_data:
return {"message": "No valid fields to update"}, 400
try:
with Session(db.engine, expire_on_commit=False) as session:
snippet = session.merge(snippet)
snippet = SnippetService.update_snippet(
session=session,
snippet=snippet,
account_id=current_user.id,
data=update_data,
)
session.commit()
except ValueError as e:
return {"message": str(e)}, 400
return marshal(snippet, snippet_fields), 200
@console_ns.doc("delete_customized_snippet")
@console_ns.response(204, "Snippet deleted successfully")
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def delete(self, snippet_id: str):
"""Delete customized snippet."""
_, current_tenant_id = current_account_with_tenant()
snippet = SnippetService.get_snippet_by_id(
snippet_id=str(snippet_id),
tenant_id=current_tenant_id,
)
if not snippet:
raise NotFound("Snippet not found")
with Session(db.engine) as session:
snippet = session.merge(snippet)
SnippetService.delete_snippet(
session=session,
snippet=snippet,
)
session.commit()
return "", 204
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>/export")
class CustomizedSnippetExportApi(Resource):
@console_ns.doc("export_customized_snippet")
@console_ns.doc(description="Export snippet configuration as DSL")
@console_ns.doc(params={"snippet_id": "Snippet ID to export"})
@console_ns.response(200, "Snippet exported successfully")
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def get(self, snippet_id: str):
"""Export snippet as DSL."""
_, current_tenant_id = current_account_with_tenant()
snippet = SnippetService.get_snippet_by_id(
snippet_id=str(snippet_id),
tenant_id=current_tenant_id,
)
if not snippet:
raise NotFound("Snippet not found")
# Get include_secret parameter
query = IncludeSecretQuery.model_validate(request.args.to_dict())
with Session(db.engine) as session:
export_service = SnippetDslService(session)
result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true")
# Set filename with .snippet extension
filename = f"{snippet.name}.snippet"
encoded_filename = quote(filename)
response = Response(
result,
mimetype="application/x-yaml",
)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
response.headers["Content-Type"] = "application/x-yaml"
return response
@console_ns.route("/workspaces/current/customized-snippets/imports")
class CustomizedSnippetImportApi(Resource):
@console_ns.doc("import_customized_snippet")
@console_ns.doc(description="Import snippet from DSL")
@console_ns.expect(console_ns.models.get(SnippetImportPayload.__name__))
@console_ns.response(200, "Snippet imported successfully")
@console_ns.response(202, "Import pending confirmation")
@console_ns.response(400, "Import failed")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def post(self):
"""Import snippet from DSL."""
current_user, _ = current_account_with_tenant()
payload = SnippetImportPayload.model_validate(console_ns.payload or {})
with Session(db.engine) as session:
import_service = SnippetDslService(session)
result = import_service.import_snippet(
account=current_user,
import_mode=payload.mode,
yaml_content=payload.yaml_content,
yaml_url=payload.yaml_url,
snippet_id=payload.snippet_id,
name=payload.name,
description=payload.description,
)
session.commit()
# Return appropriate status code based on result
status = result.status
if status == ImportStatus.FAILED:
return result.model_dump(mode="json"), 400
elif status == ImportStatus.PENDING:
return result.model_dump(mode="json"), 202
return result.model_dump(mode="json"), 200
@console_ns.route("/workspaces/current/customized-snippets/imports/<string:import_id>/confirm")
class CustomizedSnippetImportConfirmApi(Resource):
@console_ns.doc("confirm_snippet_import")
@console_ns.doc(description="Confirm a pending snippet import")
@console_ns.doc(params={"import_id": "Import ID to confirm"})
@console_ns.response(200, "Import confirmed successfully")
@console_ns.response(400, "Import failed")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def post(self, import_id: str):
"""Confirm a pending snippet import."""
current_user, _ = current_account_with_tenant()
with Session(db.engine) as session:
import_service = SnippetDslService(session)
result = import_service.confirm_import(import_id=import_id, account=current_user)
session.commit()
if result.status == ImportStatus.FAILED:
return result.model_dump(mode="json"), 400
return result.model_dump(mode="json"), 200
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>/check-dependencies")
class CustomizedSnippetCheckDependenciesApi(Resource):
@console_ns.doc("check_snippet_dependencies")
@console_ns.doc(description="Check dependencies for a snippet")
@console_ns.doc(params={"snippet_id": "Snippet ID"})
@console_ns.response(200, "Dependencies checked successfully")
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def get(self, snippet_id: str):
"""Check dependencies for a snippet."""
_, current_tenant_id = current_account_with_tenant()
snippet = SnippetService.get_snippet_by_id(
snippet_id=str(snippet_id),
tenant_id=current_tenant_id,
)
if not snippet:
raise NotFound("Snippet not found")
with Session(db.engine) as session:
import_service = SnippetDslService(session)
result = import_service.check_dependencies(snippet=snippet)
return result.model_dump(mode="json"), 200
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>/use-count/increment")
class CustomizedSnippetUseCountIncrementApi(Resource):
@console_ns.doc("increment_snippet_use_count")
@console_ns.doc(description="Increment snippet use count by 1")
@console_ns.doc(params={"snippet_id": "Snippet ID"})
@console_ns.response(200, "Use count incremented successfully")
@console_ns.response(404, "Snippet not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def post(self, snippet_id: str):
"""Increment snippet use count when it is inserted into a workflow."""
_, current_tenant_id = current_account_with_tenant()
snippet = SnippetService.get_snippet_by_id(
snippet_id=str(snippet_id),
tenant_id=current_tenant_id,
)
if not snippet:
raise NotFound("Snippet not found")
with Session(db.engine) as session:
snippet = session.merge(snippet)
SnippetService.increment_use_count(session=session, snippet=snippet)
session.commit()
session.refresh(snippet)
return {"result": "success", "use_count": snippet.use_count}, 200

View File

@ -49,8 +49,8 @@ from extensions.ext_redis import redis_client
from libs.helper import extract_remote_ip
from libs.oauth_bearer import MINTABLE_PROFILES, SubjectType, bearer_feature_required
from libs.rate_limit import (
LIMIT_APPROVE_CONSOLE,
LIMIT_DEVICE_CODE_PER_IP,
LIMIT_DEVICE_FLOW_APPROVE,
LIMIT_LOOKUP_PUBLIC,
rate_limit,
)
@ -210,7 +210,7 @@ class DeviceApproveApi(Resource):
@login_required
@account_initialization_required
@bearer_feature_required
@rate_limit(LIMIT_APPROVE_CONSOLE)
@rate_limit(LIMIT_DEVICE_FLOW_APPROVE)
@with_current_user
@with_current_tenant_id
def post(self, tenant: str, account: Account):
@ -287,7 +287,7 @@ class DeviceDenyApi(Resource):
@login_required
@account_initialization_required
@bearer_feature_required
@rate_limit(LIMIT_APPROVE_CONSOLE)
@rate_limit(LIMIT_DEVICE_FLOW_APPROVE)
def post(self):
payload = _validate_json(DeviceMutateRequest)
user_code = payload.user_code.strip().upper()

View File

@ -4,7 +4,7 @@ from typing import Any, cast
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field
from werkzeug.exceptions import Unauthorized
from werkzeug.exceptions import BadRequest, Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.common import fields
@ -58,6 +58,9 @@ class AppParameterApi(WebApiResource):
)
def get(self, app_model: App, end_user: EndUser):
"""Retrieve app parameters."""
if not app_model.enable_site:
raise BadRequest("Site is disabled.")
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:

View File

@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Literal, overload
from flask import Flask, current_app
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session, sessionmaker
import contexts
from configs import dify_config
@ -68,6 +68,25 @@ def _extract_trace_session_id_from_debug_args(args: Mapping[str, Any] | Any) ->
class WorkflowAppGenerator(BaseAppGenerator):
@staticmethod
def _ensure_snippet_start_node_in_worker(*, session: Session, workflow: Workflow) -> Workflow:
"""Re-apply snippet virtual Start injection after worker reloads workflow from DB."""
if workflow.kind_or_standard != "snippet":
return workflow
from models.snippet import CustomizedSnippet
from services.snippet_generate_service import SnippetGenerateService
snippet = session.scalar(
select(CustomizedSnippet).where(
CustomizedSnippet.id == workflow.app_id,
CustomizedSnippet.tenant_id == workflow.tenant_id,
)
)
if snippet is None:
return workflow
return SnippetGenerateService.ensure_start_node_for_worker(workflow, snippet)
@staticmethod
def _should_prepare_user_inputs(args: Mapping[str, Any]) -> bool:
return not bool(args.get(SKIP_PREPARE_USER_INPUTS_KEY))
@ -592,6 +611,8 @@ class WorkflowAppGenerator(BaseAppGenerator):
if workflow is None:
raise ValueError("Workflow not found")
workflow = self._ensure_snippet_start_node_in_worker(session=session, workflow=workflow)
# Determine system_user_id based on invocation source
is_external_api_call = application_generate_entity.invoke_from in {
InvokeFrom.WEB_APP,

View File

@ -11,6 +11,7 @@ from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, Workfl
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.node_factory import get_default_root_node_id
from core.workflow.nodes.agent_v2.session_cleanup_layer import build_workflow_agent_session_cleanup_layer
from core.workflow.snippet_start import get_compatible_start_aliases
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
from core.workflow.workflow_entry import WorkflowEntry
@ -118,7 +119,15 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
),
)
root_node_id = self._root_node_id or get_default_root_node_id(self._workflow.graph_dict)
add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=inputs)
add_node_inputs_to_pool(
variable_pool,
node_id=root_node_id,
inputs=inputs,
aliases=get_compatible_start_aliases(
workflow_kind=getattr(self._workflow, "kind_or_standard", None),
root_node_id=root_node_id,
),
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
graph = self._init_graph(

View File

@ -392,7 +392,7 @@ Here is the extra instruction you need to follow:
else:
if len(messages[-1]) + len(line) < max_tokens * 0.5:
messages[-1] += line
if get_prompt_tokens(messages[-1] + line) > max_tokens * 0.7:
elif get_prompt_tokens(messages[-1] + line) > max_tokens * 0.7:
messages.append(line)
else:
messages[-1] += line

View File

@ -135,7 +135,7 @@ class BuiltinTool(Tool):
else:
if len(messages[-1]) + len(j) < max_tokens * 0.5:
messages[-1] += j
if get_prompt_tokens(messages[-1] + j) > max_tokens * 0.7:
elif get_prompt_tokens(messages[-1] + j) > max_tokens * 0.7:
messages.append(j)
else:
messages[-1] += j

View File

@ -0,0 +1,21 @@
"""Shared snippet virtual Start-node identifiers and compatibility helpers.
Snippet workflows do not persist a real canvas Start node, so the backend
injects one at runtime. Existing workflow references commonly use the public
selector shape ``#start.<var>#``; keep that contract stable by treating the
runtime-only snippet Start node as compatible with the legacy ``start`` id.
"""
from __future__ import annotations
LEGACY_START_NODE_ID = "start"
SNIPPET_VIRTUAL_START_NODE_ID = "__snippet_virtual_start__"
def get_compatible_start_aliases(*, workflow_kind: str | None, root_node_id: str | None) -> tuple[str, ...]:
"""Return additional selector ids that should mirror snippet Start inputs."""
if workflow_kind == "snippet" and root_node_id == SNIPPET_VIRTUAL_START_NODE_ID:
return (LEGACY_START_NODE_ID,)
return ()

View File

@ -10,6 +10,19 @@ def add_variables_to_pool(variable_pool: VariablePool, variables: Sequence[Varia
variable_pool.add(variable.selector, variable)
def add_node_inputs_to_pool(variable_pool: VariablePool, *, node_id: str, inputs: Mapping[str, Any]) -> None:
for key, value in inputs.items():
variable_pool.add((node_id, key), value)
def add_node_inputs_to_pool(
variable_pool: VariablePool,
*,
node_id: str,
inputs: Mapping[str, Any],
aliases: Sequence[str] = (),
) -> None:
"""Store node inputs under the primary node id and any compatible aliases."""
node_ids: list[str] = [node_id]
for alias in aliases:
if alias not in node_ids:
node_ids.append(alias)
for current_node_id in node_ids:
for key, value in inputs.items():
variable_pool.add((current_node_id, key), value)

View File

@ -0,0 +1,52 @@
from flask_restx import fields
from fields.member_fields import simple_account_fields
from libs.helper import TimestampField
tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String}
# Snippet list item fields (lightweight for list display)
snippet_list_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"type": fields.String,
"version": fields.Integer,
"use_count": fields.Integer,
"is_published": fields.Boolean,
"icon_info": fields.Raw,
"tags": fields.List(fields.Nested(tag_fields)),
"created_by": fields.String,
"author_name": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
# Full snippet fields (includes creator info and graph data)
snippet_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"type": fields.String,
"version": fields.Integer,
"use_count": fields.Integer,
"is_published": fields.Boolean,
"icon_info": fields.Raw,
"graph": fields.Raw(attribute="graph_dict"),
"input_fields": fields.Raw(attribute="input_fields_list"),
"tags": fields.List(fields.Nested(tag_fields)),
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True),
"created_at": TimestampField,
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
"updated_at": TimestampField,
}
# Pagination response fields
snippet_pagination_fields = {
"data": fields.List(fields.Nested(snippet_list_fields)),
"page": fields.Integer,
"limit": fields.Integer,
"total": fields.Integer,
"has_more": fields.Boolean,
}

View File

@ -40,7 +40,11 @@ class RateLimit:
LIMIT_DEVICE_CODE_PER_IP = RateLimit(60, timedelta(hours=1), (RateLimitScope.IP,))
LIMIT_SSO_INITIATE_PER_IP = RateLimit(60, timedelta(hours=1), (RateLimitScope.IP,))
LIMIT_APPROVE_EXT_PER_EMAIL = RateLimit(10, timedelta(hours=1), (RateLimitScope.SUBJECT_EMAIL,))
LIMIT_APPROVE_CONSOLE = RateLimit(10, timedelta(hours=1), (RateLimitScope.SESSION,))
LIMIT_DEVICE_FLOW_APPROVE = RateLimit(
limit=dify_config.DEVICE_FLOW_APPROVE_RATE_LIMIT_PER_HOUR,
window=timedelta(hours=1),
scopes=(RateLimitScope.SESSION,),
)
LIMIT_LOOKUP_PUBLIC = RateLimit(60, timedelta(minutes=5), (RateLimitScope.IP,))
LIMIT_ME_PER_ACCOUNT = RateLimit(60, timedelta(minutes=1), (RateLimitScope.ACCOUNT,))
LIMIT_ME_PER_EMAIL = RateLimit(60, timedelta(minutes=1), (RateLimitScope.SUBJECT_EMAIL,))

View File

@ -0,0 +1,64 @@
"""add customized snippets
Revision ID: 2b3c4d5e6f70
Revises: 121e7346074d
Create Date: 2026-06-03 11:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models.types
# revision identifiers, used by Alembic.
revision = "2b3c4d5e6f70"
down_revision = "121e7346074d"
branch_labels = None
depends_on = None
def _is_pg() -> bool:
return op.get_bind().dialect.name == "postgresql"
def _current_timestamp_default():
return sa.text("CURRENT_TIMESTAMP(0)") if _is_pg() else sa.func.current_timestamp()
def upgrade() -> None:
op.create_table(
"customized_snippets",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", models.types.LongText(), nullable=True),
sa.Column("type", sa.String(length=50), server_default=sa.text("'node'"), nullable=False),
sa.Column("workflow_id", models.types.StringUUID(), nullable=True),
sa.Column("is_published", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("version", sa.Integer(), server_default=sa.text("1"), nullable=False),
sa.Column("use_count", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("icon_info", models.types.AdjustedJSON(), nullable=True),
sa.Column("input_fields", models.types.LongText(), nullable=True),
sa.Column("created_by", models.types.StringUUID(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=_current_timestamp_default(), nullable=False),
sa.Column("updated_by", models.types.StringUUID(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=_current_timestamp_default(), nullable=False),
sa.PrimaryKeyConstraint("id", name="customized_snippet_pkey"),
)
with op.batch_alter_table("customized_snippets", schema=None) as batch_op:
batch_op.create_index("customized_snippet_tenant_idx", ["tenant_id"], unique=False)
with op.batch_alter_table("workflows", schema=None) as batch_op:
batch_op.add_column(sa.Column("kind", sa.String(length=255), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("workflows", schema=None) as batch_op:
batch_op.drop_column("kind")
with op.batch_alter_table("customized_snippets", schema=None) as batch_op:
batch_op.drop_index("customized_snippet_tenant_idx")
op.drop_table("customized_snippets")

View File

@ -106,6 +106,7 @@ from .provider import (
TenantDefaultModel,
TenantPreferredModelProvider,
)
from .snippet import CustomizedSnippet, SnippetType
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
from .task import CeleryTask, CeleryTaskSet
from .tools import (
@ -131,12 +132,14 @@ from .workflow import (
WorkflowAppLog,
WorkflowAppLogCreatedFrom,
WorkflowArchiveLog,
WorkflowKind,
WorkflowNodeExecutionModel,
WorkflowNodeExecutionOffload,
WorkflowNodeExecutionTriggeredFrom,
WorkflowPause,
WorkflowRun,
WorkflowType,
resolve_workflow_kind,
)
__all__ = [
@ -179,6 +182,7 @@ __all__ = [
"CreatorUserRole",
"CredentialPermission",
"CredentialPermissionType",
"CustomizedSnippet",
"DataSourceApiKeyAuthBinding",
"DataSourceOauthBinding",
"Dataset",
@ -227,6 +231,7 @@ __all__ = [
"RecommendedApp",
"SavedMessage",
"Site",
"SnippetType",
"Tag",
"TagBinding",
"Tenant",
@ -256,6 +261,7 @@ __all__ = [
"WorkflowAppLog",
"WorkflowAppLogCreatedFrom",
"WorkflowArchiveLog",
"WorkflowKind",
"WorkflowComment",
"WorkflowCommentMention",
"WorkflowCommentReply",
@ -269,4 +275,5 @@ __all__ = [
"WorkflowToolProvider",
"WorkflowTriggerStatus",
"WorkflowType",
"resolve_workflow_kind",
]

View File

@ -224,6 +224,7 @@ class TagType(StrEnum):
KNOWLEDGE = "knowledge"
APP = "app"
SNIPPET = "snippet"
class DatasetMetadataType(StrEnum):

View File

@ -2499,7 +2499,7 @@ class Tag(TypeBase):
sa.Index("tag_name_idx", "name"),
)
TAG_TYPE_LIST = ["knowledge", "app"]
TAG_TYPE_LIST = ["knowledge", "app", "snippet"]
id: Mapped[str] = mapped_column(
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False

123
api/models/snippet.py Normal file
View File

@ -0,0 +1,123 @@
import json
from datetime import datetime
from enum import StrEnum
from typing import Any
import sqlalchemy as sa
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from libs.uuid_utils import uuidv7
from .account import Account
from .base import Base
from .engine import db
from .model import Tag, TagBinding
from .types import AdjustedJSON, LongText, StringUUID
class SnippetType(StrEnum):
"""Snippet Type Enum"""
NODE = "node"
GROUP = "group"
class CustomizedSnippet(Base):
"""
Customized Snippet Model
Stores reusable workflow components (nodes or node groups) that can be
shared across applications within a workspace.
"""
__tablename__ = "customized_snippets"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="customized_snippet_pkey"),
sa.Index("customized_snippet_tenant_idx", "tenant_id"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(LongText, nullable=True)
type: Mapped[str] = mapped_column(String(50), nullable=False, server_default=sa.text("'node'"))
# Workflow reference for published version
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
# State flags
is_published: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
version: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("1"))
use_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0"))
# Visual customization
icon_info: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True)
# Snippet configuration (stored as JSON text)
input_fields: Mapped[str | None] = mapped_column(LongText, nullable=True)
# Audit fields
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
)
@property
def graph_dict(self) -> dict[str, Any]:
"""Get graph from associated workflow."""
if self.workflow_id:
from .workflow import Workflow
workflow = db.session.get(Workflow, self.workflow_id)
if workflow:
return json.loads(workflow.graph) if workflow.graph else {}
return {}
@property
def input_fields_list(self) -> list[dict[str, Any]]:
"""Parse input_fields JSON to list."""
return json.loads(self.input_fields) if self.input_fields else []
@property
def tags(self):
"""Get snippet tags."""
tags = db.session.scalars(
sa.select(Tag)
.join(TagBinding, Tag.id == TagBinding.tag_id)
.where(
TagBinding.target_id == self.id,
TagBinding.tenant_id == self.tenant_id,
Tag.tenant_id == self.tenant_id,
Tag.type == "snippet",
)
).all()
return tags or []
@property
def created_by_account(self) -> Account | None:
"""Get the account that created this snippet."""
if self.created_by:
return db.session.get(Account, self.created_by)
return None
@property
def author_name(self) -> str | None:
"""Get the creator account name."""
account = self.created_by_account
return account.name if account else None
@property
def updated_by_account(self) -> Account | None:
"""Get the account that last updated this snippet."""
if self.updated_by:
return db.session.get(Account, self.updated_by)
return None
@property
def version_str(self) -> str:
"""Get version as string for API response."""
return str(self.version)

View File

@ -112,6 +112,7 @@ class WorkflowType(StrEnum):
WORKFLOW = "workflow"
CHAT = "chat"
RAG_PIPELINE = "rag-pipeline"
SNIPPET = "snippet"
@classmethod
def value_of(cls, value: str) -> "WorkflowType":
@ -140,6 +141,26 @@ class WorkflowType(StrEnum):
return cls.WORKFLOW if app_mode == AppMode.WORKFLOW else cls.CHAT
class WorkflowKind(StrEnum):
STANDARD = "standard"
SNIPPET = "snippet"
@classmethod
def value_of(cls, value: str) -> "WorkflowKind":
for kind in cls:
if kind.value == value:
return kind
raise ValueError(f"invalid workflow kind value {value}")
def resolve_workflow_kind(kind: str | WorkflowKind | None) -> WorkflowKind:
if kind is None:
return WorkflowKind.STANDARD
if isinstance(kind, WorkflowKind):
return kind
return WorkflowKind.value_of(kind)
class _InvalidGraphDefinitionError(Exception):
pass
@ -187,6 +208,12 @@ class Workflow(Base): # bug
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
type: Mapped[WorkflowType] = mapped_column(EnumText(WorkflowType, length=255), nullable=False)
kind: Mapped[WorkflowKind | None] = mapped_column(
EnumText(WorkflowKind, length=255),
nullable=True,
default=WorkflowKind.STANDARD,
server_default=sa.text("'standard'"),
)
version: Mapped[str] = mapped_column(String(255), nullable=False)
marked_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
marked_comment: Mapped[str] = mapped_column(String(255), default="", server_default="")
@ -228,12 +255,14 @@ class Workflow(Base): # bug
rag_pipeline_variables: list[dict],
marked_name: str = "",
marked_comment: str = "",
kind: str | None = WorkflowKind.STANDARD.value,
) -> "Workflow":
workflow = Workflow()
workflow.id = str(uuid4())
workflow.tenant_id = tenant_id
workflow.app_id = app_id
workflow.type = WorkflowType(type)
workflow.kind = resolve_workflow_kind(kind)
workflow.version = version
workflow.graph = graph
workflow.features = features
@ -255,6 +284,14 @@ class Workflow(Base): # bug
def updated_by_account(self):
return db.session.get(Account, self.updated_by) if self.updated_by else None
@property
def kind_or_standard(self) -> str:
return self.resolved_kind.value
@property
def resolved_kind(self) -> WorkflowKind:
return resolve_workflow_kind(self.kind)
@property
def graph_dict(self) -> Mapping[str, Any]:
# TODO(QuantumGhost): Consider caching `graph_dict` to avoid repeated JSON decoding.

View File

@ -0,0 +1,583 @@
import json
import logging
import uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from enum import StrEnum
from urllib.parse import urlparse
import yaml # type: ignore
from packaging import version
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.helper import ssrf_proxy
from core.plugin.entities.plugin import PluginDependency
from extensions.ext_redis import redis_client
from graphon.enums import BuiltinNodeTypes
from graphon.model_runtime.utils.encoders import jsonable_encoder
from models import Account
from models.snippet import CustomizedSnippet, SnippetType
from models.workflow import Workflow
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService
logger = logging.getLogger(__name__)
IMPORT_INFO_REDIS_KEY_PREFIX = "snippet_import_info:"
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "snippet_check_dependencies:"
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
CURRENT_DSL_VERSION = "0.1.0"
class ImportMode(StrEnum):
YAML_CONTENT = "yaml-content"
YAML_URL = "yaml-url"
class ImportStatus(StrEnum):
COMPLETED = "completed"
COMPLETED_WITH_WARNINGS = "completed-with-warnings"
PENDING = "pending"
FAILED = "failed"
class SnippetImportInfo(BaseModel):
id: str
status: ImportStatus
snippet_id: str | None = None
current_dsl_version: str = CURRENT_DSL_VERSION
imported_dsl_version: str = ""
error: str = ""
class CheckDependenciesResult(BaseModel):
leaked_dependencies: list[PluginDependency] = Field(default_factory=list)
def _check_version_compatibility(imported_version: str) -> ImportStatus:
"""Determine import status based on version comparison"""
try:
current_ver = version.parse(CURRENT_DSL_VERSION)
imported_ver = version.parse(imported_version)
except version.InvalidVersion:
return ImportStatus.FAILED
# If imported version is newer than current, always return PENDING
if imported_ver > current_ver:
return ImportStatus.PENDING
# If imported version is older than current's major, return PENDING
if imported_ver.major < current_ver.major:
return ImportStatus.PENDING
# If imported version is older than current's minor, return COMPLETED_WITH_WARNINGS
if imported_ver.minor < current_ver.minor:
return ImportStatus.COMPLETED_WITH_WARNINGS
# If imported version equals or is older than current's micro, return COMPLETED
return ImportStatus.COMPLETED
class SnippetPendingData(BaseModel):
import_mode: str
yaml_content: str
name: str | None = None
description: str | None = None
snippet_id: str | None
class CheckDependenciesPendingData(BaseModel):
dependencies: list[PluginDependency]
snippet_id: str | None
class SnippetDslService:
def __init__(self, session: Session):
self._session = session
def import_snippet(
self,
*,
account: Account,
import_mode: str,
yaml_content: str | None = None,
yaml_url: str | None = None,
snippet_id: str | None = None,
name: str | None = None,
description: str | None = None,
) -> SnippetImportInfo:
"""Import a snippet from YAML content or URL."""
import_id = str(uuid.uuid4())
# Validate import mode
try:
mode = ImportMode(import_mode)
except ValueError:
raise ValueError(f"Invalid import_mode: {import_mode}")
# Get YAML content
content: str = ""
if mode == ImportMode.YAML_URL:
if not yaml_url:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="yaml_url is required when import_mode is yaml-url",
)
try:
parsed_url = urlparse(yaml_url)
if parsed_url.scheme not in ["http", "https"]:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="Invalid URL scheme, only http and https are allowed",
)
response = ssrf_proxy.get(yaml_url, timeout=(10, 30))
if response.status_code != 200:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error=f"Failed to fetch YAML from URL: {response.status_code}",
)
content = response.text
if len(content) > DSL_MAX_SIZE:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error=f"YAML content size exceeds maximum limit of {DSL_MAX_SIZE} bytes",
)
except Exception as e:
logger.exception("Failed to fetch YAML from URL")
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error=f"Failed to fetch YAML from URL: {str(e)}",
)
elif mode == ImportMode.YAML_CONTENT:
if not yaml_content:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="yaml_content is required when import_mode is yaml-content",
)
content = yaml_content
if len(content) > DSL_MAX_SIZE:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error=f"YAML content size exceeds maximum limit of {DSL_MAX_SIZE} bytes",
)
try:
# Parse YAML
data = yaml.safe_load(content)
if not isinstance(data, dict):
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="Invalid YAML format: expected a dictionary",
)
# Validate and fix DSL version
if not data.get("version"):
data["version"] = "0.1.0"
# Strictly validate kind field
kind = data.get("kind")
if not kind:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="Missing 'kind' field in DSL. Expected 'kind: snippet'.",
)
if kind != "snippet":
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error=f"Invalid DSL kind: expected 'snippet', got '{kind}'. This DSL is for {kind}, not snippet.",
)
imported_version = data.get("version", "0.1.0")
if not isinstance(imported_version, str):
raise ValueError(f"Invalid version type, expected str, got {type(imported_version)}")
status = _check_version_compatibility(imported_version)
# Extract snippet data
snippet_data = data.get("snippet")
if not snippet_data:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="Missing snippet data in YAML content",
)
# Validate workflow nodes - check for forbidden node types
workflow_data = data.get("workflow", {})
if workflow_data:
graph = workflow_data.get("graph", {})
nodes = graph.get("nodes", [])
forbidden_nodes_found = []
for node in nodes:
node_data = node.get("data", {})
if not node_data:
continue
node_type = node_data.get("type", "")
if node_type in SNIPPET_FORBIDDEN_NODE_TYPES:
forbidden_nodes_found.append(node_type)
if forbidden_nodes_found:
forbidden_types_str = ", ".join(set(forbidden_nodes_found))
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error=f"Snippet cannot contain the following node types: {forbidden_types_str}",
)
# If snippet_id is provided, check if it exists
snippet = None
if snippet_id:
stmt = select(CustomizedSnippet).where(
CustomizedSnippet.id == snippet_id,
CustomizedSnippet.tenant_id == account.current_tenant_id,
)
snippet = self._session.scalar(stmt)
if not snippet:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="Snippet not found",
)
# If major version mismatch, store import info in Redis
if status == ImportStatus.PENDING:
pending_data = SnippetPendingData(
import_mode=import_mode,
yaml_content=content,
name=name,
description=description,
snippet_id=snippet_id,
)
redis_client.setex(
f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}",
IMPORT_INFO_REDIS_EXPIRY,
pending_data.model_dump_json(),
)
return SnippetImportInfo(
id=import_id,
status=status,
snippet_id=snippet_id,
imported_dsl_version=imported_version,
)
# Extract dependencies
dependencies = data.get("dependencies", [])
check_dependencies_pending_data = None
if dependencies:
check_dependencies_pending_data = [PluginDependency.model_validate(d) for d in dependencies]
# Create or update snippet
snippet = self._create_or_update_snippet(
snippet=snippet,
data=data,
account=account,
name=name,
description=description,
dependencies=check_dependencies_pending_data,
)
return SnippetImportInfo(
id=import_id,
status=status,
snippet_id=snippet.id,
imported_dsl_version=imported_version,
)
except yaml.YAMLError as e:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error=f"Invalid YAML format: {str(e)}",
)
except Exception as e:
logger.exception("Failed to import snippet")
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error=str(e),
)
def confirm_import(self, *, import_id: str, account: Account) -> SnippetImportInfo:
"""
Confirm an import that requires confirmation
"""
redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}"
pending_data = redis_client.get(redis_key)
if not pending_data:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="Import information expired or does not exist",
)
try:
if not isinstance(pending_data, str | bytes):
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="Invalid import information",
)
pending_data_str = pending_data.decode("utf-8") if isinstance(pending_data, bytes) else pending_data
pending = SnippetPendingData.model_validate_json(pending_data_str)
data = yaml.safe_load(pending.yaml_content)
if not isinstance(data, dict):
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="Invalid YAML format: expected a dictionary",
)
snippet = None
if pending.snippet_id:
stmt = select(CustomizedSnippet).where(
CustomizedSnippet.id == pending.snippet_id,
CustomizedSnippet.tenant_id == account.current_tenant_id,
)
snippet = self._session.scalar(stmt)
snippet = self._create_or_update_snippet(
snippet=snippet,
data=data,
account=account,
name=pending.name,
description=pending.description,
)
redis_client.delete(redis_key)
return SnippetImportInfo(
id=import_id,
status=ImportStatus.COMPLETED,
snippet_id=snippet.id,
imported_dsl_version=data.get("version", "0.1.0"),
)
except Exception as e:
logger.exception("Failed to confirm import")
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error=str(e),
)
def check_dependencies(self, snippet: CustomizedSnippet) -> CheckDependenciesResult:
"""
Check dependencies for a snippet
"""
snippet_service = SnippetService()
workflow = snippet_service.get_draft_workflow(snippet=snippet)
if not workflow:
return CheckDependenciesResult(leaked_dependencies=[])
dependencies = self._extract_dependencies_from_workflow(workflow)
leaked_dependencies = DependenciesAnalysisService.generate_dependencies(
tenant_id=snippet.tenant_id, dependencies=dependencies
)
return CheckDependenciesResult(leaked_dependencies=leaked_dependencies)
def _create_or_update_snippet(
self,
*,
snippet: CustomizedSnippet | None,
data: dict,
account: Account,
name: str | None = None,
description: str | None = None,
dependencies: list[PluginDependency] | None = None,
) -> CustomizedSnippet:
"""
Create or update snippet from DSL data
"""
snippet_data = data.get("snippet", {})
workflow_data = data.get("workflow", {})
# Extract snippet info
snippet_name = name or snippet_data.get("name") or "Untitled Snippet"
snippet_description = description or snippet_data.get("description") or ""
snippet_type_str = snippet_data.get("type", "node")
try:
snippet_type = SnippetType(snippet_type_str)
except ValueError:
snippet_type = SnippetType.NODE
icon_info = snippet_data.get("icon_info", {})
input_fields = snippet_data.get("input_fields", [])
# Create or update snippet
if snippet:
# Update existing snippet
snippet.name = snippet_name
snippet.description = snippet_description
snippet.type = snippet_type.value
snippet.icon_info = icon_info or None
snippet.input_fields = json.dumps(input_fields) if input_fields else None
snippet.updated_by = account.id
snippet.updated_at = datetime.now(UTC).replace(tzinfo=None)
else:
# Create new snippet
snippet = CustomizedSnippet(
tenant_id=account.current_tenant_id,
name=snippet_name,
description=snippet_description,
type=snippet_type.value,
icon_info=icon_info or None,
input_fields=json.dumps(input_fields) if input_fields else None,
created_by=account.id,
)
self._session.add(snippet)
self._session.flush()
# Create or update draft workflow
if workflow_data:
graph = workflow_data.get("graph", {})
snippet_service = SnippetService()
# Get existing workflow hash if exists
existing_workflow = snippet_service.get_draft_workflow(snippet=snippet)
unique_hash = existing_workflow.unique_hash if existing_workflow else None
snippet_service.sync_draft_workflow(
snippet=snippet,
graph=graph,
unique_hash=unique_hash,
account=account,
input_fields=input_fields,
)
self._session.commit()
return snippet
def export_snippet_dsl(self, snippet: CustomizedSnippet, include_secret: bool = False) -> str:
"""
Export snippet as DSL
:param snippet: CustomizedSnippet instance
:param include_secret: Whether include secret variable
:return: YAML string
"""
snippet_service = SnippetService()
workflow = snippet_service.get_draft_workflow(snippet=snippet)
if not workflow:
raise ValueError("Missing draft workflow configuration, please check.")
icon_info = snippet.icon_info or {}
export_data = {
"version": CURRENT_DSL_VERSION,
"kind": "snippet",
"snippet": {
"name": snippet.name,
"description": snippet.description or "",
"type": snippet.type,
"icon_info": icon_info,
"input_fields": snippet.input_fields_list,
},
}
self._append_workflow_export_data(
export_data=export_data, snippet=snippet, workflow=workflow, include_secret=include_secret
)
return yaml.dump(export_data, allow_unicode=True) # type: ignore
def _append_workflow_export_data(
self, *, export_data: dict, snippet: CustomizedSnippet, workflow: Workflow, include_secret: bool
) -> None:
"""
Append workflow export data
"""
workflow_dict = workflow.to_dict(include_secret=include_secret)
# Filter workspace related data from nodes
workflow_dict["environment_variables"] = []
workflow_dict["conversation_variables"] = []
for node in workflow_dict.get("graph", {}).get("nodes", []):
node_data = node.get("data", {})
if not node_data:
continue
data_type = node_data.get("type", "")
if data_type == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
dataset_ids = node_data.get("dataset_ids", [])
node["data"]["dataset_ids"] = [
self._encrypt_dataset_id(dataset_id=dataset_id, tenant_id=snippet.tenant_id)
for dataset_id in dataset_ids
]
# filter credential id from tool node
if not include_secret and data_type == BuiltinNodeTypes.TOOL:
node_data.pop("credential_id", None)
# filter credential id from agent node
if not include_secret and data_type == BuiltinNodeTypes.AGENT:
for tool in node_data.get("agent_parameters", {}).get("tools", {}).get("value", []):
tool.pop("credential_id", None)
export_data["workflow"] = workflow_dict
dependencies = self._extract_dependencies_from_workflow(workflow)
export_data["dependencies"] = [
jsonable_encoder(d.model_dump())
for d in DependenciesAnalysisService.generate_dependencies(
tenant_id=snippet.tenant_id, dependencies=dependencies
)
]
def _encrypt_dataset_id(self, *, dataset_id: str, tenant_id: str) -> str:
"""
Encrypt dataset ID for export
"""
# For now, just return the dataset_id as-is
# In the future, we might want to encrypt it
return dataset_id
def _extract_dependencies_from_workflow(self, workflow: Workflow) -> list[str]:
"""
Extract dependencies from workflow
:param workflow: Workflow instance
:return: dependencies list format like ["langgenius/google"]
"""
graph = workflow.graph_dict
dependencies = self._extract_dependencies_from_workflow_graph(graph)
return dependencies
def _extract_dependencies_from_workflow_graph(self, graph: Mapping) -> list[str]:
"""
Extract dependencies from workflow graph
:param graph: Workflow graph
:return: dependencies list format like ["langgenius/google"]
"""
dependencies = []
for node in graph.get("nodes", []):
node_data = node.get("data", {})
if not node_data:
continue
data_type = node_data.get("type", "")
if data_type == BuiltinNodeTypes.TOOL:
tool_config = node_data.get("tool_configurations", {})
provider_type = tool_config.get("provider_type")
provider_name = tool_config.get("provider")
if provider_type and provider_name:
dependencies.append(f"{provider_name}/{provider_name}")
elif data_type == BuiltinNodeTypes.AGENT:
agent_parameters = node_data.get("agent_parameters", {})
tools = agent_parameters.get("tools", {}).get("value", [])
for tool in tools:
provider_type = tool.get("provider_type")
provider_name = tool.get("provider")
if provider_type and provider_name:
dependencies.append(f"{provider_name}/{provider_name}")
return dependencies

View File

@ -0,0 +1,468 @@
"""
Service for generating snippet workflow executions.
Uses an adapter pattern to bridge CustomizedSnippet with the App-based
WorkflowAppGenerator. The adapter (_SnippetAsApp) provides the minimal App-like
interface needed by the generator, avoiding modifications to core workflow
infrastructure.
Key invariants:
- Snippets always run as WORKFLOW mode (not CHAT or ADVANCED_CHAT).
- The adapter maps snippet.id to app_id in workflow execution records.
- Snippet debugging has no rate limiting (max_active_requests = 0).
Supported execution modes:
- Full workflow run (generate): Runs the entire draft workflow as SSE stream.
- Single node run (run_draft_node): Synchronous single-step debugging for regular nodes.
- Single iteration run (generate_single_iteration): SSE stream for iteration container nodes.
- Single loop run (generate_single_loop): SSE stream for loop container nodes.
"""
import json
import logging
from collections.abc import Generator, Mapping, Sequence
from typing import Any, Union
from sqlalchemy.orm import make_transient
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.app.apps.workflow.app_generator import WorkflowAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from core.workflow.snippet_start import SNIPPET_VIRTUAL_START_NODE_ID
from factories import file_factory
from graphon.file.models import File
from models import Account
from models.model import AppMode, EndUser
from models.snippet import CustomizedSnippet
from models.workflow import Workflow, WorkflowNodeExecutionModel
from services.snippet_service import SnippetService
from services.workflow_service import WorkflowService
logger = logging.getLogger(__name__)
class _SnippetAsApp:
"""
Minimal adapter that wraps a CustomizedSnippet to satisfy the App-like
interface required by WorkflowAppGenerator, WorkflowAppConfigManager,
and WorkflowService.run_draft_workflow_node.
Used properties:
- id: maps to snippet.id (stored as app_id in workflows table)
- tenant_id: maps to snippet.tenant_id
- mode: hardcoded to AppMode.WORKFLOW since snippets always run as workflows
- max_active_requests: defaults to 0 (no limit) for snippet debugging
- app_model_config_id: None (snippets don't have app model configs)
"""
id: str
tenant_id: str
mode: str
max_active_requests: int
app_model_config_id: str | None
def __init__(self, snippet: CustomizedSnippet) -> None:
self.id = snippet.id
self.tenant_id = snippet.tenant_id
self.mode = AppMode.WORKFLOW.value
self.max_active_requests = 0
self.app_model_config_id = None
class SnippetGenerateService:
"""
Service for running snippet workflow executions.
Adapts CustomizedSnippet to work with the existing App-based
WorkflowAppGenerator infrastructure, avoiding duplication of the
complex workflow execution pipeline.
"""
# Specific ID for the injected virtual Start node so it can be recognised
_VIRTUAL_START_NODE_ID = SNIPPET_VIRTUAL_START_NODE_ID
@classmethod
def _is_virtual_start_event(cls, message: Mapping[str, Any] | str) -> bool:
"""
Return True when *message* is a snippet-only virtual Start node event.
The virtual Start node is injected purely for snippet execution and is
not part of the persisted draft graph. Filter its node lifecycle events
out of the SSE stream so the frontend only receives nodes that exist on
the canvas.
"""
if not isinstance(message, Mapping):
return False
if message.get("event") not in {"node_started", "node_finished"}:
return False
data = message.get("data")
if not isinstance(data, Mapping):
return False
return data.get("node_id") == cls._VIRTUAL_START_NODE_ID
@classmethod
def _filter_virtual_start_events(
cls,
response: Mapping[str, Any] | Generator[Mapping[str, Any] | str, None, None],
) -> Mapping[str, Any] | Generator[Mapping[str, Any] | str, None, None]:
"""
Drop snippet virtual Start node lifecycle events from stream responses.
Blocking responses are returned unchanged because they never expose the
injected node as a standalone event payload.
"""
if isinstance(response, Mapping):
return response
def _stream() -> Generator[Mapping[str, Any] | str, None, None]:
for message in response:
if cls._is_virtual_start_event(message):
continue
yield message
return _stream()
@classmethod
def generate(
cls,
snippet: CustomizedSnippet,
user: Union[Account, EndUser],
args: Mapping[str, Any],
invoke_from: InvokeFrom,
streaming: bool = True,
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]:
"""
Run a snippet's draft workflow.
Retrieves the draft workflow, adapts the snippet to an App-like proxy,
then delegates execution to WorkflowAppGenerator.
If the workflow graph has no Start node, a virtual Start node is injected
in-memory so that:
1. Graph validation passes (root node must have execution_type=ROOT).
2. User inputs are processed into the variable pool by the StartNode logic.
:param snippet: CustomizedSnippet instance
:param user: Account or EndUser initiating the run
:param args: Workflow inputs (must include "inputs" key)
:param invoke_from: Source of invocation (typically DEBUGGER)
:param streaming: Whether to stream the response
:return: Blocking response mapping or SSE streaming generator
:raises ValueError: If the snippet has no draft workflow
"""
snippet_service = SnippetService()
workflow = snippet_service.get_draft_workflow(snippet=snippet)
if not workflow:
raise ValueError("Workflow not initialized")
# Inject a virtual Start node when the graph doesn't have one.
workflow = cls._ensure_start_node(workflow, snippet)
# Adapt snippet to App-like interface for WorkflowAppGenerator
app_proxy = _SnippetAsApp(snippet)
response = WorkflowAppGenerator().generate(
app_model=app_proxy, # type: ignore[arg-type]
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=streaming,
call_depth=0,
)
return WorkflowAppGenerator.convert_to_event_stream(
cls._filter_virtual_start_events(response)
)
@classmethod
def run_published(
cls,
snippet: CustomizedSnippet,
user: Union[Account, EndUser],
args: Mapping[str, Any],
invoke_from: InvokeFrom,
) -> Mapping[str, Any]:
"""
Run a snippet's published workflow in non-streaming (blocking) mode.
Similar to :meth:`generate` but targets the published workflow instead
of the draft, and returns the raw blocking response without SSE
wrapping. Designed for programmatic callers that need direct workflow outputs.
:param snippet: CustomizedSnippet instance (must be published)
:param user: Account or EndUser initiating the run
:param args: Workflow inputs (must include "inputs" key)
:param invoke_from: Source of invocation
:return: Blocking response mapping with workflow outputs
:raises ValueError: If the snippet has no published workflow
"""
snippet_service = SnippetService()
workflow = snippet_service.get_published_workflow(snippet)
if not workflow:
raise ValueError("No published workflow found for snippet")
# Inject a virtual Start node when the graph doesn't have one.
workflow = cls._ensure_start_node(workflow, snippet)
app_proxy = _SnippetAsApp(snippet)
response: Mapping[str, Any] = WorkflowAppGenerator().generate(
app_model=app_proxy, # type: ignore[arg-type]
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=False,
)
return response
@classmethod
def ensure_start_node_for_worker(cls, workflow: Workflow, snippet: CustomizedSnippet) -> Workflow:
"""Public wrapper for worker-thread start-node injection."""
return cls._ensure_start_node(workflow, snippet)
@classmethod
def _ensure_start_node(cls, workflow: Workflow, snippet: CustomizedSnippet) -> Workflow:
"""
Return *workflow* with a Start node.
If the graph already contains a Start node, the original workflow is
returned unchanged. Otherwise a virtual Start node is injected and the
workflow object is detached from the SQLAlchemy session so the in-memory
change is never flushed to the database.
"""
graph_dict = workflow.graph_dict
nodes: list[dict[str, Any]] = graph_dict.get("nodes", [])
has_start = any(node.get("data", {}).get("type") == "start" for node in nodes)
if has_start:
return workflow
modified_graph = cls._inject_virtual_start_node(
graph_dict=graph_dict,
input_fields=snippet.input_fields_list,
)
# Detach from session to prevent accidental DB persistence of the
# modified graph. All attributes remain accessible for read.
make_transient(workflow)
workflow.graph = json.dumps(modified_graph)
return workflow
@classmethod
def _inject_virtual_start_node(
cls,
graph_dict: Mapping[str, Any],
input_fields: list[dict[str, Any]],
) -> dict[str, Any]:
"""
Build a new graph dict with a virtual Start node prepended.
The virtual Start node is wired to every existing node that has no
incoming edges (i.e. the current root candidates). This guarantees:
:param graph_dict: Original graph configuration.
:param input_fields: Snippet input field definitions from
``CustomizedSnippet.input_fields_list``.
:return: New graph dict containing the virtual Start node and edges.
"""
nodes: list[dict[str, Any]] = list(graph_dict.get("nodes", []))
edges: list[dict[str, Any]] = list(graph_dict.get("edges", []))
# Identify nodes with no incoming edges.
nodes_with_incoming: set[str] = set()
for edge in edges:
target = edge.get("target")
if isinstance(target, str):
nodes_with_incoming.add(target)
root_candidate_ids = [n["id"] for n in nodes if n["id"] not in nodes_with_incoming]
# Build Start node ``variables`` from snippet input fields.
start_variables: list[dict[str, Any]] = []
for field in input_fields:
var: dict[str, Any] = {
"variable": field.get("variable", ""),
"label": field.get("label", field.get("variable", "")),
"type": field.get("type", "text-input"),
"required": field.get("required", False),
"options": field.get("options", []),
}
if field.get("max_length") is not None:
var["max_length"] = field["max_length"]
start_variables.append(var)
virtual_start_node: dict[str, Any] = {
"id": cls._VIRTUAL_START_NODE_ID,
"data": {
"type": "start",
"title": "Start",
"variables": start_variables,
},
}
# Create edges from virtual Start to each root candidate.
new_edges: list[dict[str, Any]] = [
{
"source": cls._VIRTUAL_START_NODE_ID,
"sourceHandle": "source",
"target": root_id,
"targetHandle": "target",
}
for root_id in root_candidate_ids
]
return {
**graph_dict,
"nodes": [virtual_start_node, *nodes],
"edges": [*edges, *new_edges],
}
@classmethod
def run_draft_node(
cls,
snippet: CustomizedSnippet,
node_id: str,
user_inputs: Mapping[str, Any],
account: Account,
query: str = "",
files: Sequence[File] | None = None,
) -> WorkflowNodeExecutionModel:
"""
Run a single node in a snippet's draft workflow (single-step debugging).
Retrieves the draft workflow, adapts the snippet to an App-like proxy,
parses file inputs, then delegates to WorkflowService.run_draft_workflow_node.
:param snippet: CustomizedSnippet instance
:param node_id: ID of the node to run
:param user_inputs: User input values for the node
:param account: Account initiating the run
:param query: Optional query string
:param files: Optional parsed file objects
:return: WorkflowNodeExecutionModel with execution results
:raises ValueError: If the snippet has no draft workflow
"""
snippet_service = SnippetService()
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
if not draft_workflow:
raise ValueError("Workflow not initialized")
app_proxy = _SnippetAsApp(snippet)
workflow_service = WorkflowService()
return workflow_service.run_draft_workflow_node(
app_model=app_proxy, # type: ignore[arg-type]
draft_workflow=draft_workflow,
node_id=node_id,
user_inputs=user_inputs,
account=account,
query=query,
files=files,
)
@classmethod
def generate_single_iteration(
cls,
snippet: CustomizedSnippet,
user: Union[Account, EndUser],
node_id: str,
args: Mapping[str, Any],
streaming: bool = True,
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]:
"""
Run a single iteration node in a snippet's draft workflow.
Iteration nodes are container nodes that execute their sub-graph multiple
times, producing many events. Therefore, this uses the full WorkflowAppGenerator
pipeline with SSE streaming (unlike regular single-step node run).
:param snippet: CustomizedSnippet instance
:param user: Account or EndUser initiating the run
:param node_id: ID of the iteration node to run
:param args: Dict containing 'inputs' key with iteration input data
:param streaming: Whether to stream the response (should be True)
:return: SSE streaming generator
:raises ValueError: If the snippet has no draft workflow
"""
snippet_service = SnippetService()
workflow = snippet_service.get_draft_workflow(snippet=snippet)
if not workflow:
raise ValueError("Workflow not initialized")
app_proxy = _SnippetAsApp(snippet)
return WorkflowAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().single_iteration_generate(
app_model=app_proxy, # type: ignore[arg-type]
workflow=workflow,
node_id=node_id,
user=user,
args=args,
streaming=streaming,
)
)
@classmethod
def generate_single_loop(
cls,
snippet: CustomizedSnippet,
user: Union[Account, EndUser],
node_id: str,
args: Any,
streaming: bool = True,
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]:
"""
Run a single loop node in a snippet's draft workflow.
Loop nodes are container nodes that execute their sub-graph repeatedly,
producing many events. Therefore, this uses the full WorkflowAppGenerator
pipeline with SSE streaming (unlike regular single-step node run).
:param snippet: CustomizedSnippet instance
:param user: Account or EndUser initiating the run
:param node_id: ID of the loop node to run
:param args: Pydantic model with 'inputs' attribute containing loop input data
:param streaming: Whether to stream the response (should be True)
:return: SSE streaming generator
:raises ValueError: If the snippet has no draft workflow
"""
snippet_service = SnippetService()
workflow = snippet_service.get_draft_workflow(snippet=snippet)
if not workflow:
raise ValueError("Workflow not initialized")
app_proxy = _SnippetAsApp(snippet)
return WorkflowAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().single_loop_generate(
app_model=app_proxy, # type: ignore[arg-type]
workflow=workflow,
node_id=node_id,
user=user,
args=args, # type: ignore[arg-type]
streaming=streaming,
)
)
@staticmethod
def parse_files(workflow: Workflow, files: list[dict] | None = None) -> Sequence[File]:
"""
Parse file mappings into File objects based on workflow configuration.
:param workflow: Workflow instance for file upload config
:param files: Raw file mapping dicts
:return: Parsed File objects
"""
files = files or []
file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False)
if file_extra_config is None:
return []
return file_factory.build_from_mappings(
mappings=files,
tenant_id=workflow.tenant_id,
config=file_extra_config,
)

View File

@ -0,0 +1,675 @@
import json
import logging
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session, sessionmaker
from core.workflow.node_factory import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
from extensions.ext_database import db
from graphon.enums import BuiltinNodeTypes, NodeType
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models import Account, TagBinding
from models.enums import WorkflowRunTriggeredFrom
from models.snippet import CustomizedSnippet, SnippetType
from models.workflow import (
Workflow,
WorkflowKind,
WorkflowNodeExecutionModel,
WorkflowRun,
WorkflowType,
)
from repositories.factory import DifyAPIRepositoryFactory
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.tag_service import TagService
from services.workflow_restore import apply_published_workflow_snapshot_to_draft
logger = logging.getLogger(__name__)
# Node types not allowed in snippet workflows (sync, publish, DSL import).
SNIPPET_FORBIDDEN_NODE_TYPES: frozenset[str] = frozenset(
{
BuiltinNodeTypes.START,
BuiltinNodeTypes.HUMAN_INPUT,
BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL,
}
)
class SnippetService:
"""Service for managing customized snippets."""
def __init__(self, session_maker: sessionmaker | None = None):
"""Initialize SnippetService with repository dependencies."""
if session_maker is None:
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
session_maker
)
self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
@staticmethod
def _snippet_kind_filter():
"""Match snippet workflows by business kind."""
return Workflow.kind == WorkflowKind.SNIPPET.value
@staticmethod
def validate_snippet_graph_forbidden_nodes(graph: Mapping[str, Any]) -> None:
"""Reject graphs that contain node types not allowed in snippets."""
nodes = graph.get("nodes") or []
disallowed: list[tuple[str, str]] = []
for node in nodes:
if not isinstance(node, dict):
continue
node_data = node.get("data") or {}
node_type = node_data.get("type")
if not isinstance(node_type, str):
continue
if node_type in SNIPPET_FORBIDDEN_NODE_TYPES:
node_id = node.get("id")
disallowed.append((str(node_id) if node_id is not None else "?", node_type))
if not disallowed:
return
detail = ", ".join(f"{nid}:{t}" for nid, t in disallowed)
raise ValueError(
"Snippet workflow cannot contain start, human-input, or knowledge-retrieval nodes. "
f"Found: {detail}"
)
# --- CRUD Operations ---
@staticmethod
def get_snippets(
*,
tenant_id: str,
page: int = 1,
limit: int = 20,
keyword: str | None = None,
is_published: bool | None = None,
creators: list[str] | None = None,
tag_ids: list[str] | None = None,
) -> tuple[Sequence[CustomizedSnippet], int, bool]:
"""
Get paginated list of snippets with optional search.
:param tenant_id: Tenant ID
:param page: Page number (1-indexed)
:param limit: Number of items per page
:param keyword: Optional search keyword for name/description
:param is_published: Optional filter by published status (True/False/None for all)
:param creators: Optional filter by creator account IDs
:param tag_ids: Optional filter by tag IDs
:return: Tuple of (snippets list, total count, has_more flag)
"""
stmt = (
select(CustomizedSnippet)
.where(CustomizedSnippet.tenant_id == tenant_id)
.order_by(CustomizedSnippet.created_at.desc())
)
if keyword:
stmt = stmt.where(
CustomizedSnippet.name.ilike(f"%{keyword}%") | CustomizedSnippet.description.ilike(f"%{keyword}%")
)
if is_published is not None:
stmt = stmt.where(CustomizedSnippet.is_published == is_published)
if creators:
stmt = stmt.where(CustomizedSnippet.created_by.in_(creators))
if tag_ids:
target_ids = TagService.get_target_ids_by_tag_ids("snippet", tenant_id, tag_ids)
if target_ids:
stmt = stmt.where(CustomizedSnippet.id.in_(target_ids))
else:
return [], 0, False
# Get total count
count_stmt = select(func.count()).select_from(stmt.subquery())
total = db.session.scalar(count_stmt) or 0
# Apply pagination
stmt = stmt.limit(limit + 1).offset((page - 1) * limit)
snippets = list(db.session.scalars(stmt).all())
has_more = len(snippets) > limit
if has_more:
snippets = snippets[:-1]
return snippets, total, has_more
@staticmethod
def get_snippet_by_id(
*,
snippet_id: str,
tenant_id: str,
) -> CustomizedSnippet | None:
"""
Get snippet by ID with tenant isolation.
:param snippet_id: Snippet ID
:param tenant_id: Tenant ID
:return: CustomizedSnippet or None
"""
return (
db.session.query(CustomizedSnippet)
.where(
CustomizedSnippet.id == snippet_id,
CustomizedSnippet.tenant_id == tenant_id,
)
.first()
)
@staticmethod
def create_snippet(
*,
tenant_id: str,
name: str,
description: str | None,
snippet_type: SnippetType,
icon_info: dict | None,
input_fields: list[dict] | None,
account: Account,
) -> CustomizedSnippet:
"""
Create a new snippet.
:param tenant_id: Tenant ID
:param name: Snippet name
:param description: Snippet description
:param snippet_type: Type of snippet (node or group)
:param icon_info: Icon information
:param input_fields: Input field definitions
:param account: Creator account
:return: Created CustomizedSnippet
"""
snippet = CustomizedSnippet(
tenant_id=tenant_id,
name=name,
description=description or "",
type=snippet_type.value,
icon_info=icon_info,
input_fields=json.dumps(input_fields) if input_fields else None,
created_by=account.id,
)
db.session.add(snippet)
db.session.commit()
return snippet
@staticmethod
def update_snippet(
*,
session: Session,
snippet: CustomizedSnippet,
account_id: str,
data: dict,
) -> CustomizedSnippet:
"""
Update snippet attributes.
:param session: Database session
:param snippet: Snippet to update
:param account_id: ID of account making the update
:param data: Dictionary of fields to update
:return: Updated CustomizedSnippet
"""
if "name" in data:
snippet.name = data["name"]
if "description" in data:
snippet.description = data["description"]
if "icon_info" in data:
snippet.icon_info = data["icon_info"]
snippet.updated_by = account_id
snippet.updated_at = datetime.now(UTC).replace(tzinfo=None)
session.add(snippet)
return snippet
@staticmethod
def delete_snippet(
*,
session: Session,
snippet: CustomizedSnippet,
) -> bool:
"""
Delete a snippet.
:param session: Database session
:param snippet: Snippet to delete
:return: True if deleted successfully
"""
session.execute(
delete(TagBinding).where(
TagBinding.tenant_id == snippet.tenant_id,
TagBinding.target_id == snippet.id,
)
)
session.delete(snippet)
return True
# --- Workflow Operations ---
def get_draft_workflow(self, snippet: CustomizedSnippet) -> Workflow | None:
"""
Get draft workflow for snippet.
:param snippet: CustomizedSnippet instance
:return: Draft Workflow or None
"""
workflow = (
db.session.query(Workflow)
.where(
Workflow.tenant_id == snippet.tenant_id,
Workflow.app_id == snippet.id,
self._snippet_kind_filter(),
Workflow.version == "draft",
)
.first()
)
return workflow
def get_published_workflow(self, snippet: CustomizedSnippet) -> Workflow | None:
"""
Get published workflow for snippet.
:param snippet: CustomizedSnippet instance
:return: Published Workflow or None
"""
if not snippet.workflow_id:
return None
workflow = (
db.session.query(Workflow)
.where(
Workflow.tenant_id == snippet.tenant_id,
Workflow.app_id == snippet.id,
self._snippet_kind_filter(),
Workflow.id == snippet.workflow_id,
)
.first()
)
return workflow
def get_published_workflow_by_id(self, snippet: CustomizedSnippet, workflow_id: str) -> Workflow | None:
"""
Get a published workflow snapshot by ID for snippet history restore.
:param snippet: CustomizedSnippet instance
:param workflow_id: Workflow ID
:return: Published Workflow or None
:raises IsDraftWorkflowError: If the workflow ID points to a draft workflow
"""
workflow = (
db.session.query(Workflow)
.where(
Workflow.tenant_id == snippet.tenant_id,
Workflow.app_id == snippet.id,
self._snippet_kind_filter(),
Workflow.id == workflow_id,
)
.first()
)
if not workflow:
return None
if workflow.version == Workflow.VERSION_DRAFT:
raise IsDraftWorkflowError("source workflow must be published")
return workflow
def sync_draft_workflow(
self,
*,
snippet: CustomizedSnippet,
graph: dict,
unique_hash: str | None,
account: Account,
input_fields: list[dict] | None = None,
) -> Workflow:
"""
Sync draft workflow for snippet.
Snippet workflows do not persist environment variables (always empty) or
conversation variables (always empty).
:param snippet: CustomizedSnippet instance
:param graph: Workflow graph configuration
:param unique_hash: Hash for conflict detection
:param account: Account making the change
:param input_fields: Input fields for snippet
:return: Synced Workflow
:raises WorkflowHashNotEqualError: If hash mismatch
"""
SnippetService.validate_snippet_graph_forbidden_nodes(graph)
workflow = self.get_draft_workflow(snippet=snippet)
if workflow and workflow.unique_hash != unique_hash:
raise WorkflowHashNotEqualError()
# Create draft workflow if not found
if not workflow:
workflow = Workflow(
tenant_id=snippet.tenant_id,
app_id=snippet.id,
features="{}",
type=WorkflowType.WORKFLOW.value,
kind=WorkflowKind.SNIPPET.value,
version="draft",
graph=json.dumps(graph),
created_by=account.id,
environment_variables=[],
conversation_variables=[],
)
db.session.add(workflow)
db.session.flush()
else:
# Update existing draft workflow
workflow.graph = json.dumps(graph)
workflow.type = WorkflowType.WORKFLOW.value
workflow.kind = WorkflowKind.SNIPPET
workflow.updated_by = account.id
workflow.updated_at = datetime.now(UTC).replace(tzinfo=None)
workflow.environment_variables = []
workflow.conversation_variables = []
# Update snippet's input_fields if provided
if input_fields is not None:
snippet.input_fields = json.dumps(input_fields)
snippet.updated_by = account.id
snippet.updated_at = datetime.now(UTC).replace(tzinfo=None)
db.session.commit()
return workflow
def restore_published_workflow_to_draft(
self,
*,
snippet: CustomizedSnippet,
workflow_id: str,
account: Account,
) -> Workflow:
"""
Restore a published snippet workflow snapshot into the draft workflow.
:param snippet: CustomizedSnippet instance
:param workflow_id: Published workflow ID
:param account: Account making the change
:return: Restored draft Workflow
:raises WorkflowNotFoundError: If the source workflow does not exist
:raises IsDraftWorkflowError: If the source workflow is a draft
:raises ValueError: If the restored graph is invalid for snippets
"""
source_workflow = self.get_published_workflow_by_id(snippet=snippet, workflow_id=workflow_id)
if not source_workflow:
raise WorkflowNotFoundError("Workflow not found.")
SnippetService.validate_snippet_graph_forbidden_nodes(source_workflow.graph_dict)
draft_workflow = self.get_draft_workflow(snippet=snippet)
draft_workflow, is_new_draft = apply_published_workflow_snapshot_to_draft(
tenant_id=snippet.tenant_id,
app_id=snippet.id,
source_workflow=source_workflow,
draft_workflow=draft_workflow,
account=account,
updated_at_factory=lambda: datetime.now(UTC).replace(tzinfo=None),
)
if is_new_draft:
db.session.add(draft_workflow)
db.session.commit()
return draft_workflow
def publish_workflow(
self,
*,
session: Session,
snippet: CustomizedSnippet,
account: Account,
) -> Workflow:
"""
Publish the draft workflow as a new version.
:param session: Database session
:param snippet: CustomizedSnippet instance
:param account: Account making the change
:return: Published Workflow
:raises ValueError: If no draft workflow exists
"""
draft_workflow_stmt = select(Workflow).where(
Workflow.tenant_id == snippet.tenant_id,
Workflow.app_id == snippet.id,
self._snippet_kind_filter(),
Workflow.version == "draft",
)
draft_workflow = session.scalar(draft_workflow_stmt)
if not draft_workflow:
raise ValueError("No valid workflow found.")
SnippetService.validate_snippet_graph_forbidden_nodes(draft_workflow.graph_dict)
# Create new published workflow
workflow = Workflow.new(
tenant_id=snippet.tenant_id,
app_id=snippet.id,
type=WorkflowType.WORKFLOW.value,
version=str(datetime.now(UTC).replace(tzinfo=None)),
graph=draft_workflow.graph,
features=draft_workflow.features,
created_by=account.id,
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=draft_workflow.rag_pipeline_variables,
kind=WorkflowKind.SNIPPET.value,
marked_name="",
marked_comment="",
)
session.add(workflow)
# Update snippet version
snippet.version += 1
snippet.is_published = True
snippet.workflow_id = workflow.id
snippet.updated_by = account.id
session.add(snippet)
return workflow
def get_all_published_workflows(
self,
*,
session: Session,
snippet: CustomizedSnippet,
page: int,
limit: int,
) -> tuple[Sequence[Workflow], bool]:
"""
Get all published workflow versions for snippet.
:param session: Database session
:param snippet: CustomizedSnippet instance
:param page: Page number
:param limit: Items per page
:return: Tuple of (workflows list, has_more flag)
"""
if not snippet.workflow_id:
return [], False
stmt = (
select(Workflow)
.where(
Workflow.app_id == snippet.id,
self._snippet_kind_filter(),
Workflow.version != "draft",
)
.order_by(Workflow.version.desc())
.limit(limit + 1)
.offset((page - 1) * limit)
)
workflows = list(session.scalars(stmt).all())
has_more = len(workflows) > limit
if has_more:
workflows = workflows[:-1]
return workflows, has_more
# --- Default Block Configs ---
def get_default_block_configs(self) -> list[dict]:
"""
Get default block configurations for all node types.
:return: List of default configurations
"""
default_block_configs: list[dict[str, Any]] = []
for node_class_mapping in NODE_TYPE_CLASSES_MAPPING.values():
node_class = node_class_mapping[LATEST_VERSION]
default_config = node_class.get_default_config()
if default_config:
default_block_configs.append(dict(default_config))
return default_block_configs
def get_default_block_config(self, node_type: str, filters: dict | None = None) -> Mapping[str, object] | None:
"""
Get default config for specific node type.
:param node_type: Node type string
:param filters: Optional filters
:return: Default configuration or None
"""
node_type_enum = NodeType(node_type)
if node_type_enum not in NODE_TYPE_CLASSES_MAPPING:
return None
node_class = NODE_TYPE_CLASSES_MAPPING[node_type_enum][LATEST_VERSION]
default_config = node_class.get_default_config(filters=filters)
if not default_config:
return None
return default_config
# --- Workflow Run Operations ---
def get_snippet_workflow_runs(
self,
*,
snippet: CustomizedSnippet,
args: dict,
) -> InfiniteScrollPagination:
"""
Get paginated workflow runs for snippet.
:param snippet: CustomizedSnippet instance
:param args: Request arguments (last_id, limit)
:return: InfiniteScrollPagination result
"""
limit = int(args.get("limit", 20))
last_id = args.get("last_id")
triggered_from_values = [
WorkflowRunTriggeredFrom.DEBUGGING,
]
return self._workflow_run_repo.get_paginated_workflow_runs(
tenant_id=snippet.tenant_id,
app_id=snippet.id,
triggered_from=triggered_from_values,
limit=limit,
last_id=last_id,
)
def get_snippet_workflow_run(
self,
*,
snippet: CustomizedSnippet,
run_id: str,
) -> WorkflowRun | None:
"""
Get workflow run details.
:param snippet: CustomizedSnippet instance
:param run_id: Workflow run ID
:return: WorkflowRun or None
"""
return self._workflow_run_repo.get_workflow_run_by_id(
tenant_id=snippet.tenant_id,
app_id=snippet.id,
run_id=run_id,
)
def get_snippet_workflow_run_node_executions(
self,
*,
snippet: CustomizedSnippet,
run_id: str,
) -> Sequence[WorkflowNodeExecutionModel]:
"""
Get workflow run node execution list.
:param snippet: CustomizedSnippet instance
:param run_id: Workflow run ID
:return: List of WorkflowNodeExecutionModel
"""
workflow_run = self.get_snippet_workflow_run(snippet=snippet, run_id=run_id)
if not workflow_run:
return []
node_executions = self._node_execution_service_repo.get_executions_by_workflow_run(
tenant_id=snippet.tenant_id,
app_id=snippet.id,
workflow_run_id=workflow_run.id,
)
return node_executions
# --- Node Execution Operations ---
def get_snippet_node_last_run(
self,
*,
snippet: CustomizedSnippet,
workflow: Workflow,
node_id: str,
) -> WorkflowNodeExecutionModel | None:
"""
Get the most recent execution for a specific node in a snippet workflow.
:param snippet: CustomizedSnippet instance
:param workflow: Workflow instance
:param node_id: Node identifier
:return: WorkflowNodeExecutionModel or None
"""
return self._node_execution_service_repo.get_node_last_execution(
tenant_id=snippet.tenant_id,
app_id=snippet.id,
workflow_id=workflow.id,
node_id=node_id,
)
# --- Use Count ---
@staticmethod
def increment_use_count(
*,
session: Session,
snippet: CustomizedSnippet,
) -> None:
"""
Increment the use_count when snippet is used.
:param session: Database session
:param snippet: CustomizedSnippet instance
"""
snippet.use_count += 1
session.add(snippet)

View File

@ -12,6 +12,7 @@ from extensions.ext_database import db
from models.dataset import Dataset
from models.enums import TagType
from models.model import App, Tag, TagBinding
from models.snippet import CustomizedSnippet
class SaveTagPayload(BaseModel):
@ -159,7 +160,14 @@ class TagService:
@staticmethod
def save_tag_binding(payload: TagBindingCreatePayload):
TagService.check_target_exists(payload.type, payload.target_id)
for tag_id in payload.tag_ids:
valid_tag_ids = db.session.scalars(
select(Tag.id).where(
Tag.id.in_(payload.tag_ids),
Tag.tenant_id == current_user.current_tenant_id,
Tag.type == payload.type,
)
).all()
for tag_id in valid_tag_ids:
tag_binding = db.session.scalar(
select(TagBinding)
.where(TagBinding.tag_id == tag_id, TagBinding.target_id == payload.target_id)
@ -186,6 +194,12 @@ class TagService:
TagBinding.target_id == payload.target_id,
TagBinding.tag_id.in_(payload.tag_ids),
TagBinding.tenant_id == current_user.current_tenant_id,
TagBinding.tag_id.in_(
select(Tag.id).where(
Tag.tenant_id == current_user.current_tenant_id,
Tag.type == payload.type,
)
),
)
),
)
@ -209,5 +223,13 @@ class TagService:
)
if not app:
raise NotFound("App not found")
elif type == "snippet":
snippet = db.session.scalar(
select(CustomizedSnippet)
.where(CustomizedSnippet.tenant_id == current_user.current_tenant_id, CustomizedSnippet.id == target_id)
.limit(1)
)
if not snippet:
raise NotFound("Snippet not found")
else:
raise NotFound("Invalid binding type")

View File

@ -1,7 +1,7 @@
import dataclasses
import json
import logging
from collections.abc import Mapping, Sequence
from collections.abc import Mapping, Sequence, Set
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from enum import StrEnum
@ -271,12 +271,20 @@ class WorkflowDraftVariableService:
)
def list_variables_without_values(
self, app_id: str, page: int, limit: int, user_id: str
self,
app_id: str,
page: int,
limit: int,
user_id: str,
*,
exclude_node_ids: Set[str] | None = None,
) -> WorkflowDraftVariableList:
criteria = [
WorkflowDraftVariable.app_id == app_id,
WorkflowDraftVariable.user_id == user_id,
]
if exclude_node_ids:
criteria.append(WorkflowDraftVariable.node_id.notin_(list(exclude_node_ids)))
total = None
base_stmt = select(WorkflowDraftVariable).where(*criteria)
if page == 1:

View File

@ -0,0 +1,55 @@
from werkzeug.datastructures import MultiDict
from controllers.console.snippets.payloads import SnippetListQuery
from controllers.console.workspace.snippets import _normalize_snippet_list_query_args
def test_snippet_list_query_accepts_comma_separated_tag_ids() -> None:
first = "11111111-1111-1111-1111-111111111111"
second = "22222222-2222-2222-2222-222222222222"
query = SnippetListQuery.model_validate({"tag_ids": f"{first},{second}"})
assert query.tag_ids == [first, second]
def test_snippet_list_query_accepts_creator_id_alias() -> None:
creator_id = "1886f96a-5bf0-42bf-961d-8d2129049076"
query = SnippetListQuery.model_validate({"creator_id": creator_id})
assert query.creators == [creator_id]
def test_normalize_snippet_list_query_accepts_indexed_creator_ids() -> None:
first = "9e8959cf-a67b-4d34-9906-1d687517b248"
second = "1886f96a-5bf0-42bf-961d-8d2129049076"
normalized = _normalize_snippet_list_query_args(
MultiDict(
[
("creator_ids[1]", second),
("creator_ids[0]", first),
("keyword", "search"),
]
)
)
assert normalized == {"keyword": "search", "creators": [first, second]}
def test_normalize_snippet_list_query_accepts_indexed_tag_ids() -> None:
first = "11111111-1111-1111-1111-111111111111"
second = "22222222-2222-2222-2222-222222222222"
normalized = _normalize_snippet_list_query_args(
MultiDict(
[
("tag_ids[1]", second),
("tag_ids[0]", first),
("keyword", "search"),
]
)
)
assert normalized == {"keyword": "search", "tag_ids": [first, second]}

View File

@ -0,0 +1,136 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
import pytest
from werkzeug.exceptions import HTTPException, NotFound
from controllers.console.snippets import snippet_workflow as snippet_workflow_module
def _unwrap(func):
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
return func
def test_restore_published_snippet_workflow_to_draft_success(
app, monkeypatch: pytest.MonkeyPatch
) -> None:
workflow = SimpleNamespace(
unique_hash="restored-hash",
updated_at=None,
created_at=datetime(2024, 1, 1),
)
user = SimpleNamespace(id="account-1")
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
monkeypatch.setattr(
snippet_workflow_module,
"SnippetService",
lambda: SimpleNamespace(restore_published_workflow_to_draft=lambda **_kwargs: workflow),
)
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
handler = _unwrap(api.post)
with app.test_request_context(
"/snippets/snippet-1/workflows/published-workflow/restore",
method="POST",
):
response = handler(api, snippet=snippet, workflow_id="published-workflow")
assert response["result"] == "success"
assert response["hash"] == "restored-hash"
def test_restore_published_snippet_workflow_to_draft_not_found(
app, monkeypatch: pytest.MonkeyPatch
) -> None:
user = SimpleNamespace(id="account-1")
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
monkeypatch.setattr(
snippet_workflow_module,
"SnippetService",
lambda: SimpleNamespace(
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
snippet_workflow_module.WorkflowNotFoundError("Workflow not found")
)
),
)
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
handler = _unwrap(api.post)
with app.test_request_context(
"/snippets/snippet-1/workflows/published-workflow/restore",
method="POST",
):
with pytest.raises(NotFound):
handler(api, snippet=snippet, workflow_id="published-workflow")
def test_restore_published_snippet_workflow_to_draft_returns_400_for_draft_source(
app, monkeypatch: pytest.MonkeyPatch
) -> None:
user = SimpleNamespace(id="account-1")
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
monkeypatch.setattr(
snippet_workflow_module,
"SnippetService",
lambda: SimpleNamespace(
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
snippet_workflow_module.IsDraftWorkflowError("source workflow must be published")
)
),
)
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
handler = _unwrap(api.post)
with app.test_request_context(
"/snippets/snippet-1/workflows/draft-workflow/restore",
method="POST",
):
with pytest.raises(HTTPException) as exc:
handler(api, snippet=snippet, workflow_id="draft-workflow")
assert exc.value.code == 400
assert exc.value.description == snippet_workflow_module.RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE
def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_graph(
app, monkeypatch: pytest.MonkeyPatch
) -> None:
user = SimpleNamespace(id="account-1")
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
monkeypatch.setattr(
snippet_workflow_module,
"SnippetService",
lambda: SimpleNamespace(
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
ValueError("invalid snippet workflow graph")
)
),
)
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
handler = _unwrap(api.post)
with app.test_request_context(
"/snippets/snippet-1/workflows/published-workflow/restore",
method="POST",
):
with pytest.raises(HTTPException) as exc:
handler(api, snippet=snippet, workflow_id="published-workflow")
assert exc.value.code == 400
assert exc.value.description == "invalid snippet workflow graph"

View File

@ -105,6 +105,34 @@ class TestTagListApi:
assert status == 200
assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}]
def test_get_snippet_tags(self, app: Flask):
api = TagListApi()
method = unwrap(api.get)
with app.test_request_context("/?type=snippet"):
with (
patch(
"controllers.console.tag.tags.current_account_with_tenant",
return_value=(MagicMock(), "tenant-1"),
),
patch(
"controllers.console.tag.tags.TagService.get_tags",
return_value=[
SimpleNamespace(
id="1",
name="snippet-tag",
type=TagType.SNIPPET,
binding_count=1,
)
],
) as get_tags_mock,
):
result, status = method(api)
get_tags_mock.assert_called_once_with("snippet", "tenant-1", None)
assert status == 200
assert result == [{"id": "1", "name": "snippet-tag", "type": "snippet", "binding_count": "1"}]
def test_post_success(self, app: Flask, admin_user, tag, payload_patch):
api = TagListApi()
method = unwrap(api.post)
@ -215,6 +243,34 @@ class TestTagBindingCollectionApi:
assert status == 200
assert result["result"] == "success"
def test_create_snippet_binding_success(self, app: Flask, admin_user, payload_patch):
api = TagBindingCollectionApi()
method = unwrap(api.post)
payload = {
"tag_ids": ["tag-1"],
"target_id": "snippet-1",
"type": "snippet",
}
with app.test_request_context("/", json=payload):
with (
patch(
"controllers.console.tag.tags.current_account_with_tenant",
return_value=(admin_user, None),
),
payload_patch(payload),
patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock,
):
result, status = method(api)
save_mock.assert_called_once()
binding_payload = save_mock.call_args.args[0]
assert binding_payload.type == TagType.SNIPPET
assert binding_payload.target_id == "snippet-1"
assert status == 200
assert result["result"] == "success"
def test_create_forbidden(self, app: Flask, readonly_user, payload_patch):
api = TagBindingCollectionApi()
method = unwrap(api.post)

View File

@ -0,0 +1,40 @@
from types import SimpleNamespace
from unittest.mock import Mock
from controllers.console.workspace import snippets as snippets_module
def _unwrap(func):
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
return func
def test_create_snippet_rejects_forbidden_nodes(app, monkeypatch):
user = SimpleNamespace(id="account-1")
create_snippet = Mock()
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
monkeypatch.setattr(snippets_module.SnippetService, "create_snippet", create_snippet)
api = snippets_module.CustomizedSnippetsApi()
handler = _unwrap(api.post)
with app.test_request_context(
"/workspaces/current/customized-snippets",
method="POST",
json={
"name": "snippet with invalid node",
"type": "node",
"graph": {
"nodes": [
{"id": "knowledge-1", "data": {"type": "knowledge-retrieval"}},
],
"edges": [],
},
},
):
response, status_code = handler(api)
assert status_code == 400
assert "knowledge-retrieval" in response["message"]
create_snippet.assert_not_called()

View File

@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from werkzeug.exceptions import BadRequest
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
from controllers.web.error import AppUnavailableError
@ -16,13 +17,20 @@ from controllers.web.error import AppUnavailableError
# AppParameterApi
# ---------------------------------------------------------------------------
class TestAppParameterApi:
def test_disabled_web_app_raises(self, app: Flask) -> None:
app_model = SimpleNamespace(mode="chat", enable_site=False)
with app.test_request_context("/parameters"):
with pytest.raises(BadRequest, match="Site is disabled"):
AppParameterApi().get(app_model, SimpleNamespace())
def test_advanced_chat_mode_uses_workflow(self, app: Flask) -> None:
features_dict = {"opening_statement": "Hello"}
workflow = SimpleNamespace(
features_dict=features_dict,
user_input_form=lambda to_old_structure=False: [],
)
app_model = SimpleNamespace(mode="advanced-chat", workflow=workflow)
app_model = SimpleNamespace(mode="advanced-chat", workflow=workflow, enable_site=True)
with (
app.test_request_context("/parameters"),
@ -41,7 +49,7 @@ class TestAppParameterApi:
features_dict=features_dict,
user_input_form=lambda to_old_structure=False: [{"var": "x"}],
)
app_model = SimpleNamespace(mode="workflow", workflow=workflow)
app_model = SimpleNamespace(mode="workflow", workflow=workflow, enable_site=True)
with (
app.test_request_context("/parameters"),
@ -54,14 +62,14 @@ class TestAppParameterApi:
mock_params.assert_called_once_with(features_dict=features_dict, user_input_form=[{"var": "x"}])
def test_advanced_chat_mode_no_workflow_raises(self, app: Flask) -> None:
app_model = SimpleNamespace(mode="advanced-chat", workflow=None)
app_model = SimpleNamespace(mode="advanced-chat", workflow=None, enable_site=True)
with app.test_request_context("/parameters"):
with pytest.raises(AppUnavailableError):
AppParameterApi().get(app_model, SimpleNamespace())
def test_standard_mode_uses_app_model_config(self, app: Flask) -> None:
config = SimpleNamespace(to_dict=lambda: {"user_input_form": [{"var": "y"}], "key": "val"})
app_model = SimpleNamespace(mode="chat", app_model_config=config)
app_model = SimpleNamespace(mode="chat", app_model_config=config, enable_site=True)
with (
app.test_request_context("/parameters"),
@ -75,7 +83,7 @@ class TestAppParameterApi:
assert call_kwargs.kwargs["user_input_form"] == [{"var": "y"}]
def test_standard_mode_no_config_raises(self, app: Flask) -> None:
app_model = SimpleNamespace(mode="chat", app_model_config=None)
app_model = SimpleNamespace(mode="chat", app_model_config=None, enable_site=True)
with app.test_request_context("/parameters"):
with pytest.raises(AppUnavailableError):
AppParameterApi().get(app_model, SimpleNamespace())

View File

@ -129,3 +129,34 @@ def test_builtin_tool_summary_short_and_long_content_paths():
assert result
assert "S" in result
def test_builtin_tool_summary_does_not_duplicate_lines_when_merging_chunks():
"""Each line must be placed into exactly one summarization chunk.
The chunk-merge loop used two adjacent, non-mutually-exclusive ``if`` blocks, so a
line that fit the character budget was concatenated onto the current chunk AND then
appended a second time. That duplicated content in the text sent to the model.
"""
tool = _build_tool()
captured_chunks: list[str] = []
def _record_invoke(user_id, prompt_messages, stop):
captured_chunks.append(prompt_messages[-1].content)
return SimpleNamespace(message=SimpleNamespace(content="S"))
content = "\n".join(["a" * 20, "b" * 20, "c" * 20])
with patch.object(_BuiltinDummyTool, "get_max_tokens", return_value=100):
with patch.object(
_BuiltinDummyTool,
"get_prompt_tokens",
side_effect=lambda prompt_messages: len(prompt_messages[-1].content),
):
with patch.object(_BuiltinDummyTool, "invoke_model", side_effect=_record_invoke):
tool.summary(user_id="u1", content=content)
combined = "".join(captured_chunks)
for line in ("a" * 20, "b" * 20, "c" * 20):
assert combined.count(line) == 1, f"line was sent to the model {combined.count(line)} times, expected 1"

View File

@ -0,0 +1,23 @@
from core.workflow.snippet_start import (
LEGACY_START_NODE_ID,
SNIPPET_VIRTUAL_START_NODE_ID,
get_compatible_start_aliases,
)
def test_get_compatible_start_aliases_returns_legacy_start_for_snippet_virtual_start() -> None:
aliases = get_compatible_start_aliases(
workflow_kind="snippet",
root_node_id=SNIPPET_VIRTUAL_START_NODE_ID,
)
assert aliases == (LEGACY_START_NODE_ID,)
def test_get_compatible_start_aliases_returns_empty_for_non_snippet_roots() -> None:
aliases = get_compatible_start_aliases(
workflow_kind="workflow",
root_node_id=SNIPPET_VIRTUAL_START_NODE_ID,
)
assert aliases == ()

View File

@ -5,7 +5,7 @@ from collections import defaultdict
import pytest
from core.workflow.system_variables import build_system_variables, system_variables_to_mapping
from core.workflow.variable_pool_initializer import add_variables_to_pool
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
from core.workflow.variable_prefixes import (
CONVERSATION_VARIABLE_NODE_ID,
ENVIRONMENT_VARIABLE_NODE_ID,
@ -80,6 +80,25 @@ def test_get_file_attribute(pool, file):
assert result is None
def test_add_node_inputs_to_pool_stores_inputs_under_aliases():
pool = VariablePool()
add_node_inputs_to_pool(
pool,
node_id="__snippet_virtual_start__",
inputs={"query": "hello"},
aliases=("start", "__snippet_virtual_start__"),
)
primary_value = pool.get(["__snippet_virtual_start__", "query"])
alias_value = pool.get(["start", "query"])
assert primary_value is not None
assert primary_value.value == "hello"
assert alias_value is not None
assert alias_value.value == "hello"
class TestVariablePool:
def test_constructor(self):
pool = VariablePool()

View File

@ -0,0 +1,28 @@
from types import SimpleNamespace
from flask_restx import marshal
from fields.snippet_fields import snippet_list_fields
def test_snippet_list_fields_include_author_name() -> None:
snippet = SimpleNamespace(
id="snippet-1",
name="Snippet",
description="Reusable node",
type="node",
version=1,
use_count=0,
is_published=False,
icon_info=None,
tags=[],
created_by="account-1",
author_name="Alice",
created_at=None,
updated_by="account-1",
updated_at=None,
)
result = marshal(snippet, snippet_list_fields)
assert result["author_name"] == "Alice"

View File

@ -0,0 +1,46 @@
from types import SimpleNamespace
from unittest.mock import Mock
from services.snippet_dsl_service import ImportStatus, SnippetDslService, SnippetPendingData
def test_confirm_import_creates_snippet_from_pending_data(monkeypatch):
service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None)))
account = SimpleNamespace(id="account-1", current_tenant_id="tenant-1")
snippet = SimpleNamespace(id="snippet-new")
yaml_content = """
version: 9.0.0
kind: snippet
snippet:
name: From DSL
type: node
workflow:
graph:
nodes: []
edges: []
"""
pending = SnippetPendingData(
import_mode="yaml-content",
yaml_content=yaml_content,
name="Override name",
description="Override description",
snippet_id=None,
)
create_or_update = Mock(return_value=snippet)
monkeypatch.setattr(service, "_create_or_update_snippet", create_or_update)
monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json()))
redis_delete = Mock()
monkeypatch.setattr("services.snippet_dsl_service.redis_client.delete", redis_delete)
result = service.confirm_import(import_id="import-1", account=account)
assert result.status == ImportStatus.COMPLETED
assert result.snippet_id == "snippet-new"
assert result.imported_dsl_version == "9.0.0"
create_or_update.assert_called_once()
_, kwargs = create_or_update.call_args
assert kwargs["snippet"] is None
assert kwargs["account"] is account
assert kwargs["name"] == "Override name"
assert kwargs["description"] == "Override description"
redis_delete.assert_called_once_with("snippet_import_info:import-1")

View File

@ -0,0 +1,149 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from models.snippet import SnippetType
from models.workflow import Workflow, WorkflowKind, WorkflowType
from services.errors.app import WorkflowNotFoundError
from services.snippet_service import SnippetService
class _SessionWithoutNameLookup:
def __init__(self) -> None:
self.add = Mock()
self.commit = Mock()
def query(self, *args, **kwargs):
raise AssertionError("snippet name uniqueness lookup should not be used")
def _create_workflow(*, workflow_id: str, version: str, graph: dict, features: dict) -> Workflow:
return Workflow(
id=workflow_id,
tenant_id="tenant-1",
app_id="snippet-1",
type=WorkflowType.WORKFLOW.value,
kind=WorkflowKind.SNIPPET.value,
version=version,
graph=json.dumps(graph),
features=json.dumps(features),
created_by="account-1",
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
def test_create_snippet_allows_duplicate_names(monkeypatch: pytest.MonkeyPatch) -> None:
session = _SessionWithoutNameLookup()
account = SimpleNamespace(id="account-1")
monkeypatch.setattr("services.snippet_service.db.session", session)
snippet = SnippetService.create_snippet(
tenant_id="tenant-1",
name="shared name",
description=None,
snippet_type=SnippetType.NODE,
icon_info=None,
input_fields=None,
account=account,
)
assert snippet.name == "shared name"
session.add.assert_called_once_with(snippet)
session.commit.assert_called_once()
def test_update_snippet_allows_duplicate_names() -> None:
session = _SessionWithoutNameLookup()
snippet = SimpleNamespace(
id="snippet-1",
tenant_id="tenant-1",
name="old name",
description="",
icon_info=None,
)
result = SnippetService.update_snippet(
session=session,
snippet=snippet,
account_id="account-1",
data={"name": "shared name"},
)
assert result is snippet
assert snippet.name == "shared name"
session.add.assert_called_once_with(snippet)
def test_restore_published_snippet_workflow_to_draft_copies_source_snapshot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
account = SimpleNamespace(id="account-2")
source_graph = {"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []}
source_features = {"opening_statement": "hello"}
source_workflow = _create_workflow(
workflow_id="published-workflow",
version="2026-04-28 00:00:00",
graph=source_graph,
features=source_features,
)
draft_workflow = _create_workflow(
workflow_id="draft-workflow",
version=Workflow.VERSION_DRAFT,
graph={"nodes": [], "edges": []},
features={},
)
service = SnippetService.__new__(SnippetService)
session = SimpleNamespace(add=Mock(), commit=Mock())
monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=source_workflow))
monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=draft_workflow))
monkeypatch.setattr("services.snippet_service.db.session", session)
result = service.restore_published_workflow_to_draft(
snippet=snippet,
workflow_id=source_workflow.id,
account=account,
)
assert result is draft_workflow
assert draft_workflow.graph_dict == source_graph
assert draft_workflow.features_dict == source_features
assert draft_workflow.updated_by == account.id
session.add.assert_not_called()
session.commit.assert_called_once()
def test_restore_published_snippet_workflow_to_draft_raises_when_source_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
account = SimpleNamespace(id="account-2")
service = SnippetService.__new__(SnippetService)
monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=None))
with pytest.raises(WorkflowNotFoundError):
service.restore_published_workflow_to_draft(
snippet=snippet,
workflow_id="missing-workflow",
account=account,
)
def test_delete_snippet_removes_tag_bindings() -> None:
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
session = SimpleNamespace(execute=Mock(), delete=Mock())
result = SnippetService.delete_snippet(session=session, snippet=snippet)
assert result is True
session.execute.assert_called_once()
session.delete.assert_called_once_with(snippet)

6
api/uv.lock generated
View File

@ -6284,15 +6284,15 @@ wheels = [
[[package]]
name = "starlette"
version = "1.0.1"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" }
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]

View File

@ -225,6 +225,7 @@ OPENAPI_ENABLED=false
OPENAPI_CORS_ALLOW_ORIGINS=
OPENAPI_KNOWN_CLIENT_IDS=difyctl
OPENAPI_RATE_LIMIT_PER_TOKEN=60
DEVICE_FLOW_APPROVE_RATE_LIMIT_PER_HOUR=10
ENABLE_OAUTH_BEARER=false
DSL_EXPORT_ENCRYPT_DATASET_ID=true
DATASET_MAX_SEGMENTS_PER_REQUEST=0

View File

@ -145,6 +145,11 @@
"count": 1
}
},
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(shareLayout)/components/splash.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -250,6 +255,11 @@
"count": 1
}
},
"web/app/components/app-sidebar/nav-link/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -3152,6 +3162,16 @@
"count": 2
}
},
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/snippets/hooks/use-snippet-run.ts": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/signin/countdown.tsx": {
"no-restricted-globals": {
"count": 4
@ -3327,6 +3347,11 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/blocks.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/hooks.ts": {
"react/set-state-in-effect": {
"count": 1
@ -5164,6 +5189,11 @@
"count": 1
}
},
"web/service/__tests__/use-snippet-workflows.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control.ts": {
"@tanstack/query/exhaustive-deps": {
"count": 1
@ -5431,6 +5461,11 @@
"count": 3
}
},
"web/service/use-snippet-workflows.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/use-tools.ts": {
"no-restricted-imports": {
"count": 1

View File

@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 2.25C3.41421 2.25 3.75 2.58579 3.75 3V15C3.75 15.4142 3.41421 15.75 3 15.75C2.58579 15.75 2.25 15.4142 2.25 15V3C2.25 2.58579 2.58579 2.25 3 2.25Z" fill="#676F83"/>
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3V15C15.75 15.4142 15.4142 15.75 15 15.75C14.5858 15.75 14.25 15.4142 14.25 15V3C14.25 2.58579 14.5858 2.25 15 2.25Z" fill="#676F83"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 4.5C10.5392 4.5 10.875 4.83579 10.875 5.25V12.75C10.875 13.1642 10.5392 13.5 10.125 13.5H7.875C7.46079 13.5 7.125 13.1642 7.125 12.75V5.25C7.125 4.83579 7.46079 4.5 7.875 4.5H10.125ZM8.625 12H9.375V6H8.625V12Z" fill="#676F83"/>
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 14.25C15.4142 14.25 15.75 14.5858 15.75 15C15.75 15.4142 15.4142 15.75 15 15.75H3C2.58579 15.75 2.25 15.4142 2.25 15C2.25 14.5858 2.58579 14.25 3 14.25H15Z" fill="#676F83"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 7.125C13.9142 7.125 14.25 7.46079 14.25 7.875V10.125C14.25 10.5392 13.9142 10.875 13.5 10.875H4.5C4.08579 10.875 3.75 10.5392 3.75 10.125V7.875C3.75 7.46079 4.08579 7.125 4.5 7.125H13.5ZM5.25 9.375H12.75V8.625H5.25V9.375Z" fill="#676F83"/>
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75H3C2.58579 3.75 2.25 3.41421 2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15Z" fill="#676F83"/>
</svg>

After

Width:  |  Height:  |  Size: 763 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 15V3.75V12.2625V10.6688V15ZM2.5 16.5C1.94772 16.5 1.5 16.0523 1.5 15.5V3.25C1.5 2.69771 1.94772 2.25 2.5 2.25H15.5C16.0523 2.25 16.5 2.69772 16.5 3.25V10.5H15V3.75H3V15H9V16.5H2.5ZM13.0125 17.25L10.35 14.5875L11.4188 13.5375L13.0125 15.1312L16.2 11.9438L17.25 13.0125L13.0125 17.25ZM7.5 9.75H13.5V8.25H7.5V9.75ZM7.5 6.75H13.5V5.25H7.5V6.75ZM4.5 9.75H6V8.25H4.5V9.75ZM4.5 6.75H6V5.25H4.5V6.75Z" fill="#495464"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

View File

@ -513,12 +513,27 @@
"width": 14,
"height": 14
},
"line-others-dhs": {
"body": "<g fill=\"currentColor\"><path d=\"M3 2.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3A.75.75 0 0 1 3 2.25m12 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M10.125 4.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75zm-1.5 7.5h.75V6h-.75z\" clip-rule=\"evenodd\"/></g>",
"width": 18,
"height": 18
},
"line-others-drag-handle": {
"body": "<g fill=\"none\"><g id=\"Drag Handle\"><path id=\"drag-handle\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z\" fill=\"currentColor\"/></g></g>"
},
"line-others-dvs": {
"body": "<g fill=\"currentColor\"><path d=\"M15 14.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/><path fill-rule=\"evenodd\" d=\"M13.5 7.125a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.75.75h-9a.75.75 0 0 1-.75-.75v-2.25a.75.75 0 0 1 .75-.75zm-8.25 2.25h7.5v-.75h-7.5z\" clip-rule=\"evenodd\"/><path d=\"M15 2.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/></g>",
"width": 18,
"height": 18
},
"line-others-env": {
"body": "<g fill=\"none\"><g id=\"env\"><g id=\"Vector\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 3.33325C1.33325 2.22868 2.22868 1.33325 3.33325 1.33325H12.6666C13.7712 1.33325 14.6666 2.22869 14.6666 3.33325V3.66659C14.6666 4.03478 14.3681 4.33325 13.9999 4.33325C13.6317 4.33325 13.3333 4.03478 13.3333 3.66659V3.33325C13.3333 2.96506 13.0348 2.66659 12.6666 2.66659H3.33325C2.96506 2.66659 2.66659 2.96506 2.66659 3.33325V3.66659C2.66659 4.03478 2.36811 4.33325 1.99992 4.33325C1.63173 4.33325 1.33325 4.03478 1.33325 3.66659V3.33325Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.6666 12.6666C14.6666 13.7712 13.7712 14.6666 12.6666 14.6666L3.33325 14.6666C2.22866 14.6666 1.33325 13.7711 1.33325 12.6666L1.33325 12.3333C1.33325 11.9651 1.63173 11.6666 1.99992 11.6666C2.36811 11.6666 2.66659 11.9651 2.66659 12.3333V12.6666C2.66659 13.0348 2.96505 13.3333 3.33325 13.3333L12.6666 13.3333C13.0348 13.3333 13.3333 13.0348 13.3333 12.6666V12.3333C13.3333 11.9651 13.6317 11.6666 13.9999 11.6666C14.3681 11.6666 14.6666 11.9651 14.6666 12.3333V12.6666Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 5.99992C1.33325 5.63173 1.63173 5.33325 1.99992 5.33325H4.33325C4.70144 5.33325 4.99992 5.63173 4.99992 5.99992C4.99992 6.36811 4.70144 6.66658 4.33325 6.66658H2.66659V7.33325H3.99992C4.36811 7.33325 4.66659 7.63173 4.66659 7.99992C4.66659 8.36811 4.36811 8.66658 3.99992 8.66658H2.66659V9.33325H4.33325C4.70144 9.33325 4.99992 9.63173 4.99992 9.99992C4.99992 10.3681 4.70144 10.6666 4.33325 10.6666H1.99992C1.63173 10.6666 1.33325 10.3681 1.33325 9.99992V5.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.4734 5.36186C6.75457 5.27673 7.05833 5.38568 7.22129 5.63012L8.66659 7.79807V5.99992C8.66659 5.63173 8.96506 5.33325 9.33325 5.33325C9.70144 5.33325 9.99992 5.63173 9.99992 5.99992V9.99992C9.99992 10.2937 9.80761 10.5528 9.52644 10.638C9.24527 10.7231 8.94151 10.6142 8.77855 10.3697L7.33325 8.20177V9.99992C7.33325 10.3681 7.03478 10.6666 6.66659 10.6666C6.2984 10.6666 5.99992 10.3681 5.99992 9.99992V5.99992C5.99992 5.70614 6.19222 5.44699 6.4734 5.36186Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.0768 5.38453C11.4167 5.24292 11.807 5.40364 11.9486 5.74351L12.9999 8.26658L14.0512 5.74351C14.1928 5.40364 14.5831 5.24292 14.923 5.38453C15.2629 5.52614 15.4236 5.91646 15.282 6.25633L13.6153 10.2563C13.5118 10.5048 13.2691 10.6666 12.9999 10.6666C12.7308 10.6666 12.488 10.5048 12.3845 10.2563L10.7179 6.25633C10.5763 5.91646 10.737 5.52614 11.0768 5.38453Z\" fill=\"currentColor\"/></g></g></g>"
},
"line-others-evaluation": {
"body": "<path fill=\"currentColor\" d=\"M3 15V3.75v8.513v-1.594zm-.5 1.5a1 1 0 0 1-1-1V3.25a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7.25H15V3.75H3V15h6v1.5zm10.513.75l-2.663-2.662l1.069-1.05l1.593 1.593l3.188-3.187l1.05 1.068zM7.5 9.75h6v-1.5h-6zm0-3h6v-1.5h-6zm-3 3H6v-1.5H4.5zm0-3H6v-1.5H4.5z\"/>",
"width": 18,
"height": 18
},
"line-others-global-variable": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.23814 1.33333H9.76188C10.4844 1.33332 11.0672 1.33332 11.5391 1.37187C12.025 1.41157 12.4518 1.49545 12.8466 1.69664C13.4739 2.01622 13.9838 2.52615 14.3034 3.15336C14.5046 3.54822 14.5884 3.97501 14.6281 4.46091C14.6667 4.93283 14.6667 5.51559 14.6667 6.23811V9.76188C14.6667 10.4844 14.6667 11.0672 14.6281 11.5391C14.5884 12.025 14.5046 12.4518 14.3034 12.8466C13.9838 13.4738 13.4739 13.9838 12.8466 14.3033C12.4518 14.5045 12.025 14.5884 11.5391 14.6281C11.0672 14.6667 10.4844 14.6667 9.7619 14.6667H6.23812C5.51561 14.6667 4.93284 14.6667 4.46093 14.6281C3.97503 14.5884 3.54824 14.5045 3.15338 14.3033C2.52617 13.9838 2.01623 13.4738 1.69666 12.8466C1.49546 12.4518 1.41159 12.025 1.37189 11.5391C1.33333 11.0672 1.33334 10.4844 1.33334 9.76187V6.23812C1.33334 5.5156 1.33333 4.93283 1.37189 4.46091C1.41159 3.97501 1.49546 3.54822 1.69666 3.15336C2.01623 2.52615 2.52617 2.01622 3.15338 1.69664C3.54824 1.49545 3.97503 1.41157 4.46093 1.37187C4.93285 1.33332 5.51561 1.33332 6.23814 1.33333ZM4.5695 2.70078C4.16606 2.73374 3.93427 2.79519 3.7587 2.88465C3.38237 3.0764 3.07641 3.38236 2.88466 3.75868C2.79521 3.93425 2.73376 4.16604 2.70079 4.56949C2.6672 4.98072 2.66668 5.50892 2.66668 6.26666V9.73333C2.66668 10.4911 2.6672 11.0193 2.70079 11.4305C2.73376 11.8339 2.79521 12.0657 2.88466 12.2413C3.07641 12.6176 3.38237 12.9236 3.7587 13.1153C3.93427 13.2048 4.16606 13.2662 4.5695 13.2992C4.98073 13.3328 5.50894 13.3333 6.26668 13.3333H9.73334C10.4911 13.3333 11.0193 13.3328 11.4305 13.2992C11.834 13.2662 12.0658 13.2048 12.2413 13.1153C12.6176 12.9236 12.9236 12.6176 13.1154 12.2413C13.2048 12.0657 13.2663 11.8339 13.2992 11.4305C13.3328 11.0193 13.3333 10.4911 13.3333 9.73333V6.26666C13.3333 5.50892 13.3328 4.98072 13.2992 4.56949C13.2663 4.16604 13.2048 3.93425 13.1154 3.75868C12.9236 3.38236 12.6176 3.0764 12.2413 2.88465C12.0658 2.79519 11.834 2.73374 11.4305 2.70078C11.0193 2.66718 10.4911 2.66666 9.73334 2.66666H6.26668C5.50894 2.66666 4.98073 2.66718 4.5695 2.70078ZM5.08339 5.33333C5.08339 4.96514 5.38187 4.66666 5.75006 4.66666H6.68433C7.324 4.66666 7.87606 5.09677 8.04724 5.70542L8.30138 6.60902L9.2915 5.43554C9.7018 4.94926 10.3035 4.66666 10.9399 4.66666H11C11.3682 4.66666 11.6667 4.96514 11.6667 5.33333C11.6667 5.70152 11.3682 5.99999 11 5.99999H10.9399C10.7005 5.99999 10.4702 6.10616 10.3106 6.29537L8.73751 8.15972L9.23641 9.93357C9.24921 9.97909 9.28574 10 9.31579 10H10.2501C10.6182 10 10.9167 10.2985 10.9167 10.6667C10.9167 11.0349 10.6182 11.3333 10.2501 11.3333H9.31579C8.67612 11.3333 8.12406 10.9032 7.95288 10.2946L7.69871 9.39088L6.70852 10.5644C6.29822 11.0507 5.6965 11.3333 5.06011 11.3333H5.00001C4.63182 11.3333 4.33334 11.0349 4.33334 10.6667C4.33334 10.2985 4.63182 10 5.00001 10H5.06011C5.29949 10 5.52982 9.89383 5.68946 9.70462L7.26258 7.84019L6.76371 6.06642C6.75091 6.0209 6.71438 5.99999 6.68433 5.99999H5.75006C5.38187 5.99999 5.08339 5.70152 5.08339 5.33333Z\" fill=\"currentColor\"/></g>"
},
@ -1025,6 +1040,11 @@
"workflow-if-else": {
"body": "<g fill=\"none\"><g id=\"icons/if-else\"><path id=\"Vector (Stroke)\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.16667 2.98975C7.80423 2.98975 7.51042 2.69593 7.51042 2.3335C7.51042 1.97106 7.80423 1.67725 8.16667 1.67725H11.0833C11.4458 1.67725 11.7396 1.97106 11.7396 2.3335V5.25016C11.7396 5.6126 11.4458 5.90641 11.0833 5.90641C10.7209 5.90641 10.4271 5.6126 10.4271 5.25016V3.91782L7.34474 7.00016L10.4271 10.0825V8.75016C10.4271 8.38773 10.7209 8.09391 11.0833 8.09391C11.4458 8.09391 11.7396 8.38773 11.7396 8.75016V11.6668C11.7396 12.0293 11.4458 12.3231 11.0833 12.3231H8.16667C7.80423 12.3231 7.51042 12.0293 7.51042 11.6668C7.51042 11.3044 7.80423 11.0106 8.16667 11.0106H9.49901L6.14484 7.65641H1.75C1.38756 7.65641 1.09375 7.3626 1.09375 7.00016C1.09375 6.63773 1.38756 6.34391 1.75 6.34391H6.14484L9.49901 2.98975H8.16667Z\" fill=\"currentColor\"/></g></g>"
},
"workflow-input-field": {
"body": "<path fill=\"currentColor\" d=\"M5.333 3.333h2v9.334h-2V14h5.333v-1.333h-2V3.333h2V2H5.333zm-4 1.334a.667.667 0 0 0-.666.666v5.334c0 .368.298.666.666.666h4V10H2V6h3.333V4.667zM10.667 6H14v4h-3.334v1.333h4a.667.667 0 0 0 .667-.666V5.333a.667.667 0 0 0-.667-.666h-4z\"/>",
"width": 16,
"height": 16
},
"workflow-iteration": {
"body": "<g fill=\"none\"><g id=\"icons/iteration\"><path id=\"Vector\" d=\"M6.82849 0.754349C6.6007 0.526545 6.23133 0.526545 6.00354 0.754349C5.77573 0.982158 5.77573 1.3515 6.00354 1.57931L6.82849 0.754349ZM8.16602 2.91683L8.57849 3.32931C8.80628 3.1015 8.80628 2.73216 8.57849 2.50435L8.16602 2.91683ZM6.00354 4.25435C5.77573 4.48216 5.77573 4.8515 6.00354 5.07931C6.23133 5.30711 6.6007 5.30711 6.82849 5.07931L6.00354 4.25435ZM7.99516 9.74597C8.22295 9.51818 8.22295 9.14881 7.99516 8.92102C7.76737 8.69323 7.398 8.69323 7.17021 8.92102L7.99516 9.74597ZM5.83268 11.0835L5.4202 10.671C5.1924 10.8988 5.1924 11.2682 5.4202 11.496L5.83268 11.0835ZM7.17021 13.246C7.398 13.4738 7.76737 13.4738 7.99516 13.246C8.22295 13.0182 8.22295 12.6488 7.99516 12.421L7.17021 13.246ZM11.4993 3.73414C11.2738 3.50404 10.9045 3.5003 10.6744 3.72578C10.4443 3.95127 10.4405 4.32059 10.6661 4.55069L11.4993 3.73414ZM7.58268 3.50016C7.90486 3.50016 8.16602 3.23899 8.16602 2.91683C8.16602 2.59467 7.90486 2.3335 7.58268 2.3335L7.58268 3.50016ZM2.49938 10.2662C2.72486 10.4963 3.09419 10.5 3.32429 10.2745C3.55439 10.0491 3.55814 9.6797 3.33266 9.44964L2.49938 10.2662ZM6.00354 1.57931L7.75354 3.32931L8.57849 2.50435L6.82849 0.754349L6.00354 1.57931ZM7.75354 2.50435L6.00354 4.25435L6.82849 5.07931L8.57849 3.32931L7.75354 2.50435ZM7.17021 8.92102L5.4202 10.671L6.24516 11.496L7.99516 9.74597L7.17021 8.92102ZM5.4202 11.496L7.17021 13.246L7.99516 12.421L6.24516 10.671L5.4202 11.496ZM8.16602 10.5002L6.41602 10.5002V11.6668L8.16602 11.6668V10.5002ZM11.666 7.00016C11.666 8.93316 10.099 10.5002 8.16602 10.5002V11.6668C10.7434 11.6668 12.8327 9.57751 12.8327 7.00016H11.666ZM12.8327 7.00016C12.8327 5.72882 12.3235 4.57524 11.4993 3.73414L10.6661 4.55069C11.2852 5.18256 11.666 6.0463 11.666 7.00016H12.8327ZM5.83268 3.50016H7.58268L7.58268 2.3335H5.83268L5.83268 3.50016ZM2.33268 7.00016C2.33268 5.06717 3.89968 3.50016 5.83268 3.50016L5.83268 2.3335C3.25535 2.3335 1.16602 4.42283 1.16602 7.00016H2.33268ZM1.16602 7.00016C1.16602 8.27148 1.67517 9.42508 2.49938 10.2662L3.33266 9.44964C2.71348 8.81777 2.33268 7.95403 2.33268 7.00016H1.16602Z\" fill=\"currentColor\"/></g></g>"
},

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 277,
"total": 281,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -341,16 +341,11 @@ describe('App List Browsing Flow', () => {
// -- Tab navigation --
describe('Tab Navigation', () => {
it('should render all category tabs', () => {
it('should render the app type dropdown trigger', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
})
})
@ -381,21 +376,19 @@ describe('App List Browsing Flow', () => {
// -- "Created by me" filter --
describe('Created By Me Filter', () => {
it('should render the "created by me" checkbox', () => {
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
})
it('should toggle the "created by me" filter on click', () => {
it('should keep the current layout stable without a "created by me" control', () => {
mockPages = [createPage([createMockApp()])]
renderList()
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
fireEvent.click(checkbox)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
})
})

View File

@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
import { redirect, usePathname } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)

View File

@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} />
}
export default Page

View File

@ -0,0 +1,21 @@
import Page from './page'
const mockRedirect = vi.fn()
vi.mock('next/navigation', () => ({
redirect: (path: string) => mockRedirect(path),
}))
describe('snippet detail redirect page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect legacy snippet detail routes to orchestrate', async () => {
await Page({
params: Promise.resolve({ snippetId: 'snippet-1' }),
})
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
})
})

View File

@ -0,0 +1,11 @@
import { redirect } from 'next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
redirect(`/snippets/${snippetId}/orchestrate`)
}
export default Page

View File

@ -0,0 +1,7 @@
import SnippetList from '@/app/components/snippet-list'
const SnippetsPage = () => {
return <SnippetList />
}
export default SnippetsPage

View File

@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSearchParams } from '@/next/navigation'
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
const { mockSendGAEvent, mockRememberRegistrationSuccess } = vi.hoisted(() => ({
mockSendGAEvent: vi.fn(),
mockTrackEvent: vi.fn(),
mockRememberRegistrationSuccess: vi.fn(),
}))
vi.mock('@/utils/gtag', () => ({
@ -17,8 +17,8 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
vi.mock('../base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
vi.mock('../base/amplitude/registration-tracking', () => ({
rememberRegistrationSuccess: (...args: unknown[]) => mockRememberRegistrationSuccess(...args),
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
@ -48,10 +48,9 @@ describe('OAuthRegistrationAnalytics', () => {
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
})
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
@ -73,8 +72,9 @@ describe('OAuthRegistrationAnalytics', () => {
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
method: 'oauth',
utmInfo: null,
})
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
@ -87,7 +87,7 @@ describe('OAuthRegistrationAnalytics', () => {
it('should do nothing without the oauth registration query flag', () => {
render(<OAuthRegistrationAnalytics />)
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
expect(mockSendGAEvent).not.toHaveBeenCalled()
})
@ -100,7 +100,7 @@ describe('OAuthRegistrationAnalytics', () => {
await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
})
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
expect(mockSendGAEvent).not.toHaveBeenCalled()
})
})

View File

@ -168,6 +168,21 @@ describe('AppDetailNav', () => {
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {

View File

@ -28,12 +28,16 @@ type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
appInfoActions?: AppInfoActions
}
const AppDetailNav = ({
navigation,
extraInfo,
renderHeader,
renderNavigation,
iconType = 'app',
appInfoActions,
}: IAppDetailNavProps) => {
@ -112,18 +116,20 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
appInfoActions
? (
<AppInfoView
expand={expand}
actions={appInfoActions}
renderDetail={false}
/>
)
: <AppInfo expand={expand} />
)}
{iconType !== 'app' && (
{renderHeader
? renderHeader(appSidebarExpand)
: iconType === 'app' && (
appInfoActions
? (
<AppInfoView
expand={expand}
actions={appInfoActions}
renderDetail={false}
/>
)
: <AppInfo expand={expand} />
)}
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@ -152,18 +158,20 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
return (
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
{renderNavigation
? renderNavigation(appSidebarExpand)
: navigation.map((item, index) => {
return (
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
</div>

View File

@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
expect(iconWrapper).toHaveClass('-ml-1')
})
})
describe('Button Mode', () => {
it('should render as an interactive button when href is omitted', () => {
const onClick = vi.fn()
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
const buttonElement = screen.getByText('Orchestrate').closest('button')
expect(buttonElement).not.toBeNull()
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
buttonElement?.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
export type NavLinkProps = {
name: string
href: string
href?: string
iconMap: {
selected: NavIcon
normal: NavIcon
}
mode?: string
disabled?: boolean
active?: boolean
onClick?: () => void
}
const NavLink = ({
@ -29,6 +31,8 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@ -39,8 +43,11 @@ const NavLink = ({
return res
})()
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>
@ -70,13 +77,32 @@ const NavLink = ({
)
}
if (!href) {
return (
<button
key={name}
type="button"
className={linkClassName}
title={mode === 'collapse' ? name : ''}
onClick={onClick}
>
{renderIcon()}
<span
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={cn(isActive
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')}
className={linkClassName}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@ -0,0 +1,270 @@
import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog'
import type { SnippetDetail } from '@/models/snippet'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import SnippetInfoDropdown from '../dropdown'
const mockReplace = vi.fn()
const mockDownloadBlob = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockUpdateMutate = vi.fn()
const mockExportMutateAsync = vi.fn()
const mockDeleteMutate = vi.fn()
let mockDropdownOpen = false
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenu: ({
open,
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) => {
mockDropdownOpen = !!open
mockDropdownOnOpenChange = onOpenChange
return <div>{children}</div>
},
DropdownMenuTrigger: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => (
<button
type="button"
className={className}
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
mockDropdownOpen ? <div>{children}</div> : null
),
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
}))
vi.mock('@/service/use-snippets', () => ({
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
}))
type MockCreateSnippetDialogProps = {
isOpen: boolean
title?: string
confirmText?: string
initialValue?: {
name?: string
description?: string
}
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
default: ({
isOpen,
title,
confirmText,
initialValue,
onClose,
onConfirm,
}: MockCreateSnippetDialogProps) => {
if (!isOpen)
return null
return (
<div data-testid="create-snippet-dialog">
<div>{title}</div>
<div>{confirmText}</div>
<div>{initialValue?.name}</div>
<div>{initialValue?.description}</div>
<button
type="button"
onClick={() => onConfirm({
name: 'Updated snippet',
description: 'Updated description',
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})}
>
submit-edit
</button>
<button type="button" onClick={onClose}>close-edit</button>
</div>
)
},
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
updatedAt: '2026-03-25 10:00',
usage: '12',
tags: [],
status: undefined,
}
describe('SnippetInfoDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDropdownOpen = false
mockDropdownOnOpenChange = undefined
})
// Rendering coverage for the menu trigger itself.
describe('Rendering', () => {
it('should render the dropdown trigger button', () => {
render(<SnippetInfoDropdown snippet={mockSnippet} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// Edit flow should seed the dialog with current snippet info and submit updates.
describe('Edit Snippet', () => {
it('should open the edit dialog and submit snippet updates', async () => {
const user = userEvent.setup()
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.editInfo'))
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
body: {
name: 'Updated snippet',
description: 'Updated description',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
})
})
// Export should call the export hook and download the returned YAML blob.
describe('Export Snippet', () => {
it('should export and download the snippet yaml', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockResolvedValue('yaml: content')
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
})
expect(mockDownloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: `${mockSnippet.name}.yml`,
})
})
it('should show an error toast when export fails', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
})
})
})
// Delete should require confirmation and redirect after a successful mutation.
describe('Delete Snippet', () => {
it('should confirm deletion and redirect to the snippets list', async () => {
const user = userEvent.setup()
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
expect(mockDeleteMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
expect(mockReplace).toHaveBeenCalledWith('/snippets')
})
})
})

View File

@ -0,0 +1,60 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import SnippetInfo from '..'
vi.mock('../dropdown', () => ({
default: () => <div data-testid="snippet-info-dropdown" />,
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
updatedAt: '2026-03-25 10:00',
usage: '12',
tags: [],
status: undefined,
}
describe('SnippetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests for the collapsed and expanded sidebar header states.
describe('Rendering', () => {
it('should render the expanded snippet details and dropdown when expand is true', () => {
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
})
it('should hide the expanded-only content when expand is false', () => {
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
})
})
// Edge cases around optional snippet fields should not break the header layout.
describe('Edge Cases', () => {
it('should omit the description block when the snippet has no description', () => {
render(
<SnippetInfo
expand={true}
snippet={{ ...mockSnippet, description: '' }}
/>,
)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,177 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { downloadBlob } from '@/utils/download'
type SnippetInfoDropdownProps = {
snippet: SnippetDetail
}
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
const { t } = useTranslation('snippet')
const { replace } = useRouter()
const [open, setOpen] = React.useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const initialValue = React.useMemo(() => ({
name: snippet.name,
description: snippet.description,
}), [snippet.description, snippet.name])
const handleOpenEditDialog = React.useCallback(() => {
setOpen(false)
setIsEditDialogOpen(true)
}, [])
const handleExportSnippet = React.useCallback(async () => {
setOpen(false)
try {
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
}
catch {
toast.error(t('exportFailed'))
}
}, [exportSnippetMutation, snippet.id, snippet.name, t])
const handleEditSnippet = React.useCallback(async ({ name, description }: {
name: string
description: string
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
},
}, {
onSuccess: () => {
toast.success(t('editDone'))
setIsEditDialogOpen(false)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('editFailed'))
},
})
}, [snippet.id, t, updateSnippetMutation])
const handleDeleteSnippet = React.useCallback(() => {
deleteSnippetMutation.mutate({
params: { snippetId: snippet.id },
}, {
onSuccess: () => {
toast.success(t('deleted'))
setIsDeleteDialogOpen(false)
replace('/snippets')
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}, [deleteSnippetMutation, replace, snippet.id, t])
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[180px] p-1"
>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
<DropdownMenuItem
className="mx-0 gap-2"
variant="destructive"
onClick={() => {
setOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="grow">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={t('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleEditSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-100">
<div className="space-y-2 p-6">
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default React.memo(SnippetInfoDropdown)

View File

@ -0,0 +1,46 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
if (!expand)
return null
return (
<div className="flex flex-col px-2 pt-2 pb-1">
<div className="flex flex-col gap-2 rounded-xl p-2">
<div className="flex items-center justify-end">
<SnippetInfoDropdown snippet={snippet} />
</div>
<div className="min-w-0">
<div className="truncate system-md-semibold text-text-secondary">
{snippet.name}
</div>
<div className="pt-1 system-2xs-medium-uppercase text-text-tertiary">
{t('typeLabel')}
</div>
</div>
{snippet.description && (
<p className="line-clamp-3 system-xs-regular break-words text-text-tertiary">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@ -1,7 +1,6 @@
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { FileUpload } from '@/app/components/base/features/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type { Features, FileUpload } from '@/app/components/base/features/types'
import type { ModelConfig } from '@/models/debug'
import {
AlertDialog,
AlertDialogActions,
@ -21,9 +20,15 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
type PublishedModelConfig = ModelConfig & {
resetAppConfig?: () => void
}
type Props = Omit<AppPublisherProps, 'onPublish'> & {
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
publishedConfig?: any
onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown
publishedConfig: {
modelConfig: PublishedModelConfig
}
resetAppConfig?: () => void
}
@ -71,7 +76,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
setRestoreConfirmOpen(false)
}, [featuresStore, props])
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
const handlePublish = useCallback((params?: AppPublisherPublishParams) => {
return props.onPublish?.(params, features)
}, [features, props])

View File

@ -85,8 +85,10 @@ export type AppPublisherProps = {
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
type AppPublisherPublishHandler
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
= | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown)
| ((params?: unknown) => Promise<unknown> | unknown)
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown

View File

@ -211,6 +211,12 @@ describe('ConfigModalFormFields', () => {
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
textInputView.unmount()
const hiddenFieldDisabledProps = createBaseProps()
const hiddenFieldDisabledView = render(<ConfigModalFormFields {...hiddenFieldDisabledProps} showHiddenField={false} />)
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
hiddenFieldDisabledView.unmount()
const singleFileProps = createBaseProps()
singleFileProps.tempPayload = {
...singleFileProps.tempPayload,

View File

@ -49,6 +49,7 @@ type ConfigModalFormFieldsProps = {
onVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
options?: string[]
selectOptions: SelectOptionItem[]
showHiddenField?: boolean
tempPayload: InputVar
t: Translate
}
@ -67,6 +68,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
onVarNameChange,
options,
selectOptions,
showHiddenField = true,
tempPayload,
t,
}) => {
@ -242,7 +244,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
</label>
{!isFileInput && (
{showHiddenField && !isFileInput && (
<div className="mt-5! flex h-6 items-center gap-2">
<label className="flex items-center gap-2">
<Checkbox

View File

@ -33,6 +33,7 @@ type IConfigModalProps = {
onClose: () => void
onConfirm: (newValue: InputVar, moreInfo?: MoreInfo) => void
supportFile?: boolean
showHiddenField?: boolean
}
const ConfigModal: FC<IConfigModalProps> = ({
@ -41,6 +42,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
isShow,
onClose,
onConfirm,
showHiddenField,
supportFile,
}) => {
const { modelConfig } = useContext(ConfigContext)
@ -173,6 +175,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
onVarNameChange={handleVarNameChange}
options={options}
selectOptions={selectOptions}
showHiddenField={showHiddenField}
tempPayload={tempPayload}
t={t}
/>

View File

@ -96,7 +96,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType.chat,
mode: mode as unknown as ModelModeType,
completion_params: {} as CompletionParams,
})
const {

View File

@ -78,7 +78,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType.chat,
mode: mode as unknown as ModelModeType,
completion_params: defaultCompletionParams,
})
const {

View File

@ -1,5 +1,6 @@
'use client'
import type { ComponentProps } from 'react'
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types'
@ -21,7 +22,6 @@ import type {
TextToSpeechConfig,
} from '@/models/debug'
import type { VisionSettings } from '@/types/app'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
@ -481,7 +481,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
resolvedModelModeType,
])
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => {
const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params
? params
: undefined

View File

@ -349,29 +349,40 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
function AppPreview({ mode }: { mode: AppModeEnum }) {
const { t } = useTranslation()
const modeToPreviewInfoMap = {
[AppModeEnum.CHAT]: {
title: t('types.chatbot', { ns: 'app' }),
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
},
[AppModeEnum.ADVANCED_CHAT]: {
title: t('types.advanced', { ns: 'app' }),
description: t('newApp.advancedUserDescription', { ns: 'app' }),
},
[AppModeEnum.AGENT_CHAT]: {
title: t('types.agent', { ns: 'app' }),
description: t('newApp.agentUserDescription', { ns: 'app' }),
},
[AppModeEnum.COMPLETION]: {
title: t('newApp.completeApp', { ns: 'app' }),
description: t('newApp.completionUserDescription', { ns: 'app' }),
},
[AppModeEnum.WORKFLOW]: {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
},
}
const previewInfo = modeToPreviewInfoMap[mode]
const previewInfo = (() => {
switch (mode) {
case AppModeEnum.CHAT:
return {
title: t('types.chatbot', { ns: 'app' }),
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
}
case AppModeEnum.ADVANCED_CHAT:
return {
title: t('types.advanced', { ns: 'app' }),
description: t('newApp.advancedUserDescription', { ns: 'app' }),
}
case AppModeEnum.AGENT_CHAT:
return {
title: t('types.agent', { ns: 'app' }),
description: t('newApp.agentUserDescription', { ns: 'app' }),
}
case AppModeEnum.COMPLETION:
return {
title: t('newApp.completeApp', { ns: 'app' }),
description: t('newApp.completionUserDescription', { ns: 'app' }),
}
case AppModeEnum.WORKFLOW:
return {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
}
default:
return {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
}
}
})()
return (
<div className="px-8 py-4">
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>

View File

@ -2,6 +2,8 @@ import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Empty from '../empty'
const defaultMessage = 'workflow.tabs.noSnippetsFound'
describe('Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -9,32 +11,32 @@ describe('Empty', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
render(<Empty message={defaultMessage} />)
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
})
it('should render 36 placeholder cards', () => {
const { container } = render(<Empty />)
const { container } = render(<Empty message={defaultMessage} />)
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
expect(placeholderCards).toHaveLength(36)
})
it('should display the no apps found message', () => {
render(<Empty />)
it('should display the provided message', () => {
render(<Empty message="app.newApp.noAppsFound" />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling for overlay', () => {
const { container } = render(<Empty />)
const { container } = render(<Empty message={defaultMessage} />)
const overlay = container.querySelector('.pointer-events-none')
expect(overlay).toBeInTheDocument()
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
})
it('should have correct styling for placeholder cards', () => {
const { container } = render(<Empty />)
const { container } = render(<Empty message={defaultMessage} />)
const card = container.querySelector('.bg-background-default-lighter')
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
})
@ -42,10 +44,10 @@ describe('Empty', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
const { rerender } = render(<Empty message={defaultMessage} />)
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
rerender(<Empty />)
rerender(<Empty message="app.newApp.noAppsFound" />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})

View File

@ -51,16 +51,17 @@ vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
userProfile: { id: 'creator-1' },
}),
}))
const mockSetKeywords = vi.fn()
const mockSetIsCreatedByMe = vi.fn()
const mockSetCreatorIDs = vi.fn()
const mockSetCategory = vi.fn()
const mockQueryState = {
category: 'all',
keywords: '',
isCreatedByMe: false,
creatorIDs: [] as string[],
}
vi.mock('../hooks/use-apps-query-state', () => ({
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
@ -68,7 +69,19 @@ vi.mock('../hooks/use-apps-query-state', () => ({
query: mockQueryState,
setCategory: mockSetCategory,
setKeywords: mockSetKeywords,
setIsCreatedByMe: mockSetIsCreatedByMe,
setCreatorIDs: mockSetCreatorIDs,
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
],
},
setCreatorIDs: mockSetCreatorIDs,
}),
}))
@ -203,9 +216,9 @@ vi.mock('../app-card', () => ({
}))
vi.mock('../new-app-card', () => ({
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
}),
default: ({ ref: _ref }: { ref?: React.Ref<HTMLDivElement> }) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button', 'ref': _ref }, 'New App Card')
},
}))
vi.mock('../empty', () => ({
@ -220,14 +233,14 @@ vi.mock('../footer', () => ({
},
}))
let intersectionCallback: IntersectionObserverCallback | null = null
let intersectionCallbacks: IntersectionObserverCallback[] = []
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
beforeAll(() => {
globalThis.IntersectionObserver = class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback
intersectionCallbacks.push(callback)
}
observe = mockObserve
@ -242,12 +255,16 @@ beforeAll(() => {
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
const renderList = (searchParams = '') => {
const renderList = (searchParams = '', pageType: 'apps' | 'snippets' = 'apps') => {
mockSearchParams = new URLSearchParams(searchParams)
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
return renderWithNuqs(<SystemFeaturesWrapper><List pageType={pageType} /></SystemFeaturesWrapper>, { searchParams })
}
const openTypeFilter = () => {
fireEvent.click(screen.getByRole('button', { name: /^app\.(studio\.filters\.types|types\.)/ }))
}
type AppListInfiniteOptions = {
@ -268,20 +285,21 @@ describe('List', () => {
mockServiceState.isFetchingNextPage = false
mockQueryState.category = 'all'
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockQueryState.creatorIDs = []
mockUseWorkflowOnlineUsers.mockClear()
intersectionCallback = null
intersectionCallbacks = []
localStorage.clear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
it('should render app type dropdown with all app types', () => {
renderList()
openTypeFilter()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
@ -301,9 +319,21 @@ describe('List', () => {
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
})
it('should render created by me checkbox', () => {
it('should render creators filter', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
})
it('should render link to snippets on apps page', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.viewSnippets' })).toHaveAttribute('href', '/snippets')
})
it('should not render link to snippets on snippets page', () => {
renderList('', 'snippets')
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
@ -338,20 +368,22 @@ describe('List', () => {
})
})
describe('Tab Navigation', () => {
it('should update category when workflow tab is clicked', () => {
describe('Type Filter', () => {
it('should update category when workflow type is selected', () => {
renderList()
openTypeFilter()
fireEvent.click(screen.getByText('app.types.workflow'))
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.workflow' }))
expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
})
it('should update category when all tab is clicked', () => {
it('should update category when all type is selected', () => {
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
openTypeFilter()
fireEvent.click(screen.getByText('app.types.all'))
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.all' }))
expect(mockSetCategory).toHaveBeenCalledWith('all')
})
@ -377,10 +409,7 @@ describe('List', () => {
renderList()
const clearButton = document.querySelector('.group')
expect(clearButton)!.toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(mockSetKeywords).toHaveBeenCalledWith('')
})
@ -389,7 +418,7 @@ describe('List', () => {
describe('App List Query', () => {
it('should build paged query input from active filters', () => {
mockQueryState.keywords = 'sales'
mockQueryState.isCreatedByMe = true
mockQueryState.creatorIDs = ['creator-1', 'creator-2']
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
@ -403,7 +432,7 @@ describe('List', () => {
limit: 30,
name: 'sales',
tag_ids: ['tag-1'],
is_created_by_me: true,
creator_ids: ['creator-1', 'creator-2'],
mode: AppModeEnum.WORKFLOW,
},
})
@ -430,19 +459,19 @@ describe('List', () => {
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
describe('Creators Filter', () => {
it('should render creators filter with correct label', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
})
it('should handle checkbox change', () => {
it('should handle creator selection as a multi creator filter', () => {
renderList()
const checkbox = screen.getByRole('checkbox', { name: 'app.showMyCreatedAppsOnly' })
fireEvent.click(checkbox)
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
expect(mockSetCreatorIDs).toHaveBeenCalledWith(['creator-2'])
})
})
@ -488,11 +517,11 @@ describe('List', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { unmount } = renderList()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
unmount()
renderList()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
})
it('should render app cards correctly', () => {
@ -505,9 +534,10 @@ describe('List', () => {
it('should render with all filter options visible', () => {
renderList()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
})
})
@ -524,9 +554,10 @@ describe('List', () => {
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
describe('App Type Dropdown', () => {
it('should render all app type options', () => {
renderList()
openTypeFilter()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
@ -536,9 +567,7 @@ describe('List', () => {
expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
})
it('should update category for each app type tab click', () => {
renderList()
it('should update category for each app type option click', () => {
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
@ -549,8 +578,11 @@ describe('List', () => {
for (const { mode, text } of appTypeTexts) {
mockSetCategory.mockClear()
fireEvent.click(screen.getByText(text))
const { unmount } = renderList()
openTypeFilter()
fireEvent.click(screen.getByRole('menuitemradio', { name: text }))
expect(mockSetCategory).toHaveBeenCalledWith(mode)
unmount()
}
})
})
@ -626,16 +658,18 @@ describe('List', () => {
})
describe('Infinite Scroll', () => {
it('should call fetchNextPage when intersection observer triggers', () => {
it('should call fetchNextPage when intersection observer triggers', async () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
await waitFor(() => expect(mockObserve).toHaveBeenCalled())
if (intersectionCallbacks.length) {
act(() => {
intersectionCallback!(
intersectionCallbacks.forEach(callback => callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
))
})
}
@ -646,12 +680,12 @@ describe('List', () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
if (intersectionCallbacks.length) {
act(() => {
intersectionCallback!(
intersectionCallbacks.forEach(callback => callback(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
))
})
}
@ -663,12 +697,12 @@ describe('List', () => {
mockServiceState.isLoading = true
renderList()
if (intersectionCallback) {
if (intersectionCallbacks.length) {
act(() => {
intersectionCallback!(
intersectionCallbacks.forEach(callback => callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
))
})
}

View File

@ -0,0 +1,16 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
export type { AppListCategory }
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })

View File

@ -0,0 +1,76 @@
'use client'
import type { AppListCategory } from './app-type-filter-shared'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AppModeEnum } from '@/types/app'
import { isAppListCategory } from './app-type-filter-shared'
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
type AppTypeFilterProps = {
value: AppListCategory
onChange: (value: AppListCategory) => void
}
export function AppTypeFilter({
value,
onChange,
}: AppTypeFilterProps) {
const { t } = useTranslation()
const options = useMemo(() => ([
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
]), [t])
const activeOption = options.find(option => option.value === value)
const isSelected = value !== 'all'
const triggerLabel = isSelected ? activeOption?.text : t('studio.filters.types', { ns: 'app' })
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(
chipClassName,
isSelected
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
)}
/>
)}
>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
<span className="px-1 text-text-tertiary">{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
<DropdownMenuRadioGroup value={value} onValueChange={nextValue => isAppListCategory(nextValue) && onChange(nextValue)}>
{options.map(option => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
<span>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,230 @@
'use client'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Input } from '@langgenius/dify-ui/input'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { useMembers } from '@/service/use-common'
type CreatorsFilterProps = {
value: string[]
onChange: (value: string[]) => void
}
type CreatorOption = {
id: string
name: string
avatarUrl: string | null
isYou: boolean
}
const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
const CreatorsFilter = ({
value,
onChange,
}: CreatorsFilterProps) => {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const { data: membersData } = useMembers()
const [keywords, setKeywords] = useState('')
const creatorOptions = useMemo<CreatorOption[]>(() => {
const currentUserId = userProfile?.id
const members = membersData?.accounts ?? []
return [...members]
.filter(member => member.status !== 'pending')
.sort((left, right) => {
if (left.id === currentUserId)
return -1
if (right.id === currentUserId)
return 1
return left.name.localeCompare(right.name)
})
.map(member => ({
id: member.id,
name: member.name,
avatarUrl: member.avatar_url,
isYou: member.id === currentUserId,
}))
}, [membersData?.accounts, userProfile?.id])
const filteredCreators = useMemo(() => {
const normalizedKeywords = keywords.trim().toLowerCase()
if (!normalizedKeywords)
return creatorOptions
return creatorOptions.filter((creator) => {
const keyword = normalizedKeywords
return creator.name.toLowerCase().includes(keyword)
})
}, [creatorOptions, keywords])
const selectedCreators = useMemo(() => {
const creatorMap = new Map(creatorOptions.map(creator => [creator.id, creator]))
return value
.map(id => creatorMap.get(id))
.filter((creator): creator is CreatorOption => Boolean(creator))
}, [creatorOptions, value])
const toggleCreator = useCallback((creatorId: string) => {
if (value.includes(creatorId)) {
onChange(value.filter(id => id !== creatorId))
return
}
onChange([...value, creatorId])
}, [onChange, value])
const resetCreators = useCallback(() => {
onChange([])
setKeywords('')
}, [onChange])
const selectedCount = value.length
const selectedAvatarCreators = selectedCreators.slice(0, 3)
const isSelected = selectedCount > 0
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(
baseChipClassName,
isSelected
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
)}
/>
)}
>
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
{!isSelected && (
<>
<span className="px-1 text-text-tertiary">{t('studio.filters.allCreators', { ns: 'app' })}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</>
)}
{isSelected && (
<>
<span className="px-1 text-text-tertiary">{t('studio.filters.creators', { ns: 'app' })}</span>
<span className="flex items-center pr-1">
{selectedAvatarCreators.map((creator, index) => (
<Avatar
key={creator.id}
avatar={creator.avatarUrl}
name={creator.name}
size="xs"
className={cn(
'border border-components-panel-bg',
index > 0 && '-ml-1',
)}
/>
))}
</span>
<span className="text-xs leading-4 font-medium text-text-tertiary">{`+${selectedCount}`}</span>
<span
role="button"
tabIndex={0}
aria-label={t('studio.filters.reset', { ns: 'app' })}
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-xs text-text-quaternary hover:text-text-tertiary"
onClick={(event) => {
event.stopPropagation()
resetCreators()
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ')
return
event.preventDefault()
event.stopPropagation()
resetCreators()
}}
>
<span aria-hidden className="i-ri-close-circle-fill h-3.5 w-3.5" />
</span>
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
<div className="flex items-center gap-1 p-2 pb-1">
<div className="relative min-w-0 grow">
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
<Input
className={cn('pl-6.5', keywords && 'pr-6.5')}
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
{!!keywords && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
onClick={() => setKeywords('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
{isSelected && (
<button
type="button"
className="shrink-0 rounded-sm px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={resetCreators}
>
{t('studio.filters.reset', { ns: 'app' })}
</button>
)}
</div>
<div className="max-h-60 overflow-y-auto px-1 pb-1">
{filteredCreators.map((creator) => {
const checked = value.includes(creator.id)
return (
<button
key={creator.id}
type="button"
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => toggleCreator(creator.id)}
>
<Checkbox
id={creator.id}
checked={checked}
className="shrink-0"
/>
<div className="flex min-w-0 grow items-center gap-2 px-1">
<Avatar
avatar={creator.avatarUrl}
name={creator.name}
size="xs"
className="border-[0.5px] border-divider-regular"
/>
<div className="flex min-w-0 grow items-center justify-between gap-2">
<span className="truncate text-sm text-text-secondary">{creator.name}</span>
{creator.isYou && (
<span className="shrink-0 text-sm text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
)}
</div>
</div>
</button>
)
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default CreatorsFilter

View File

@ -17,7 +17,11 @@ const DefaultCards = React.memo(() => {
)
})
const Empty = () => {
type EmptyProps = {
message?: string
}
const Empty = ({ message }: EmptyProps) => {
const { t } = useTranslation()
return (
@ -25,7 +29,7 @@ const Empty = () => {
<DefaultCards />
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
<span className="system-md-medium text-text-tertiary">
{t('newApp.noAppsFound', { ns: 'app' })}
{message ?? t('newApp.noAppsFound', { ns: 'app' })}
</span>
</div>
</>

View File

@ -20,22 +20,22 @@ describe('useAppsQueryState', () => {
expect(result.current.query).toEqual({
category: 'all',
keywords: '',
isCreatedByMe: false,
creatorIDs: [],
})
expect(typeof result.current.setCategory).toBe('function')
expect(typeof result.current.setKeywords).toBe('function')
expect(typeof result.current.setIsCreatedByMe).toBe('function')
expect(typeof result.current.setCreatorIDs).toBe('function')
})
it('should parse app list filters from URL', () => {
const { result } = renderWithAdapter(
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term',
)
expect(result.current.query).toEqual({
category: AppModeEnum.WORKFLOW,
keywords: 'search term',
isCreatedByMe: true,
creatorIDs: [],
})
})
@ -115,30 +115,29 @@ describe('useAppsQueryState', () => {
}
})
it('should update created-by-me URL state', async () => {
it('should update creator IDs in local state without writing to the URL', () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setIsCreatedByMe(true)
result.current.setCreatorIDs(['creator-1', 'creator-2'])
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.isCreatedByMe).toBe(true)
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
expect(update.options.history).toBe('push')
expect(result.current.query.creatorIDs).toEqual(['creator-1', 'creator-2'])
expect(onUrlUpdate).not.toHaveBeenCalled()
})
it('should remove isCreatedByMe from URL when disabled', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
it('should clear creator IDs from local state without writing to the URL', () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setIsCreatedByMe(false)
result.current.setCreatorIDs(['creator-1'])
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.isCreatedByMe).toBe(false)
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
act(() => {
result.current.setCreatorIDs([])
})
expect(result.current.query.creatorIDs).toEqual([])
expect(onUrlUpdate).not.toHaveBeenCalled()
})
})

View File

@ -1,48 +1,41 @@
import { debounce, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { AppModes } from '@/types/app'
import type { AppListCategory } from '../app-type-filter-shared'
import { debounce, parseAsString, useQueryStates } from 'nuqs'
import { useCallback, useMemo, useState } from 'react'
import { parseAsAppListCategory } from '../app-type-filter-shared'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
const appListQueryParsers = {
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' }),
category: parseAsAppListCategory,
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
}),
isCreatedByMe: parseAsBoolean
.withDefault(false)
.withOptions({ history: 'push' }),
}
export function useAppsQueryState() {
const [query, setQuery] = useQueryStates(appListQueryParsers)
const [urlQuery, setUrlQuery] = useQueryStates(appListQueryParsers)
const [creatorIDs, setCreatorIDs] = useState<string[]>([])
const setCategory = useCallback((category: AppListCategory) => {
setQuery({ category })
}, [setQuery])
setUrlQuery({ category })
}, [setUrlQuery])
const setKeywords = useCallback((keywords: string) => {
setQuery({ keywords })
}, [setQuery])
setUrlQuery({ keywords })
}, [setUrlQuery])
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
setQuery({ isCreatedByMe })
}, [setQuery])
const handleSetCreatorIDs = useCallback((creatorIDs: string[]) => {
setCreatorIDs(creatorIDs)
}, [])
const query = useMemo(() => ({
...urlQuery,
creatorIDs,
}), [creatorIDs, urlQuery])
return useMemo(() => ({
query,
setCategory,
setKeywords,
setIsCreatedByMe,
}), [query, setCategory, setKeywords, setIsCreatedByMe])
setCreatorIDs: handleSetCreatorIDs,
}), [handleSetCreatorIDs, query, setCategory, setKeywords])
}

View File

@ -15,19 +15,29 @@ import { fetchAppDetail } from '@/service/explore'
import { trackCreateApp } from '@/utils/create-app-tracking'
import List from './list'
export type StudioPageType = 'apps' | 'snippets'
type AppsProps = {
pageType?: StudioPageType
}
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false })
const Apps = () => {
const Apps = ({
pageType = 'apps',
}: AppsProps) => {
const { t } = useTranslation()
const searchParams = useSearchParams()
const { replace } = useRouter()
const templateId = searchParams.get('template-id')
const templateDismissedRef = useRef(false)
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useDocumentTitle(pageType === 'apps'
? t('menus.apps', { ns: 'common' })
: t('tabs.snippets', { ns: 'workflow' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
@ -165,7 +175,7 @@ const Apps = () => {
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} />
<List controlRefreshList={controlRefreshList} pageType={pageType} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}

View File

@ -1,30 +1,33 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { AppListQuery } from '@/contract/console/apps'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import Link from '@/next/link'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import { AppTypeFilter } from './app-type-filter'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
import { useAppsQueryState } from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
import NewAppCard from './new-app-card'
@ -38,9 +41,11 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -51,10 +56,10 @@ const List: FC<Props> = ({
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { category, keywords, isCreatedByMe },
query: { category, keywords, creatorIDs },
setCategory,
setKeywords,
setIsCreatedByMe,
setCreatorIDs,
} = useAppsQueryState()
const [tagIDs, setTagIDs] = useState<string[]>([])
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
@ -63,6 +68,7 @@ const List: FC<Props> = ({
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const [needRefreshAppList, setNeedRefreshAppList] = useLocalStorage<string>(NEED_REFRESH_APP_LIST_KEY, '0', { raw: true })
const handleDSLFileDropped = useCallback((file: File) => {
setDroppedDSLFile(file)
@ -90,9 +96,9 @@ const List: FC<Props> = ({
limit: 30,
name: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
...(category !== 'all' ? { mode: category } : {}),
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
}), [category, creatorIDs, debouncedKeywords, tagIDs])
const {
data,
@ -126,21 +132,13 @@ const List: FC<Props> = ({
}, [controlRefreshList, refetch])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
]
useEffect(() => {
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
if (needRefreshAppList === '1') {
setNeedRefreshAppList(null)
refetch()
}
}, [refetch])
}, [needRefreshAppList, refetch, setNeedRefreshAppList])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
@ -172,10 +170,6 @@ const List: FC<Props> = ({
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
const handleCreatedByMeChange = useCallback((checked: boolean) => {
setIsCreatedByMe(checked)
}, [setIsCreatedByMe])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
@ -207,32 +201,45 @@ const List: FC<Props> = ({
</div>
)}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-7 pb-5">
<TabSliderNew
value={category}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setCategory(nextValue)
}}
options={options}
/>
<div className="flex items-center gap-2">
<label className="mr-2 flex h-7 items-center space-x-2">
<Checkbox checked={isCreatedByMe} onCheckedChange={handleCreatedByMeChange} />
<div className="text-sm font-normal text-text-secondary">
{t('showMyCreatedAppsOnly', { ns: 'app' })}
</div>
</label>
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 bg-background-body px-12 pt-7 pb-5">
<div className="flex flex-wrap items-center gap-2">
<AppTypeFilter
value={category}
onChange={setCategory}
/>
<CreatorsFilter
value={creatorIDs}
onChange={setCreatorIDs}
/>
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<div className="relative w-50">
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
<Input
className={cn('pl-6.5', keywords && 'pr-6.5')}
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder={t('operation.search', { ns: 'common' })}
/>
{!!keywords && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
onClick={() => setKeywords('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
</div>
{pageType === 'apps' && (
<Link
href="/snippets"
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary hover:bg-state-base-hover hover:text-text-primary"
>
{t('studio.viewSnippets', { ns: 'app' })}
</Link>
)}
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
@ -260,7 +267,7 @@ const List: FC<Props> = ({
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
))
: <Empty />}
: <Empty message={pageType === 'snippets' ? t('tabs.noSnippetsFound', { ns: 'workflow' }) : undefined} />}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}

View File

@ -0,0 +1,191 @@
import {
flushRegistrationSuccess,
REGISTRATION_SUCCESS_STORAGE_KEY,
rememberRegistrationSuccess,
} from '../registration-tracking'
const mockTrackEvent = vi.hoisted(() => vi.fn())
vi.mock('../utils', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
describe('registration tracking', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
window.sessionStorage.clear()
})
// Captures the registration event for a later flush instead of firing it right away.
describe('rememberRegistrationSuccess', () => {
it('should store the base event and not track immediately when there is no utm info', () => {
rememberRegistrationSuccess({ method: 'email' })
expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({
eventName: 'user_registration_success',
properties: { method: 'email' },
})
expect(mockTrackEvent).not.toHaveBeenCalled()
})
it('should store the utm event and merge utm info into properties when utm info is present', () => {
rememberRegistrationSuccess({
method: 'oauth',
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
})
expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({
eventName: 'user_registration_success_with_utm',
properties: { method: 'oauth', utm_source: 'linkedin', slug: 'agent-launch' },
})
})
it('should swallow errors when writing to sessionStorage fails', () => {
vi.stubGlobal('window', {
sessionStorage: {
getItem: vi.fn(() => null),
setItem: () => {
throw new Error('quota exceeded')
},
removeItem: vi.fn(),
},
})
try {
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
}
finally {
vi.unstubAllGlobals()
}
})
})
// Replays the remembered event exactly once, after the user ID has been attached.
describe('flushRegistrationSuccess', () => {
it('should track the remembered event and clear it from storage', () => {
rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'blog' } })
flushRegistrationSuccess()
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'email',
utm_source: 'blog',
})
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
})
it('should do nothing when there is no pending event', () => {
flushRegistrationSuccess()
expect(mockTrackEvent).not.toHaveBeenCalled()
})
it('should fire the event at most once across repeated flushes', () => {
rememberRegistrationSuccess({ method: 'oauth' })
flushRegistrationSuccess()
flushRegistrationSuccess()
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
})
it('should clear malformed pending data without tracking', () => {
window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, '{not-json')
flushRegistrationSuccess()
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
})
it('should clear the pending entry without tracking when it has no event name', () => {
window.sessionStorage.setItem(
REGISTRATION_SUCCESS_STORAGE_KEY,
JSON.stringify({ properties: { method: 'email' } }),
)
flushRegistrationSuccess()
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
})
it('should stop without tracking when reading from sessionStorage throws', () => {
vi.stubGlobal('window', {
sessionStorage: {
getItem: () => {
throw new Error('read failed')
},
setItem: vi.fn(),
removeItem: vi.fn(),
},
})
try {
expect(() => flushRegistrationSuccess()).not.toThrow()
expect(mockTrackEvent).not.toHaveBeenCalled()
}
finally {
vi.unstubAllGlobals()
}
})
it('should still track when clearing the pending entry fails', () => {
const pending = { eventName: 'user_registration_success', properties: { method: 'email' } }
vi.stubGlobal('window', {
sessionStorage: {
getItem: () => JSON.stringify(pending),
setItem: vi.fn(),
removeItem: () => {
throw new Error('remove failed')
},
},
})
try {
flushRegistrationSuccess()
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', { method: 'email' })
}
finally {
vi.unstubAllGlobals()
}
})
})
// Both producers and the consumer must degrade gracefully when sessionStorage is
// missing (SSR) or blocked (privacy mode / disabled storage).
describe('when sessionStorage is unavailable', () => {
it('should no-op without throwing when window is undefined', () => {
vi.stubGlobal('window', undefined)
try {
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
expect(() => flushRegistrationSuccess()).not.toThrow()
expect(mockTrackEvent).not.toHaveBeenCalled()
}
finally {
vi.unstubAllGlobals()
}
})
it('should no-op without throwing when accessing sessionStorage throws', () => {
vi.stubGlobal('window', {
get sessionStorage() {
throw new Error('storage disabled')
},
})
try {
expect(() => rememberRegistrationSuccess({ method: 'oauth' })).not.toThrow()
expect(() => flushRegistrationSuccess()).not.toThrow()
expect(mockTrackEvent).not.toHaveBeenCalled()
}
finally {
vi.unstubAllGlobals()
}
})
})
})

View File

@ -0,0 +1,88 @@
import { trackEvent } from './utils'
/**
* Storage key for a registration success event that is waiting to be sent to
* Amplitude until a user ID has been attached.
*/
export const REGISTRATION_SUCCESS_STORAGE_KEY = 'pending_registration_success_event'
type RegistrationMethod = 'email' | 'oauth'
type PendingRegistrationSuccessEvent = {
eventName: string
properties: Record<string, unknown>
}
const getSessionStorage = (): Storage | null => {
try {
if (typeof window === 'undefined')
return null
return window.sessionStorage
}
catch {
return null
}
}
/**
* Remember a registration success event so it can be sent to Amplitude *after* the
* user ID is attached (see `flushRegistrationSuccess`).
*
* Amplitude attributes events to whatever identity is active when `track` runs. At
* registration time the client does not yet know the user ID, so firing the event
* immediately records it under an anonymous profile. We persist the event here and
* replay it once `setUserId` runs in the app context provider after the redirect.
*/
export const rememberRegistrationSuccess = (
{ method, utmInfo }: { method: RegistrationMethod, utmInfo?: Record<string, unknown> | null },
) => {
const storage = getSessionStorage()
if (!storage)
return
const pending: PendingRegistrationSuccessEvent = {
eventName: utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success',
properties: { method, ...utmInfo },
}
try {
storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending))
}
catch {}
}
/**
* Send a previously remembered registration success event to Amplitude.
*
* MUST be called after `setUserId` so the event lands on the identified user profile.
* No-op when nothing is pending. The pending entry is removed before tracking so the
* event fires at most once even if this runs multiple times.
*/
export const flushRegistrationSuccess = () => {
const storage = getSessionStorage()
if (!storage)
return
let raw: string | null = null
try {
raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)
}
catch {
return
}
if (!raw)
return
try {
storage.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY)
}
catch {}
try {
const pending = JSON.parse(raw) as PendingRegistrationSuccessEvent
if (pending?.eventName)
trackEvent(pending.eventName, pending.properties)
}
catch {}
}

View File

@ -5,7 +5,7 @@ import type {
EditorState,
} from 'lexical'
import type { FC } from 'react'
import type { Hotkey, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
import type { Hotkey, ShortcutPopupDisplayMode, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
import type {
ContextBlockType,
CurrentBlockType,
@ -131,7 +131,11 @@ export type PromptEditorProps = {
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
isSupportFileVar?: boolean
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }> }>
shortcutPopups?: Array<{
hotkey: Hotkey
displayMode?: ShortcutPopupDisplayMode
Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }>
}>
}
const PromptEditor: FC<PromptEditorProps> = ({

View File

@ -91,6 +91,29 @@ describe('InputField', () => {
lastVarReferencePickerProps = undefined
})
it('should keep the header and actions visible while the field content scrolls internally', () => {
const { container } = render(
<InputField
nodeId="node-layout"
isEdit={false}
payload={createPayload()}
onChange={vi.fn()}
onCancel={vi.fn()}
/>,
)
const panel = container.firstElementChild
const header = panel?.children[0]
const scrollBody = panel?.children[1]
const footer = panel?.lastElementChild
expect(panel).toHaveClass('max-h-(--shortcut-popup-max-height)', 'overflow-hidden')
expect(header).toHaveClass('shrink-0', 'pb-2')
expect(scrollBody).toHaveClass('min-h-0', 'flex-1', 'overflow-y-auto')
expect(footer).toHaveClass('shrink-0', 'bg-components-panel-bg')
expect(footer).not.toHaveClass('border-t')
})
it('should disable save and show validation error when variable name is invalid', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

View File

@ -3,6 +3,7 @@ import type { FormInputItem, FormInputItemDefault, ParagraphFormInput } from '@/
import type { UploadFileSetting, ValueSelector } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { produce } from 'immer'
@ -11,7 +12,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import TypeSelector from '@/app/components/app/configuration/config-var/config-modal/type-select'
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
import Input from '@/app/components/base/input'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import {
@ -206,9 +206,11 @@ const InputField: React.FC<InputFieldProps> = ({
}, [handleSave])
return (
<div className="flex max-h-[540px] w-[372px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="min-h-0 flex-1 overflow-y-auto p-3 pb-0">
<div className="flex max-h-(--shortcut-popup-max-height) w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="shrink-0 p-3 pb-2">
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3 pt-0 pb-0">
<div className="mt-3">
<div className="system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.fieldType`, { ns: 'workflow' })}
@ -322,7 +324,7 @@ const InputField: React.FC<InputFieldProps> = ({
</div>
)}
</div>
<div className="shrink-0 p-3">
<div className="shrink-0 bg-components-panel-bg p-3">
<div className="flex justify-end space-x-2">
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
{isEdit

View File

@ -1,4 +1,4 @@
import type { ShortcutPopupInsertHandler } from '../index'
import type { ShortcutPopupDisplayMode, ShortcutPopupInsertHandler } from '../index'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
@ -50,6 +50,7 @@ const CONTENT_EDITABLE_ID = 'ce'
type MinimalEditorProps = {
withContainer?: boolean
hotkey?: string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
displayMode?: ShortcutPopupDisplayMode
children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode)
className?: string
onOpen?: () => void
@ -59,6 +60,7 @@ type MinimalEditorProps = {
const MinimalEditor: React.FC<MinimalEditorProps> = ({
withContainer = true,
hotkey,
displayMode,
children,
className,
onOpen,
@ -83,6 +85,7 @@ const MinimalEditor: React.FC<MinimalEditorProps> = ({
<ShortcutsPopupPlugin
container={withContainer ? containerEl : undefined}
hotkey={hotkey}
displayMode={displayMode}
className={className}
onOpen={onOpen}
onClose={onClose}
@ -179,7 +182,9 @@ describe('ShortcutsPopupPlugin', () => {
const host = screen.getByTestId(CONTAINER_ID)
focusAndTriggerHotkey('/')
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = screen.getByTestId('shortcuts-popup')
expect(host).toContainElement(portalContent)
expect(floatingDiv).toHaveStyle({ position: 'absolute' })
})
it('falls back to document.body when container is not provided', async () => {
@ -188,10 +193,65 @@ describe('ShortcutsPopupPlugin', () => {
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = screen.getByTestId('shortcuts-popup')
expect(document.body).toContainElement(portalContent)
expect(floatingDiv).toHaveStyle({ position: 'fixed' })
expect(floatingDiv).toHaveStyle({ zIndex: '50' })
expect(floatingDiv).toHaveStyle({ overflow: 'visible' })
})
it('clips the popup viewport so child popups own their internal scrolling', async () => {
render(<MinimalEditor />)
focusAndTriggerHotkey('/')
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = screen.getByTestId('shortcuts-popup')
expect(floatingDiv.firstElementChild).toHaveClass('overflow-hidden')
})
it('can render fixed next to the workflow panel instead of following the cursor', async () => {
const originalInnerWidth = window.innerWidth
const originalInnerHeight = window.innerHeight
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
const rightPanel = document.createElement('div')
rightPanel.setAttribute('data-workflow-right-panel', '')
rightPanel.getBoundingClientRect = vi.fn(() => ({
x: 800,
y: 56,
width: 400,
height: 840,
top: 56,
right: 1200,
bottom: 896,
left: 800,
toJSON: () => ({}),
} as DOMRect))
document.body.appendChild(rightPanel)
try {
render(<MinimalEditor withContainer={false} displayMode="workflow-panel-adjacent-center" />)
focusAndTriggerHotkey('/')
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = screen.getByTestId('shortcuts-popup')
await waitFor(() => {
expect(floatingDiv).toHaveStyle({
position: 'fixed',
right: '404px',
top: '474px',
transform: 'translateY(-50%)',
})
})
expect(floatingDiv.style.getPropertyValue('--shortcut-popup-max-width')).toBe('400px')
expect(floatingDiv.style.getPropertyValue('--shortcut-popup-max-height')).toBe('836px')
}
finally {
rightPanel.remove()
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight })
}
})
// ─── matchHotkey: string hotkey ───
it('matches a string hotkey like "mod+/"', async () => {
render(<MinimalEditor hotkey="mod+/" />)

View File

@ -1,4 +1,5 @@
import type { LexicalCommand } from 'lexical'
import type { CSSProperties } from 'react'
import {
autoUpdate,
flip,
@ -19,11 +20,13 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import { createPortal } from 'react-dom'
export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
export type ShortcutPopupInsertHandler = <Payload>(command: LexicalCommand<Payload>, params: Payload) => void
export type ShortcutPopupDisplayMode = 'selection' | 'workflow-panel-adjacent-center'
// Hotkey can be:
// - string: 'mod+/'
@ -37,10 +40,85 @@ type ShortcutPopupPluginProps = {
children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode)
className?: string
container?: Element | null
displayMode?: ShortcutPopupDisplayMode
onOpen?: () => void
onClose?: () => void
}
const VIEWPORT_PADDING = 8
const PANEL_GAP = 4
const POPUP_MAX_WIDTH = 400
type FixedPlacementState = {
right: number
top: number
availableWidth: number
availableHeight: number
}
function getWorkflowPanelAdjacentPlacement(): FixedPlacementState {
const rightPanel = document.querySelector('[data-workflow-right-panel]') as HTMLElement | null
const rightPanelRect = rightPanel?.getBoundingClientRect()
const rightBoundary = rightPanelRect && rightPanelRect.left > 0
? rightPanelRect.left
: window.innerWidth
const topBoundary = rightPanelRect?.top ?? 56
const bottomBoundary = window.innerHeight - VIEWPORT_PADDING
const availableWidth = Math.max(0, rightBoundary - VIEWPORT_PADDING * 2)
const availableHeight = Math.max(0, bottomBoundary - topBoundary)
return {
right: Math.max(VIEWPORT_PADDING, window.innerWidth - rightBoundary + PANEL_GAP),
top: topBoundary + availableHeight / 2,
availableWidth,
availableHeight,
}
}
function getWorkflowPanelAdjacentPlacementSnapshot() {
/* v8 ignore next 2 -- server/non-browser fallback for a client-only positioning branch. @preserve */
if (typeof window === 'undefined' || typeof document === 'undefined')
return '0|0|0|0'
const placement = getWorkflowPanelAdjacentPlacement()
return [
placement.right,
placement.top,
placement.availableWidth,
placement.availableHeight,
].join('|')
}
function parseWorkflowPanelAdjacentPlacement(snapshot: string): FixedPlacementState {
const [right = '0', top = '0', availableWidth = '0', availableHeight = '0'] = snapshot.split('|')
return {
right: Number(right),
top: Number(top),
availableWidth: Number(availableWidth),
availableHeight: Number(availableHeight),
}
}
function subscribeWorkflowPanelAdjacentPlacement(callback: () => void) {
/* v8 ignore next 2 -- server/non-browser fallback for a client-only positioning branch. @preserve */
if (typeof window === 'undefined' || typeof document === 'undefined')
return () => {}
window.addEventListener('resize', callback)
const rightPanel = document.querySelector('[data-workflow-right-panel]')
const resizeObserver = rightPanel && typeof ResizeObserver !== 'undefined'
? new ResizeObserver(callback)
: null
if (rightPanel)
resizeObserver?.observe(rightPanel)
return () => {
window.removeEventListener('resize', callback)
resizeObserver?.disconnect()
}
}
const META_ALIASES = new Set(['meta', 'cmd', 'command'])
const CTRL_ALIASES = new Set(['ctrl'])
const ALT_ALIASES = new Set(['alt', 'option'])
@ -134,11 +212,17 @@ export default function ShortcutsPopupPlugin({
children,
className,
container,
displayMode = 'selection',
onOpen,
onClose,
}: ShortcutPopupPluginProps): React.ReactPortal | null {
const [editor] = useLexicalComposerContext()
const [open, setOpen] = useState(false)
const workflowPanelAdjacentPlacementSnapshot = useSyncExternalStore(
subscribeWorkflowPanelAdjacentPlacement,
getWorkflowPanelAdjacentPlacementSnapshot,
() => '0|0|0|0',
)
const portalRef = useRef<HTMLDivElement | null>(null)
const lastSelectionRef = useRef<Range | null>(null)
@ -148,6 +232,7 @@ export default function ShortcutsPopupPlugin({
const { refs, floatingStyles, isPositioned } = useFloating({
placement: 'bottom-start',
strategy: useContainer ? 'absolute' : 'fixed',
middleware: [
offset(0), // fix hide cursor
shift({
@ -192,6 +277,12 @@ export default function ShortcutsPopupPlugin({
}, [editor])
const openPortal = useCallback(() => {
if (displayMode !== 'selection') {
setOpen(true)
onOpen?.()
return
}
const domSelection = window.getSelection()
let range: Range | null = null
if (domSelection && domSelection.rangeCount > 0)
@ -237,7 +328,7 @@ export default function ShortcutsPopupPlugin({
setOpen(true)
onOpen?.()
}, [editor, onOpen, refs])
}, [displayMode, editor, onOpen, refs])
const closePortal = useCallback(() => {
setOpen(false)
@ -292,25 +383,44 @@ export default function ShortcutsPopupPlugin({
if (!open || !containerEl)
return null
const isFixedPanelAdjacent = displayMode === 'workflow-panel-adjacent-center'
const fixedPlacementState = parseWorkflowPanelAdjacentPlacement(workflowPanelAdjacentPlacementSnapshot)
const fixedPanelAdjacentStyles: CSSProperties = isFixedPanelAdjacent
? {
position: 'fixed',
right: fixedPlacementState.right,
top: fixedPlacementState.top,
transform: 'translateY(-50%)',
zIndex: 50,
overflow: 'visible',
visibility: 'visible',
['--shortcut-popup-max-width' as string]: `${Math.min(POPUP_MAX_WIDTH, fixedPlacementState.availableWidth)}px`,
['--shortcut-popup-max-height' as string]: `${fixedPlacementState.availableHeight}px`,
} as CSSProperties
: {}
return createPortal(
<div
data-testid="shortcuts-popup"
ref={(node) => {
portalRef.current = node
refs.setFloating(node)
if (!isFixedPanelAdjacent)
refs.setFloating(node)
}}
className={cn(
'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
className,
)}
style={{
...floatingStyles,
zIndex: useContainer ? undefined : 50,
overflow: 'visible',
visibility: isPositioned ? 'visible' : 'hidden',
}}
style={isFixedPanelAdjacent
? fixedPanelAdjacentStyles
: {
...floatingStyles,
zIndex: useContainer ? undefined : 50,
overflow: 'visible',
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
<div className="max-h-(--shortcut-popup-max-height) max-w-(--shortcut-popup-max-width) overflow-x-hidden overflow-y-auto rounded-xl">
<div className="max-h-(--shortcut-popup-max-height) max-w-(--shortcut-popup-max-width) overflow-hidden rounded-xl">
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
</div>
</div>,

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