Compare commits

...

245 Commits

Author SHA1 Message Date
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
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
8747e3a2d3 Merge branch 'main' into jzh 2026-04-14 10:14:13 +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
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
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
4d3738d225 Merge branch 'main' into feat/evaluation-fe 2026-03-17 10:42:44 +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
244 changed files with 22695 additions and 775 deletions

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

@ -72,6 +72,13 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
useSnippetAndEvaluationPlanAccess: () => ({
canAccess: true,
isReady: true,
}),
}))
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
@ -88,6 +95,16 @@ vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/service/apps', () => ({
fetchWorkflowOnlineUsers: vi.fn().mockResolvedValue({}),
}))
@ -109,6 +126,18 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: () => ({
data: { pages: [] },
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
error: null,
}),
}))
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
@ -328,16 +357,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()
})
})
@ -363,21 +387,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

@ -59,6 +59,13 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
useSnippetAndEvaluationPlanAccess: () => ({
canAccess: true,
isReady: true,
}),
}))
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
@ -75,6 +82,16 @@ vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/service/apps', () => ({
fetchWorkflowOnlineUsers: vi.fn().mockResolvedValue({}),
}))
@ -96,6 +113,18 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: () => ({
data: { pages: [] },
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
error: null,
}),
}))
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))

View File

@ -0,0 +1,16 @@
import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard'
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ appId: string }>
}) => {
const { appId } = await props.params
return (
<SnippetAndEvaluationPlanGuard fallbackHref={`/app/${appId}/overview`}>
<Evaluation resourceType="apps" resourceId={appId} />
</SnippetAndEvaluationPlanGuard>
)
}
export default Page

View File

@ -25,6 +25,7 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st
import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import dynamic from '@/next/dynamic'
import { usePathname, useRouter } from '@/next/navigation'
import { fetchAppDetailDirect } from '@/service/apps'
@ -40,6 +41,10 @@ type IAppDetailLayoutProps = {
appId: string
}
const EvaluationIcon = ({ className }: { className?: string }) => {
return <span aria-hidden className={cn('i-custom-vender-line-others-evaluation', className)} />
}
const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const {
children,
@ -50,6 +55,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({
appDetail: state.appDetail,
@ -67,42 +73,53 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
]
const navConfig = []
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
})
}
navConfig.push({
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
})
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
})
}
navConfig.push({
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
})
if (isCurrentWorkspaceEditor && canAccessSnippetsAndEvaluation) {
navConfig.push({
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: EvaluationIcon,
selectedIcon: EvaluationIcon,
})
}
return navConfig
}, [t])
}, [canAccessSnippetsAndEvaluation, t])
useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' }))

View File

@ -0,0 +1,230 @@
import type { ReactNode } from 'react'
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetDetailLayout from '../layout-main'
let mockPathname = '/datasets/test-dataset-id/documents'
let mockDataset: DataSet | undefined
let mockCanAccessSnippetsAndEvaluation = true
const mockSetAppSidebarExpand = vi.fn()
const mockMutateDatasetRes = vi.fn()
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
setAppSidebarExpand: mockSetAppSidebarExpand,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: {
mobile: 'mobile',
desktop: 'desktop',
},
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceDatasetOperator: false,
}),
}))
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
useSnippetAndEvaluationPlanAccess: () => ({
canAccess: mockCanAccessSnippetsAndEvaluation,
isReady: true,
}),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetDetail: () => ({
data: mockDataset,
error: null,
refetch: mockMutateDatasetRes,
}),
useDatasetRelatedApps: () => ({
data: [],
}),
}))
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
navigation,
children,
}: {
navigation: Array<{ name: string, href: string, disabled?: boolean }>
children?: ReactNode
}) => (
<div data-testid="app-sidebar">
{navigation.map(item => (
<button
key={item.href}
type="button"
disabled={item.disabled}
>
{item.name}
</button>
))}
{children}
</div>
),
}))
vi.mock('@/app/components/datasets/extra-info', () => ({
default: () => <div data-testid="dataset-extra-info" />,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div role="status">loading</div>,
}))
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'test-dataset-id',
name: 'Test Dataset',
indexing_status: 'completed',
icon_info: {
icon: 'book',
icon_background: '#fff',
icon_type: 'emoji',
icon_url: '',
},
description: '',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 0,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 0,
total_document_count: 0,
word_count: 0,
provider: 'vendor',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
pipeline_id: 'pipeline-1',
is_published: true,
runtime_mode: 'rag_pipeline',
enable_api: false,
is_multimodal: false,
...overrides,
})
describe('DatasetDetailLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/datasets/test-dataset-id/documents'
mockDataset = createDataset()
mockCanAccessSnippetsAndEvaluation = true
})
describe('Evaluation navigation', () => {
it('should hide the evaluation menu when the dataset is not a rag pipeline', () => {
mockDataset = createDataset({
runtime_mode: 'general',
is_published: false,
})
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.queryByRole('button', { name: 'common.datasetMenus.evaluation' })).not.toBeInTheDocument()
})
it('should disable the evaluation menu when the rag pipeline is unpublished', () => {
mockDataset = createDataset({
is_published: false,
})
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.getByRole('button', { name: 'common.datasetMenus.evaluation' })).toBeDisabled()
})
it('should enable the evaluation menu when the rag pipeline is published', () => {
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.getByRole('button', { name: 'common.datasetMenus.evaluation' })).toBeEnabled()
})
it('should hide the evaluation menu when snippet and evaluation access is unavailable', () => {
mockCanAccessSnippetsAndEvaluation = false
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.queryByRole('button', { name: 'common.datasetMenus.evaluation' })).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,16 @@
import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard'
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ datasetId: string }>
}) => {
const { datasetId } = await props.params
return (
<SnippetAndEvaluationPlanGuard fallbackHref={`/datasets/${datasetId}/documents`}>
<Evaluation resourceType="datasets" resourceId={datasetId} />
</SnippetAndEvaluationPlanGuard>
)
}
export default Page

View File

@ -23,6 +23,7 @@ import DatasetDetailContext from '@/context/dataset-detail'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import { usePathname } from '@/next/navigation'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
@ -31,6 +32,10 @@ type IAppDetailLayoutProps = {
datasetId: string
}
const EvaluationIcon = ({ className }: { className?: string }) => {
return <span aria-hidden className={cn('i-custom-vender-line-others-evaluation', className)} />
}
const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const {
children,
@ -49,6 +54,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
setHideHeader(v.payload)
})
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
@ -56,6 +62,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
const { data: relatedApps } = useDatasetRelatedApps(datasetId)
const isRagPipelineDataset = datasetRes?.runtime_mode === 'rag_pipeline'
const isButtonDisabledWithPipeline = useMemo(() => {
if (!datasetRes)
@ -86,24 +93,36 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
]
if (datasetRes?.provider !== 'external') {
baseNavigation.unshift({
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
})
baseNavigation.unshift({
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
})
return [
{
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
},
...baseNavigation,
...(isRagPipelineDataset && canAccessSnippetsAndEvaluation
? [{
name: t('datasetMenus.evaluation', { ns: 'common' }),
href: `/datasets/${datasetId}/evaluation`,
icon: EvaluationIcon,
selectedIcon: EvaluationIcon,
disabled: isButtonDisabledWithPipeline,
}]
: []),
]
}
return baseNavigation
}, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider])
}, [canAccessSnippetsAndEvaluation, t, datasetId, isButtonDisabledWithPipeline, isRagPipelineDataset, datasetRes?.provider])
useDocumentTitle(datasetRes?.name || t('menus.datasets', { ns: 'common' }))

View File

@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
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 SnippetEvaluationPage from '@/app/components/snippets/snippet-evaluation-page'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetEvaluationPage snippetId={snippetId} />
}
export default Page

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,12 @@
import Apps from '@/app/components/apps'
import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard'
const SnippetsPage = () => {
return (
<SnippetAndEvaluationPlanGuard fallbackHref="/apps">
<Apps pageType="snippets" />
</SnippetAndEvaluationPlanGuard>
)
}
export default SnippetsPage

View File

@ -165,6 +165,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

@ -2,7 +2,7 @@ import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import AppInfoDetailPanel from '../app-info-detail-panel'
vi.mock('../../../base/app-icon', () => ({
@ -135,6 +135,17 @@ describe('AppInfoDetailPanel', () => {
expect(cardView).toHaveAttribute('data-app-id', 'app-1')
})
it('should not render CardView when app type is evaluation', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ type: AppTypeEnum.EVALUATION })}
/>,
)
expect(screen.queryByTestId('card-view')).not.toBeInTheDocument()
})
it('should render app icon with large size', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const icon = screen.getByTestId('app-icon')

View File

@ -15,7 +15,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import ContentDialog from '@/app/components/base/content-dialog'
import { AppModeEnum } from '@/types/app'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import AppIcon from '../../base/app-icon'
import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
@ -126,11 +126,13 @@ const AppInfoDetailPanel = ({
secondaryOperations={secondaryOperations}
/>
</div>
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{appDetail.workflow_kind !== AppTypeEnum.EVALUATION && (
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
)}
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button

View File

@ -36,12 +36,16 @@ type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
renderHeader,
renderNavigation,
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
@ -116,10 +120,11 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
{renderHeader?.(appSidebarExpand)}
{!renderHeader && iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType !== 'app' && (
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@ -148,7 +153,8 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
{renderNavigation?.(appSidebarExpand)}
{!renderNavigation && navigation.map((item, index) => {
return (
<NavLink
key={index}

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,284 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { CreateSnippetDialogPayload } from '@/app/components/workflow/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
icon?: AppIconSelection
}
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
vi.mock('@/app/components/workflow/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',
icon: {
type: 'emoji',
icon: '✨',
background: '#FFFFFF',
},
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',
icon: '🤖',
iconBackground: '#F0FDF9',
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',
icon_info: {
icon: '✨',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: undefined,
},
},
}, 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,61 @@
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',
icon: '🤖',
iconBackground: '#F0FDF9',
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,198 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
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/workflow/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 FALLBACK_ICON: AppIconSelection = {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
}
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,
icon: snippet.icon
? {
type: 'emoji' as const,
icon: snippet.icon,
background: snippet.iconBackground || FALLBACK_ICON.background,
}
: FALLBACK_ICON,
}), [snippet.description, snippet.icon, snippet.iconBackground, 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, icon }: {
name: string
description: string
icon: AppIconSelection
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : 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"
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-[400px]">
<div className="space-y-2 p-6">
<AlertDialogTitle className="title-lg-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,55 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
return (
<div className={cn('flex flex-col', expand ? 'px-2 pt-2 pb-1' : 'p-1')}>
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
<div className={cn('shrink-0', !expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
icon={snippet.icon}
background={snippet.iconBackground}
/>
</div>
{expand && <SnippetInfoDropdown snippet={snippet} />}
</div>
{expand && (
<div className="min-w-0">
<div className="system-md-semibold truncate text-text-secondary">
{snippet.name}
</div>
<div className="system-2xs-medium-uppercase pt-1 text-text-tertiary">
{t('typeLabel')}
</div>
</div>
)}
{expand && snippet.description && (
<p className="system-xs-regular line-clamp-3 break-words text-text-tertiary">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@ -3,7 +3,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import AppPublisher from '../index'
@ -20,7 +20,11 @@ const mockOpenAsyncWindow = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockToastError = vi.fn()
const mockToastSuccess = vi.fn()
const mockConvertWorkflowType = vi.fn()
const mockRefetchEvaluationWorkflowAssociatedTargets = vi.fn()
const mockInvalidateAppWorkflow = vi.fn()
let mockCanAccessSnippetsAndEvaluation = true
const sectionProps = vi.hoisted(() => ({
summary: null as null | Record<string, any>,
@ -32,6 +36,7 @@ const ahooksMocks = vi.hoisted(() => ({
}))
let mockAppDetail: Record<string, any> | null = null
let mockEvaluationWorkflowAssociatedTargets: Record<string, any> | undefined
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@ -64,6 +69,13 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
useSnippetAndEvaluationPlanAccess: () => ({
canAccess: mockCanAccessSnippetsAndEvaluation,
isReady: true,
}),
}))
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
@ -87,6 +99,21 @@ vi.mock('@/service/apps', () => ({
publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args),
}))
vi.mock('@/service/use-apps', () => ({
useConvertWorkflowTypeMutation: () => ({
mutateAsync: (...args: unknown[]) => mockConvertWorkflowType(...args),
isPending: false,
}),
}))
vi.mock('@/service/use-evaluation', () => ({
useEvaluationWorkflowAssociatedTargets: () => ({
data: mockEvaluationWorkflowAssociatedTargets,
refetch: mockRefetchEvaluationWorkflowAssociatedTargets,
isFetching: false,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow,
}))
@ -94,6 +121,7 @@ vi.mock('@/service/use-workflow', () => ({
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
success: (...args: unknown[]) => mockToastSuccess(...args),
},
}))
@ -127,15 +155,15 @@ vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext.Provider value={open}>
<OpenContext value={open}>
<div>{children}</div>
</OpenContext.Provider>
</OpenContext>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = ReactModule.useContext(OpenContext)
const open = ReactModule.use(OpenContext)
return open ? <div>{children}</div> : null
},
}
@ -148,6 +176,7 @@ vi.mock('../sections', () => ({
<div>
<button onClick={() => void props.handlePublish()}>publisher-summary-publish</button>
<button onClick={() => void props.handleRestore()}>publisher-summary-restore</button>
<button onClick={() => void props.onWorkflowTypeSwitch()}>publisher-switch-workflow-type</button>
</div>
)
},
@ -173,11 +202,13 @@ describe('AppPublisher', () => {
sectionProps.summary = null
sectionProps.access = null
sectionProps.actions = null
mockCanAccessSnippetsAndEvaluation = true
mockAppDetail = {
id: 'app-1',
name: 'Demo App',
mode: AppModeEnum.CHAT,
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
type: AppTypeEnum.WORKFLOW,
site: {
app_base_url: 'https://example.com',
access_token: 'token-1',
@ -190,6 +221,12 @@ describe('AppPublisher', () => {
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
mockConvertWorkflowType.mockResolvedValue({})
mockEvaluationWorkflowAssociatedTargets = { items: [] }
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValue({
data: { items: [] },
isError: false,
})
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
await resolver()
})
@ -525,4 +562,278 @@ describe('AppPublisher', () => {
})
expect(screen.getByTestId('access-control'))!.toBeInTheDocument()
})
it('should switch workflow type, refresh app detail, and close the popover for published apps', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
mockFetchAppDetailDirect.mockResolvedValueOnce({
id: 'app-1',
workflow_kind: AppTypeEnum.EVALUATION,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.EVALUATION },
})
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith({
id: 'app-1',
workflow_kind: AppTypeEnum.EVALUATION,
})
expect(mockToastSuccess).toHaveBeenCalledWith('api.actionSuccess')
})
await waitFor(() => {
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
})
})
it('should publish an unpublished workflow as evaluation workflow through the evaluation publish endpoint', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
mockOnPublish.mockResolvedValue(undefined)
mockFetchAppDetailDirect.mockResolvedValueOnce({
id: 'app-1',
workflow_kind: AppTypeEnum.EVALUATION,
})
render(
<AppPublisher
onPublish={mockOnPublish}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockOnPublish).toHaveBeenCalledWith({
url: '/apps/app-1/workflows/publish/evaluation',
title: '',
releaseNotes: '',
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith({
id: 'app-1',
workflow_kind: AppTypeEnum.EVALUATION,
})
expect(mockToastSuccess).toHaveBeenCalledWith('api.actionSuccess')
})
})
it('should hide access and actions sections for evaluation workflow apps', () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
workflow_kind: AppTypeEnum.EVALUATION,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
expect(screen.queryByText('publisher-access-control')).not.toBeInTheDocument()
expect(screen.queryByText('publisher-embed')).not.toBeInTheDocument()
expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({
targetType: AppTypeEnum.WORKFLOW,
publishLabelKey: 'common.publishAsStandardWorkflow',
switchLabelKey: 'common.switchToStandardWorkflow',
tipKey: 'common.switchToStandardWorkflowTip',
})
})
it('should confirm before switching an evaluation workflow with associated targets to a standard workflow', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
workflow_kind: AppTypeEnum.EVALUATION,
}
mockEvaluationWorkflowAssociatedTargets = {
items: [
{
target_type: 'app',
target_id: 'dependent-app-1',
target_name: 'Dependent App',
},
{
target_type: 'knowledge_base',
target_id: 'knowledge-1',
target_name: 'Knowledge Base',
},
],
}
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
data: mockEvaluationWorkflowAssociatedTargets,
isError: false,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockRefetchEvaluationWorkflowAssociatedTargets).toHaveBeenCalledTimes(1)
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
expect(screen.getByText('Dependent App')).toBeInTheDocument()
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.switchToStandardWorkflowConfirm.switch' }))
await waitFor(() => {
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.WORKFLOW },
})
expect(mockToastSuccess).toHaveBeenCalledWith('api.actionSuccess')
})
})
it('should switch an evaluation workflow directly when there are no associated targets', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
workflow_kind: AppTypeEnum.EVALUATION,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockRefetchEvaluationWorkflowAssociatedTargets).toHaveBeenCalledTimes(1)
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.WORKFLOW },
})
expect(mockToastSuccess).toHaveBeenCalledWith('api.actionSuccess')
})
expect(screen.queryByText('common.switchToStandardWorkflowConfirm.title')).not.toBeInTheDocument()
})
it('should block switching an evaluation workflow when associated targets fail to load', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
workflow_kind: AppTypeEnum.EVALUATION,
}
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
data: undefined,
isError: true,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('common.switchToStandardWorkflowConfirm.loadFailed')
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
})
it('should block switching to evaluation workflow when restricted nodes exist', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
render(
<AppPublisher
publishedAt={Date.now()}
hasHumanInputNode
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('common.switchToEvaluationWorkflowDisabledTip')
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
expect(sectionProps.summary?.workflowTypeSwitchDisabled).toBe(true)
expect(sectionProps.summary?.workflowTypeSwitchDisabledReason).toBe('common.switchToEvaluationWorkflowDisabledTip')
})
it('should keep the evaluation workflow switch visible but disabled when the current plan cannot access it', () => {
mockCanAccessSnippetsAndEvaluation = false
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({
targetType: AppTypeEnum.EVALUATION,
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
})
expect(sectionProps.summary?.workflowTypeSwitchDisabled).toBe(true)
expect(sectionProps.summary?.workflowTypeSwitchDisabledReason).toBe('compliance.sandboxUpgradeTooltip')
})
it('should not expose workflow type switching for non-workflow app modes', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(sectionProps.summary?.workflowTypeSwitchConfig).toBeUndefined()
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
})
expect(mockOnPublish).not.toHaveBeenCalled()
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
expect(mockToastSuccess).not.toHaveBeenCalled()
})
})

View File

@ -45,12 +45,14 @@ describe('app-publisher sections', () => {
handleRestore={handleRestore}
isChatApp
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={Date.now()}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
@ -83,12 +85,14 @@ describe('app-publisher sections', () => {
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
@ -107,12 +111,14 @@ describe('app-publisher sections', () => {
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[{ id: '1' } as any]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
@ -131,18 +137,85 @@ describe('app-publisher sections', () => {
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
expect(screen.getByText('publishLimit.startNodeDesc')).toBeInTheDocument()
})
it('should render workflow type switch action and call switch handler', () => {
const onWorkflowTypeSwitch = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={onWorkflowTypeSwitch}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchConfig={{
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
}}
workflowTypeSwitchDisabled={false}
/>,
)
fireEvent.click(screen.getByText('common.publishAsEvaluationWorkflow'))
expect(onWorkflowTypeSwitch).toHaveBeenCalledTimes(1)
})
it('should disable workflow type switch when a disabled reason is provided', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchConfig={{
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
}}
workflowTypeSwitchDisabled
workflowTypeSwitchDisabledReason="common.switchToEvaluationWorkflowDisabledTip"
/>,
)
expect(screen.getByRole('button', { name: /common\.publishAsEvaluationWorkflow/i })).toBeDisabled()
})
it('should render loading access state and access mode labels when enabled', () => {
const { rerender } = render(
<PublisherAccessSection

View File

@ -0,0 +1,158 @@
'use client'
import type { EvaluationWorkflowAssociatedTarget, EvaluationWorkflowAssociatedTargetType } from '@/types/evaluation'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
type EvaluationWorkflowSwitchConfirmDialogProps = {
open: boolean
targets: EvaluationWorkflowAssociatedTarget[]
loading?: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
const TARGET_TYPE_META: Record<EvaluationWorkflowAssociatedTargetType, {
icon: string
iconClassName: string
labelKey: I18nKeysWithPrefix<'workflow', 'common.switchToStandardWorkflowConfirm.targetTypes.'>
href: (targetId: string) => string
}> = {
app: {
icon: 'i-ri-flow-chart',
iconClassName: 'bg-components-icon-bg-teal-soft text-util-colors-teal-teal-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.app',
href: targetId => `/app/${targetId}/workflow`,
},
snippets: {
icon: 'i-ri-edit-2-line',
iconClassName: 'bg-components-icon-bg-violet-soft text-util-colors-violet-violet-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.snippets',
href: targetId => `/snippets/${targetId}/orchestrate`,
},
knowledge_base: {
icon: 'i-ri-book-2-line',
iconClassName: 'bg-components-icon-bg-indigo-soft text-util-colors-blue-blue-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.knowledge_base',
href: targetId => `/datasets/${targetId}/documents`,
},
}
const getTargetMeta = (targetType: EvaluationWorkflowAssociatedTargetType) => {
return TARGET_TYPE_META[targetType] ?? TARGET_TYPE_META.app
}
const DependentTargetItem = ({
target,
}: {
target: EvaluationWorkflowAssociatedTarget
}) => {
const { t } = useTranslation()
const meta = getTargetMeta(target.target_type)
const targetName = target.target_name || target.target_id
return (
<Link
href={meta.href(target.target_id)}
className="group flex w-full items-center gap-3 rounded-lg bg-background-section p-2 hover:bg-background-section-burn"
title={targetName}
target="_blank"
rel="noreferrer"
>
<span
aria-hidden="true"
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular',
meta.iconClassName,
)}
>
<span className={cn(meta.icon, 'size-5')} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 py-px">
<span className="truncate system-md-semibold text-text-secondary">
{targetName}
</span>
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t(meta.labelKey, { ns: 'workflow' })}
</span>
</span>
<span
aria-hidden="true"
className="i-ri-arrow-right-up-line size-3.5 shrink-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100"
/>
</Link>
)
}
const EvaluationWorkflowSwitchConfirmDialog = ({
open,
targets,
loading = false,
onOpenChange,
onConfirm,
}: EvaluationWorkflowSwitchConfirmDialogProps) => {
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="w-[480px]">
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('common.switchToStandardWorkflowConfirm.title', { ns: 'workflow' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular text-text-secondary">
<span className="block">
{t('common.switchToStandardWorkflowConfirm.activeIn', { ns: 'workflow', count: targets.length })}
</span>
<span className="block">
{t('common.switchToStandardWorkflowConfirm.description', { ns: 'workflow' })}
</span>
</AlertDialogDescription>
</div>
<div className="flex flex-col gap-2 px-6 py-3">
<div className="flex items-center gap-2">
<span className="shrink-0 system-xs-medium-uppercase text-text-quaternary">
{t('common.switchToStandardWorkflowConfirm.dependentWorkflows', { ns: 'workflow' })}
</span>
<span className="h-px min-w-0 flex-1 bg-divider-subtle" />
</div>
<div className="flex max-h-[188px] flex-col gap-1 overflow-y-auto">
{targets.map(target => (
<DependentTargetItem
key={`${target.target_type}:${target.target_id}`}
target={target}
/>
))}
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={loading}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={loading}
disabled={loading}
onClick={onConfirm}
>
{t('common.switchToStandardWorkflowConfirm.switch', { ns: 'workflow' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
export default EvaluationWorkflowSwitchConfirmDialog

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

@ -5,7 +5,6 @@ import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { RiStoreLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useKeyPress } from 'ahooks'
import {
@ -25,6 +24,7 @@ import { webSocketClient } from '@/app/components/workflow/collaboration/core/we
import { WorkflowContext } from '@/app/components/workflow/context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect, publishToCreatorsPlatform } from '@/service/apps'
@ -36,18 +36,27 @@ import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import AccessControl from '../app-access-control'
import EvaluationWorkflowSwitchConfirmDialog from './evaluation-workflow-switch-confirm-dialog'
import {
PublisherAccessSection,
PublisherActionsSection,
PublisherSummarySection,
} from './sections'
import SuggestedAction from './suggested-action'
import { useWorkflowTypeSwitch } from './use-workflow-type-switch'
import {
getDisabledFunctionTooltip,
getPublisherAppUrl,
isPublisherAccessConfigured,
} from './utils'
export type AppPublisherPublishParams
= | ModelAndParameter
| (Pick<PublishWorkflowParams, 'title' | 'releaseNotes'> & {
url?: string
id?: string
})
export type AppPublisherProps = {
disabled?: boolean
publishDisabled?: boolean
@ -57,8 +66,8 @@ export type AppPublisherProps = {
debugWithMultipleModel?: boolean
multipleModelConfigs?: ModelAndParameter[]
/** modelAndParameter is passed when debugWithMultipleModel is true */
onPublish?: (params?: any) => Promise<any> | any
onRestore?: () => Promise<any> | any
onPublish?: (params?: AppPublisherPublishParams) => Promise<unknown> | unknown
onRestore?: () => Promise<unknown> | unknown
onToggle?: (state: boolean) => void
crossAxisOffset?: number
toolPublished?: boolean
@ -107,6 +116,7 @@ const AppPublisher = ({
const workflowStore = useContext(WorkflowContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
@ -140,7 +150,7 @@ const AppPublisher = ({
refetch()
}, [open, appDetail, refetch, systemFeatures])
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
const handlePublish = useCallback(async (params?: AppPublisherPublishParams) => {
try {
await onPublish?.(params)
setPublished(true)
@ -222,6 +232,34 @@ const AppPublisher = ({
}
}, [appDetail, setAppDetail])
const handlePublishedWorkflowTypeSwitch = useCallback(() => {
setOpen(false)
}, [])
const {
evaluationWorkflowSwitchTargets,
handleEvaluationWorkflowSwitchConfirmOpenChange,
handleWorkflowTypeSwitch,
isConvertingWorkflowType,
isEvaluationWorkflowType,
performWorkflowTypeSwitch,
showEvaluationWorkflowSwitchConfirm,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabled,
workflowTypeSwitchDisabledReason,
} = useWorkflowTypeSwitch({
appDetail,
canAccessSnippetsAndEvaluation,
hasHumanInputNode,
hasTriggerNode,
onPublish: handlePublish,
onPublishedSwitch: handlePublishedWorkflowTypeSwitch,
published,
publishedAt,
publishDisabled,
setAppDetail,
})
const handlePublishToMarketplace = useCallback(async () => {
if (!appDetail?.id || publishingToMarketplace)
return
@ -319,55 +357,63 @@ const AppPublisher = ({
publishShortcut={PUBLISH_SHORTCUT}
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
workflowTypeSwitchConfig={workflowTypeSwitchConfig}
workflowTypeSwitchDisabled={workflowTypeSwitchDisabled}
workflowTypeSwitchDisabledReason={workflowTypeSwitchDisabledReason}
onWorkflowTypeSwitch={handleWorkflowTypeSwitch}
/>
<PublisherAccessSection
enabled={systemFeatures.webapp_auth.enabled}
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
accessMode={appDetail?.access_mode}
onClick={() => {
handleOpenChange(false)
setShowAppAccessControl(true)
}}
/>
<PublisherActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
setEmbeddingModalOpen(true)
handleOpenChange(false)
}}
handleOpenInExplore={() => {
handleOpenChange(false)
handleOpenInExplore()
}}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolMessage={workflowToolMessage}
/>
{systemFeatures.enable_creators_platform && (
<div className="border-t border-divider-subtle p-4">
<SuggestedAction
icon={<RiStoreLine className="h-4 w-4" />}
disabled={!publishedAt || publishingToMarketplace}
onClick={handlePublishToMarketplace}
>
{publishingToMarketplace
? t('common.publishingToMarketplace', { ns: 'workflow' })
: t('common.publishToMarketplace', { ns: 'workflow' })}
</SuggestedAction>
</div>
{!isEvaluationWorkflowType && (
<>
<PublisherAccessSection
enabled={systemFeatures.webapp_auth.enabled}
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
accessMode={appDetail?.access_mode}
onClick={() => {
setShowAppAccessControl(true)
handleOpenChange(false)
}}
/>
<PublisherActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
setEmbeddingModalOpen(true)
handleOpenChange(false)
}}
handleOpenInExplore={() => {
handleOpenChange(false)
handleOpenInExplore()
}}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolMessage={workflowToolMessage}
/>
{systemFeatures.enable_creators_platform && (
<div className="border-t border-divider-subtle p-4">
<SuggestedAction
icon={<span className="i-ri-store-line h-4 w-4" />}
disabled={!publishedAt || publishingToMarketplace}
onClick={handlePublishToMarketplace}
>
{publishingToMarketplace
? t('common.publishingToMarketplace', { ns: 'workflow' })
: t('common.publishToMarketplace', { ns: 'workflow' })}
</SuggestedAction>
</div>
)}
</>
)}
</div>
</PopoverContent>
@ -380,6 +426,13 @@ const AppPublisher = ({
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
</Popover>
<EvaluationWorkflowSwitchConfirmDialog
open={showEvaluationWorkflowSwitchConfirm}
targets={evaluationWorkflowSwitchTargets}
loading={isConvertingWorkflowType}
onOpenChange={handleEvaluationWorkflowSwitchConfirmOpenChange}
onConfirm={() => void performWorkflowTypeSwitch()}
/>
</>
)
}

View File

@ -1,6 +1,7 @@
import type { CSSProperties, ReactNode } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type { AppPublisherProps } from './index'
import type { WorkflowTypeSwitchConfig } from './use-workflow-type-switch'
import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import {
@ -10,7 +11,6 @@ import {
} from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
import Loading from '@/app/components/base/loading'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
@ -31,9 +31,13 @@ type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel'
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
handleRestore: () => Promise<void>
isChatApp: boolean
onWorkflowTypeSwitch: () => Promise<void>
published: boolean
publishShortcut: string[]
upgradeHighlightStyle: CSSProperties
workflowTypeSwitchConfig?: WorkflowTypeSwitchConfig
workflowTypeSwitchDisabled: boolean
workflowTypeSwitchDisabledReason?: string
}
type AccessSectionProps = {
@ -90,6 +94,28 @@ export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MA
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip)
return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export const PublisherSummarySection = ({
debugWithMultipleModel = false,
draftUpdatedAt,
@ -98,12 +124,16 @@ export const PublisherSummarySection = ({
handleRestore,
isChatApp,
multipleModelConfigs = [],
onWorkflowTypeSwitch,
publishDisabled = false,
published,
publishedAt,
publishShortcut,
startNodeLimitExceeded = false,
upgradeHighlightStyle,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabled,
workflowTypeSwitchDisabledReason,
}: SummarySectionProps) => {
const { t } = useTranslation()
@ -164,6 +194,47 @@ export const PublisherSummarySection = ({
</div>
)}
</Button>
{workflowTypeSwitchConfig && (
<ActionTooltip disabled={workflowTypeSwitchDisabled} tooltip={workflowTypeSwitchDisabledReason}>
<Button
variant="ghost"
className="mt-1 w-full gap-0.5 px-3 text-text-tertiary"
onClick={() => void onWorkflowTypeSwitch()}
disabled={workflowTypeSwitchDisabled}
>
<span className="px-0.5">
{t(
publishedAt
? workflowTypeSwitchConfig.switchLabelKey
: workflowTypeSwitchConfig.publishLabelKey,
{ ns: 'workflow' },
)}
</span>
<Tooltip>
<TooltipTrigger
render={(
<span
className="flex h-4 w-4 items-center justify-center text-text-quaternary hover:text-text-tertiary"
aria-label={t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<span className="i-ri-question-line h-3.5 w-3.5" />
</span>
)}
/>
<TooltipContent
placement="top"
className="w-[180px]"
>
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
</Button>
</ActionTooltip>
)}
{startNodeLimitExceeded && (
<div className="mt-3 flex flex-col items-stretch">
<p
@ -227,28 +298,6 @@ export const PublisherAccessSection = ({
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip)
return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export const PublisherActionsSection = ({
appDetail,
appURL,
@ -305,7 +354,7 @@ export const PublisherActionsSection = ({
<SuggestedAction
onClick={handleEmbed}
disabled={!publishedAt}
icon={<CodeBrowser className="h-4 w-4" />}
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
>
{t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction>

View File

@ -0,0 +1,235 @@
import type { ModelAndParameter } from '../configuration/debug/types'
import type { App, AppSSO } from '@/types/app'
import type { EvaluationWorkflowAssociatedTarget } from '@/types/evaluation'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import type { PublishWorkflowParams, WorkflowKind, WorkflowTypeConversionTarget } from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { fetchAppDetailDirect } from '@/service/apps'
import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
import { useEvaluationWorkflowAssociatedTargets } from '@/service/use-evaluation'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
export type WorkflowTypeSwitchConfig = {
targetType: WorkflowTypeConversionTarget
publishLabelKey: WorkflowTypeSwitchLabelKey
switchLabelKey: WorkflowTypeSwitchLabelKey
tipKey: WorkflowTypeSwitchLabelKey
}
const WORKFLOW_TYPE_SWITCH_CONFIG: Record<WorkflowTypeConversionTarget, WorkflowTypeSwitchConfig> = {
workflow: {
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
},
evaluation: {
targetType: 'workflow',
publishLabelKey: 'common.publishAsStandardWorkflow',
switchLabelKey: 'common.switchToStandardWorkflow',
tipKey: 'common.switchToStandardWorkflowTip',
},
} as const
const getWorkflowTypeSwitchConfig = (workflowKind?: WorkflowKind | null) => {
if (!workflowKind || workflowKind === 'standard')
return WORKFLOW_TYPE_SWITCH_CONFIG.workflow
if (workflowKind === 'evaluation')
return WORKFLOW_TYPE_SWITCH_CONFIG.evaluation
}
type UseWorkflowTypeSwitchParams = {
appDetail?: App & Partial<AppSSO>
canAccessSnippetsAndEvaluation: boolean
hasHumanInputNode: boolean
hasTriggerNode: boolean
onPublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
onPublishedSwitch: () => void
published: boolean
publishedAt?: number
publishDisabled: boolean
setAppDetail: (appDetail?: App & Partial<AppSSO>) => void
}
export const useWorkflowTypeSwitch = ({
appDetail,
canAccessSnippetsAndEvaluation,
hasHumanInputNode,
hasTriggerNode,
onPublish,
onPublishedSwitch,
published,
publishedAt,
publishDisabled,
setAppDetail,
}: UseWorkflowTypeSwitchParams) => {
const { t } = useTranslation()
const [showEvaluationWorkflowSwitchConfirm, setShowEvaluationWorkflowSwitchConfirm] = useState(false)
const [evaluationWorkflowSwitchTargets, setEvaluationWorkflowSwitchTargets] = useState<EvaluationWorkflowAssociatedTarget[]>([])
const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation()
const {
refetch: refetchEvaluationWorkflowAssociatedTargets,
isFetching: isFetchingEvaluationWorkflowAssociatedTargets,
} = useEvaluationWorkflowAssociatedTargets(appDetail?.id, { enabled: false })
const workflowTypeSwitchConfig = useMemo(() => {
if (appDetail?.mode !== AppModeEnum.WORKFLOW)
return undefined
return getWorkflowTypeSwitchConfig(appDetail?.workflow_kind)
}, [appDetail?.mode, appDetail?.workflow_kind])
const workflowTypeSwitchDisabledReason = useMemo(() => {
if (workflowTypeSwitchConfig?.targetType !== AppTypeEnum.EVALUATION)
return undefined
if (!canAccessSnippetsAndEvaluation)
return t('compliance.sandboxUpgradeTooltip', { ns: 'common' })
if (!hasHumanInputNode && !hasTriggerNode)
return undefined
return t('common.switchToEvaluationWorkflowDisabledTip', { ns: 'workflow' })
}, [canAccessSnippetsAndEvaluation, hasHumanInputNode, hasTriggerNode, t, workflowTypeSwitchConfig?.targetType])
const getWorkflowTypeSwitchPublishUrl = useCallback(() => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return undefined
if (workflowTypeSwitchConfig.targetType === AppTypeEnum.EVALUATION)
return `/apps/${appDetail.id}/workflows/publish/evaluation`
return `/apps/${appDetail.id}/workflows/publish`
}, [appDetail?.id, workflowTypeSwitchConfig])
const resetEvaluationWorkflowSwitchConfirm = useCallback(() => {
setShowEvaluationWorkflowSwitchConfirm(false)
setEvaluationWorkflowSwitchTargets([])
}, [])
const performWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return false
try {
if (!publishedAt) {
const publishUrl = getWorkflowTypeSwitchPublishUrl()
if (!publishUrl)
return false
await onPublish({
url: publishUrl,
title: '',
releaseNotes: '',
})
const latestAppDetail = await fetchAppDetailDirect({
url: '/apps',
id: appDetail.id,
})
setAppDetail(latestAppDetail)
resetEvaluationWorkflowSwitchConfirm()
toast.success(t('api.actionSuccess', { ns: 'common' }))
return true
}
await convertWorkflowType({
params: {
appId: appDetail.id,
},
query: {
target_type: workflowTypeSwitchConfig.targetType,
},
})
const latestAppDetail = await fetchAppDetailDirect({
url: '/apps',
id: appDetail.id,
})
setAppDetail(latestAppDetail)
onPublishedSwitch()
resetEvaluationWorkflowSwitchConfirm()
toast.success(t('api.actionSuccess', { ns: 'common' }))
return true
}
catch {
return false
}
}, [
appDetail?.id,
convertWorkflowType,
getWorkflowTypeSwitchPublishUrl,
onPublish,
onPublishedSwitch,
publishedAt,
resetEvaluationWorkflowSwitchConfirm,
setAppDetail,
t,
workflowTypeSwitchConfig,
])
const handleWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return
if (workflowTypeSwitchDisabledReason) {
toast.error(workflowTypeSwitchDisabledReason)
return
}
if (appDetail.workflow_kind === AppTypeEnum.EVALUATION && workflowTypeSwitchConfig.targetType === AppTypeEnum.WORKFLOW) {
const associatedTargetsResult = await refetchEvaluationWorkflowAssociatedTargets()
if (associatedTargetsResult.isError) {
toast.error(t('common.switchToStandardWorkflowConfirm.loadFailed', { ns: 'workflow' }))
return
}
const associatedTargets = associatedTargetsResult.data?.items ?? []
if (associatedTargets.length > 0) {
setEvaluationWorkflowSwitchTargets(associatedTargets)
setShowEvaluationWorkflowSwitchConfirm(true)
return
}
}
await performWorkflowTypeSwitch()
}, [
appDetail?.id,
appDetail?.workflow_kind,
performWorkflowTypeSwitch,
refetchEvaluationWorkflowAssociatedTargets,
t,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabledReason,
])
const handleEvaluationWorkflowSwitchConfirmOpenChange = useCallback((nextOpen: boolean) => {
setShowEvaluationWorkflowSwitchConfirm(nextOpen)
if (!nextOpen)
setEvaluationWorkflowSwitchTargets([])
}, [])
return {
evaluationWorkflowSwitchTargets,
handleEvaluationWorkflowSwitchConfirmOpenChange,
handleWorkflowTypeSwitch,
isConvertingWorkflowType,
isEvaluationWorkflowType: appDetail?.workflow_kind === AppTypeEnum.EVALUATION,
performWorkflowTypeSwitch,
showEvaluationWorkflowSwitchConfirm,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabled: publishDisabled
|| published
|| isConvertingWorkflowType
|| isFetchingEvaluationWorkflowAssociatedTargets
|| Boolean(workflowTypeSwitchDisabledReason),
workflowTypeSwitchDisabledReason,
}
}

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

@ -0,0 +1,119 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EvaluationCell from '../evaluation-cell'
describe('EvaluationCell', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render a placeholder when evaluation data is empty', () => {
render(<EvaluationCell evaluation={[]} />)
expect(screen.getByText('-')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'appLog.table.header.evaluation' })).not.toBeInTheDocument()
})
it('should render a placeholder when evaluation data is missing', () => {
render(<EvaluationCell />)
expect(screen.getByText('-')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'appLog.table.header.evaluation' })).not.toBeInTheDocument()
})
it('should render a placeholder when evaluation data is null', () => {
render(<EvaluationCell evaluation={null} />)
expect(screen.getByText('-')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'appLog.table.header.evaluation' })).not.toBeInTheDocument()
})
it('should render a trigger button when evaluation data is available', () => {
render(
<EvaluationCell
evaluation={[{
name: 'Faithfulness',
value: 0.98,
}]}
/>,
)
expect(screen.getByRole('button', { name: 'appLog.table.header.evaluation' })).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should render evaluation details when clicking the trigger', async () => {
const user = userEvent.setup()
render(
<EvaluationCell
evaluation={[{
name: 'Faithfulness',
value: 0.98,
details: {
stubbed: true,
source: 'console-evaluation-run',
value_type: 'number',
},
nodeInfo: {
node_id: 'node-1',
title: 'Knowledge Retrieval',
type: 'knowledge-retrieval',
},
}]}
/>,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByTestId('workflow-log-evaluation-popover')).toBeInTheDocument()
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
expect(screen.getByText('0.98')).toBeInTheDocument()
expect(screen.getByText('Knowledge Retrieval')).toBeInTheDocument()
})
it('should render boolean values using readable text', async () => {
const user = userEvent.setup()
render(
<EvaluationCell
evaluation={[{
name: 'Correctness',
value: true,
}]}
/>,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByText('True')).toBeInTheDocument()
})
it('should render evaluation items with null node info', async () => {
const user = userEvent.setup()
render(
<EvaluationCell
evaluation={[{
name: 'custom_score',
value: 0.95,
details: {
stubbed: true,
source: 'console-evaluation-run',
value_type: 'number',
customized: true,
},
nodeInfo: null,
}]}
/>,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByText('custom_score')).toBeInTheDocument()
expect(screen.getByText('0.95')).toBeInTheDocument()
})
})
})

View File

@ -215,6 +215,7 @@ const createMockWorkflowLog = (overrides: Partial<WorkflowAppLogDetail> = {}): W
},
created_at: Date.now(),
...overrides,
evaluation: overrides.evaluation ?? [],
})
const createMockLogsResponse = (

View File

@ -146,6 +146,7 @@ const createMockWorkflowLog = (overrides: Partial<WorkflowAppLogDetail> = {}): W
email: 'test@example.com',
},
created_at: Date.now(),
evaluation: [],
...overrides,
})
@ -216,6 +217,7 @@ describe('WorkflowAppLogList', () => {
expect(screen.getByText('appLog.table.header.runtime'))!.toBeInTheDocument()
expect(screen.getByText('appLog.table.header.tokens'))!.toBeInTheDocument()
expect(screen.getByText('appLog.table.header.user'))!.toBeInTheDocument()
expect(screen.getByText('appLog.table.header.evaluation'))!.toBeInTheDocument()
})
it('should render trigger column for workflow apps', () => {
@ -523,6 +525,34 @@ describe('WorkflowAppLogList', () => {
// The row should have the selected class
expect(dataRow)!.toHaveClass('bg-background-default-hover')
})
it('should open evaluation popover without opening drawer when clicking evaluation trigger', async () => {
const user = userEvent.setup()
const logs = createMockLogsResponse([
createMockWorkflowLog({
evaluation: [{
name: 'Faithfulness',
value: 0.98,
nodeInfo: {
node_id: 'node-1',
title: 'Knowledge Retrieval',
type: 'knowledge-retrieval',
},
}],
}),
])
render(
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByTestId('workflow-log-evaluation-popover')).toBeInTheDocument()
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
expect(screen.getByText('Knowledge Retrieval')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'appLog.runDetail.workflowTitle' })).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------

View File

@ -0,0 +1,99 @@
'use client'
import type { EvaluationLogItem } from '@/models/log'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getEvaluationNodeBlockType } from '@/app/components/evaluation/components/metric-selector/utils'
import BlockIcon from '@/app/components/workflow/block-icon'
type EvaluationCellProps = {
evaluation?: EvaluationLogItem[] | null
}
const formatEvaluationValue = (value: EvaluationLogItem['value']) => {
if (typeof value === 'boolean')
return value ? 'True' : 'False'
return String(value)
}
const EvaluationCell = ({
evaluation,
}: EvaluationCellProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const evaluationItems = evaluation ?? []
if (!evaluationItems.length) {
return (
<div className="flex items-center justify-center px-2 py-3 system-sm-regular text-text-quaternary">
-
</div>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<button
type="button"
aria-label={t('table.header.evaluation', { ns: 'appLog' })}
data-testid="workflow-log-evaluation-trigger"
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-text-tertiary transition-colors',
'hover:bg-state-base-hover hover:text-text-secondary',
open && 'bg-state-base-hover text-text-secondary',
)}
>
<span aria-hidden="true" className="i-ri-eye-line h-4 w-4" />
</button>
)}
/>
<PopoverContent
placement="left-start"
sideOffset={12}
popupClassName="w-[320px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
<div data-testid="workflow-log-evaluation-popover" className="max-h-[320px] overflow-y-auto bg-components-panel-bg">
{evaluationItems.map((item, index) => (
<div
key={item.nodeInfo ? `${item.name}-${item.nodeInfo.node_id}` : item.name}
className={cn(
'grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3',
index !== evaluationItems.length - 1 && 'border-b border-divider-subtle',
)}
>
<div className="min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{item.name}</div>
{item.nodeInfo && (
<div className="mt-1 flex min-w-0 items-center gap-1.5">
<BlockIcon
type={getEvaluationNodeBlockType(item.nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="truncate system-xs-regular text-text-tertiary">
{item.nodeInfo.title}
</span>
</div>
)}
</div>
<div className="max-w-[120px] text-right system-sm-regular wrap-break-word text-text-secondary">
{formatEvaluationValue(item.value)}
</div>
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}
export default EvaluationCell

View File

@ -2,7 +2,6 @@
import type { FC } from 'react'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunTriggeredFrom } from '@/models/log'
import type { App } from '@/types/app'
import { ArrowDownIcon } from '@heroicons/react/24/outline'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useEffect, useState } from 'react'
@ -14,6 +13,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp'
import { AppModeEnum } from '@/types/app'
import DetailPanel from './detail'
import EvaluationCell from './evaluation-cell'
import TriggerByDisplay from './trigger-by-display'
type ILogs = {
@ -118,22 +118,21 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
return (
<div className="overflow-x-auto">
<table className={cn('mt-2 w-full min-w-[440px] border-collapse border-0')}>
<table className={cn('mt-2 w-full min-w-[560px] border-collapse border-0')}>
<thead className="system-xs-medium-uppercase text-text-tertiary">
<tr>
<td className="w-5 rounded-l-lg bg-background-section-burn pr-1 pl-2 whitespace-nowrap"></td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">
<div className="flex cursor-pointer items-center hover:text-text-secondary" onClick={handleSort}>
{t('table.header.startTime', { ns: 'appLog' })}
<ArrowDownIcon
className={cn('ml-0.5 h-3 w-3 stroke-current stroke-2 transition-all', 'text-text-tertiary', sortOrder === 'asc' ? 'rotate-180' : '')}
/>
<span className={cn('i-heroicons-arrow-down', 'ml-0.5 h-3 w-3 stroke-current stroke-2 transition-all', 'text-text-tertiary', sortOrder === 'asc' ? 'rotate-180' : '')} />
</div>
</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.status', { ns: 'appLog' })}</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.runtime', { ns: 'appLog' })}</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.tokens', { ns: 'appLog' })}</td>
<td className={cn('bg-background-section-burn py-1.5 pl-3 whitespace-nowrap', !isWorkflow ? 'rounded-r-lg' : '')}>{t('table.header.user', { ns: 'appLog' })}</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.user', { ns: 'appLog' })}</td>
<td className={cn('bg-background-section-burn py-1.5 px-3 whitespace-nowrap text-center', !isWorkflow ? 'rounded-r-lg' : '')}>{t('table.header.evaluation', { ns: 'appLog' })}</td>
{isWorkflow && <td className="rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.triggered_from', { ns: 'appLog' })}</td>}
</tr>
</thead>
@ -172,6 +171,9 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
{endUser}
</div>
</td>
<td className="p-2 pr-2" onClick={event => event.stopPropagation()}>
<EvaluationCell evaluation={log.evaluation} />
</td>
{isWorkflow && (
<td className="p-3 pr-2">
<TriggerByDisplay triggeredFrom={log.workflow_run.triggered_from as WorkflowRunTriggeredFrom} triggerMetadata={log.details?.trigger_metadata} />

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

@ -1,4 +1,4 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
@ -16,19 +16,40 @@ vi.mock('@/next/navigation', () => ({
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
const mockCanAccessSnippetsAndEvaluation = vi.fn(() => true)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: false },
},
}),
}))
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
useSnippetAndEvaluationPlanAccess: () => ({
canAccess: mockCanAccessSnippetsAndEvaluation(),
isReady: true,
}),
}))
const mockSetQuery = vi.fn()
const mockQueryState = {
tagIDs: [] as string[],
creatorIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
@ -38,6 +59,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
mockOnDSLFileDropped = onDSLFileDropped
@ -47,11 +69,15 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
const mockFetchSnippetNextPage = vi.fn()
const mockUseInfiniteAppList = vi.fn()
const mockUseInfiniteSnippetList = vi.fn()
const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
@ -90,21 +116,86 @@ const defaultAppData = {
}
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
error: mockServiceState.error,
refetch: mockRefetch,
}),
useInfiniteAppList: (params: unknown, options: unknown) => {
mockUseInfiniteAppList(params, options)
return {
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
error: mockServiceState.error,
refetch: mockRefetch,
}
},
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
const mockSnippetServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
const defaultSnippetData = {
pages: [{
data: [
{
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
type: 'node',
is_published: false,
use_count: 19,
icon_info: {
icon_type: 'emoji',
icon: '🪄',
icon_background: '#E0EAFF',
icon_url: '',
},
created_at: 1704067200,
created_by: 'user-1',
updated_at: 1704153600,
updated_by: 'user-2',
},
],
total: 1,
}],
}
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: (params: unknown, options: unknown) => {
mockUseInfiniteSnippetList(params, options)
return {
data: defaultSnippetData,
isLoading: mockSnippetServiceState.isLoading,
isFetching: mockSnippetServiceState.isFetching,
isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage,
fetchNextPage: mockFetchSnippetNextPage,
hasNextPage: mockSnippetServiceState.hasNextPage,
error: mockSnippetServiceState.error,
}
},
useCreateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useImportSnippetDSLMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useConfirmSnippetImportMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
}))
@ -117,6 +208,17 @@ vi.mock('@/config', async (importOriginal) => {
}
})
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-2', name: 'Alice', email: 'alice@example.com', avatar: '', avatar_url: '', role: 'admin', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
@ -130,13 +232,21 @@ vi.mock('@/next/dynamic', () => ({
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
return React.createElement(
'div',
{ 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
)
}
}
return () => null
},
}))
@ -154,8 +264,8 @@ vi.mock('../new-app-card', () => ({
}))
vi.mock('../empty', () => ({
default: () => {
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
default: ({ message }: { message: string }) => {
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, message)
},
}))
@ -187,133 +297,109 @@ 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 = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
return renderWithNuqs(<SystemFeaturesWrapper><List {...props} /></SystemFeaturesWrapper>, { searchParams })
}
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
defaultSnippetData.pages[0]!.data = [
{
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
type: 'node',
is_published: false,
use_count: 19,
icon_info: {
icon_type: 'emoji',
icon: '🪄',
icon_background: '#E0EAFF',
icon_url: '',
},
created_at: 1704067200,
created_by: 'user-1',
updated_at: 1704153600,
updated_by: 'user-2',
},
]
defaultSnippetData.pages[0]!.total = 1
useTagStore.setState({
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
showTagManagementModal: false,
})
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
mockCanAccessSnippetsAndEvaluation.mockReturnValue(true)
mockDragging = false
mockOnDSLFileDropped = null
mockServiceState.error = null
mockServiceState.hasNextPage = false
mockServiceState.isLoading = false
mockServiceState.isFetching = false
mockServiceState.isFetchingNextPage = false
mockQueryState.tagIDs = []
mockQueryState.creatorIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockSnippetServiceState.error = null
mockSnippetServiceState.hasNextPage = false
mockSnippetServiceState.isLoading = false
mockSnippetServiceState.isFetching = false
mockSnippetServiceState.isFetchingNextPage = false
mockUseInfiniteAppList.mockClear()
mockUseInfiniteSnippetList.mockClear()
intersectionCallback = null
localStorage.clear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
describe('Apps Mode', () => {
it('should render the apps route switch, dropdown filters, and app cards', () => {
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.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.allCreators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should render search input', () => {
renderList()
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should render tag filter', () => {
renderList()
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
})
it('should render created by me checkbox', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
renderList()
expect(screen.getByTestId('app-card-app-1'))!.toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2'))!.toBeInTheDocument()
})
it('should render new app card for editors', () => {
renderList()
expect(screen.getByTestId('new-app-card'))!.toBeInTheDocument()
})
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer'))!.toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp'))!.toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should update URL when workflow tab is clicked', async () => {
it('should update the category query when selecting an app type from the dropdown', async () => {
const { onUrlUpdate } = renderList()
fireEvent.click(screen.getByText('app.types.workflow'))
fireEvent.click(screen.getByText('app.studio.filters.types'))
fireEvent.click(await screen.findByText('app.types.workflow'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
})
it('should update URL when all tab is clicked', async () => {
const { onUrlUpdate } = renderList('?category=workflow')
fireEvent.click(screen.getByText('app.types.all'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
// nuqs removes the default value ('all') from URL params
expect(lastCall.searchParams.has('category')).toBe(false)
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
renderList()
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should handle search input change', () => {
it('should update creatorIDs when selecting a creator from the dropdown', async () => {
renderList()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
fireEvent.click(screen.getByText('app.studio.filters.allCreators'))
fireEvent.click(await screen.findByText('Current User'))
expect(mockSetQuery).toHaveBeenCalled()
expect(mockSetQuery).toHaveBeenCalledTimes(1)
})
it('should handle search clear button click', () => {
mockQueryState.keywords = 'existing search'
it('should pass creator_id to the app list query when creatorIDs are selected', () => {
mockQueryState.creatorIDs = ['user-1', 'user-2']
renderList()
expect(mockUseInfiniteAppList).toHaveBeenCalledWith(expect.objectContaining({
creator_id: 'user-1,user-2',
}), expect.any(Object))
const clearButton = document.querySelector('.group')
expect(clearButton)!.toBeInTheDocument()
if (clearButton)
@ -505,19 +591,40 @@ describe('List', () => {
expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should close DSL modal and refetch when onSuccess is called', () => {
it('should hide the snippets route switch when snippet access is unavailable', () => {
mockCanAccessSnippetsAndEvaluation.mockReturnValue(false)
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
expect(screen.queryByRole('link', { name: 'workflow.tabs.snippets' })).not.toBeInTheDocument()
})
})
describe('Snippets Mode', () => {
it('should render the snippets create card and snippet card from the real query hook', () => {
renderList({ pageType: 'snippets' })
expect(screen.getByText('snippet.create')).toBeInTheDocument()
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
})
it('should request the next snippet page when the infinite-scroll anchor intersects', () => {
mockSnippetServiceState.hasNextPage = true
renderList({ pageType: 'snippets' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(mockFetchSnippetNextPage).toHaveBeenCalled()
expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('success-dsl-modal'))
@ -544,40 +651,43 @@ describe('List', () => {
expect(mockFetchNextPage).toHaveBeenCalled()
})
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
renderList()
it('should not render app-only controls in snippets mode', () => {
renderList({ pageType: 'snippets' })
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
})
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
renderList()
it('should pass creator_id to the snippet list query when creatorIDs are selected', () => {
mockQueryState.creatorIDs = ['user-1', 'user-2']
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
renderList({ pageType: 'snippets' })
expect(mockFetchNextPage).not.toHaveBeenCalled()
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith(expect.objectContaining({
creator_id: 'user-1,user-2',
}), expect.any(Object))
})
it('should not fetch the next snippet page when no more data is available', () => {
renderList({ pageType: 'snippets' })
act(() => {
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(mockFetchSnippetNextPage).not.toHaveBeenCalled()
})
it('should reuse the shared empty state when no snippets are available', () => {
defaultSnippetData.pages[0]!.data = []
defaultSnippetData.pages[0]!.total = 0
renderList({ pageType: 'snippets' })
expect(screen.getByTestId('empty-state')).toHaveTextContent('workflow.tabs.noSnippetsFound')
})
})
describe('Error State', () => {
it('should handle error state in useEffect', () => {
mockServiceState.error = new Error('Test error')

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,72 @@
'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 gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
type AppTypeFilterProps = {
activeTab: AppListCategory
onChange: (value: AppListCategory) => void
}
const AppTypeFilter = ({
activeTab,
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 === activeTab)
const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, activeTab !== 'all' && 'shadow-xs')}
/>
)}
>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
<span>{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={activeTab} onValueChange={value => isAppListCategory(value) && onChange(value)}>
{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>
)
}
export default AppTypeFilter

View File

@ -0,0 +1,219 @@
'use client'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
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={(
<div
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">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
{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-[240px] 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}
onCheck={() => undefined}
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

@ -1,5 +1,4 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
const DefaultCards = React.memo(() => {
const renderArray = Array.from({ length: 36 })
@ -17,15 +16,17 @@ const DefaultCards = React.memo(() => {
)
})
const Empty = () => {
const { t } = useTranslation()
type Props = {
message: string
}
const Empty = ({ message }: Props) => {
return (
<>
<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}
</span>
</div>
</>

View File

@ -23,6 +23,7 @@ describe('useAppsQueryState', () => {
const { result } = renderWithAdapter()
expect(result.current.query.tagIDs).toBeUndefined()
expect(result.current.query.creatorIDs).toBeUndefined()
expect(result.current.query.keywords).toBeUndefined()
expect(result.current.query.isCreatedByMe).toBe(false)
})
@ -41,6 +42,12 @@ describe('useAppsQueryState', () => {
expect(result.current.query.keywords).toBe('search term')
})
it('should parse creatorIDs when URL includes creatorIDs', () => {
const { result } = renderWithAdapter('?creatorIDs=user-1;user-2')
expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
})
it('should parse isCreatedByMe when URL includes true value', () => {
const { result } = renderWithAdapter('?isCreatedByMe=true')
@ -49,10 +56,11 @@ describe('useAppsQueryState', () => {
it('should parse all params when URL includes multiple filters', () => {
const { result } = renderWithAdapter(
'?tagIDs=tag1;tag2&keywords=test&isCreatedByMe=true',
'?tagIDs=tag1;tag2&creatorIDs=user-1;user-2&keywords=test&isCreatedByMe=true',
)
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
expect(result.current.query.keywords).toBe('test')
expect(result.current.query.isCreatedByMe).toBe(true)
})
@ -79,6 +87,16 @@ describe('useAppsQueryState', () => {
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
})
it('should update creatorIDs when setQuery receives creatorIDs', () => {
const { result } = renderWithAdapter()
act(() => {
result.current.setQuery({ creatorIDs: ['user-1', 'user-2'] })
})
expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
})
it('should update isCreatedByMe when setQuery receives true', () => {
const { result } = renderWithAdapter()
@ -131,6 +149,18 @@ describe('useAppsQueryState', () => {
expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
})
it('should sync creatorIDs to URL when creatorIDs change', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setQuery({ creatorIDs: ['user-1', 'user-2'] })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('creatorIDs')).toBe('user-1;user-2')
})
it('should sync isCreatedByMe to URL when enabled', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
@ -167,6 +197,18 @@ describe('useAppsQueryState', () => {
expect(update.searchParams.has('tagIDs')).toBe(false)
})
it('should remove creatorIDs from URL when creatorIDs are empty', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?creatorIDs=user-1;user-2')
act(() => {
result.current.setQuery({ creatorIDs: [] })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('creatorIDs')).toBe(false)
})
it('should remove isCreatedByMe from URL when disabled', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
@ -212,12 +254,17 @@ describe('useAppsQueryState', () => {
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
})
act(() => {
result.current.setQuery(prev => ({ ...prev, creatorIDs: ['user-1'] }))
})
act(() => {
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
})
expect(result.current.query.keywords).toBe('first')
expect(result.current.query.tagIDs).toEqual(['tag1'])
expect(result.current.query.creatorIDs).toEqual(['user-1'])
expect(result.current.query.isCreatedByMe).toBe(true)
})
})

View File

@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react'
type AppsQuery = {
tagIDs?: string[]
creatorIDs?: string[]
keywords?: string
isCreatedByMe?: boolean
}
@ -13,6 +14,7 @@ function useAppsQueryState() {
const [urlQuery, setUrlQuery] = useQueryStates(
{
tagIDs: parseAsArrayOf(parseAsString, ';'),
creatorIDs: parseAsArrayOf(parseAsString, ';'),
keywords: parseAsString,
isCreatedByMe: parseAsBoolean,
},
@ -23,15 +25,18 @@ function useAppsQueryState() {
const query = useMemo<AppsQuery>(() => ({
tagIDs: urlQuery.tagIDs ?? undefined,
creatorIDs: urlQuery.creatorIDs ?? undefined,
keywords: normalizeKeywords(urlQuery.keywords),
isCreatedByMe: urlQuery.isCreatedByMe ?? false,
}), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
}), [urlQuery.creatorIDs, urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => {
const buildPatch = (patch: AppsQuery) => {
const result: Partial<typeof urlQuery> = {}
if ('tagIDs' in patch)
result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null
if ('creatorIDs' in patch)
result.creatorIDs = patch.creatorIDs && patch.creatorIDs.length > 0 ? patch.creatorIDs : null
if ('keywords' in patch)
result.keywords = patch.keywords ? patch.keywords : null
if ('isCreatedByMe' in patch)
@ -42,6 +47,7 @@ function useAppsQueryState() {
if (typeof next === 'function') {
setUrlQuery(prev => buildPatch(next({
tagIDs: prev.tagIDs ?? undefined,
creatorIDs: prev.creatorIDs ?? undefined,
keywords: normalizeKeywords(prev.keywords),
isCreatedByMe: prev.isCreatedByMe ?? false,
})))

View File

@ -14,19 +14,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)
@ -146,7 +156,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,33 +1,40 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { WorkflowOnlineUser } from '@/models/app'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { CheckModal } from '@/hooks/use-pay'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import dynamic from '@/next/dynamic'
import { fetchWorkflowOnlineUsers } from '@/service/apps'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum, AppModes } from '@/types/app'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import { AppModeEnum } from '@/types/app'
import SnippetCard from '../snippets/components/snippet-card'
import SnippetCreateCard from '../snippets/components/snippet-create-card'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import AppTypeFilter from './app-type-filter'
import { parseAsAppListCategory } from './app-type-filter-shared'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import NewAppCard from './new-app-card'
import StudioRouteSwitch from './studio-route-switch'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false,
@ -36,25 +43,18 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const isAppsPage = pageType === 'apps'
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@ -63,20 +63,28 @@ const List: FC<Props> = ({
parseAsAppListCategory,
)
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const { query: { tagIDs = [], creatorIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [searchKeywords, setSearchKeywords] = useState(keywords)
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [appKeywords, setAppKeywords] = useState(keywords)
const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('')
const [snippetKeywords, setSnippetKeywords] = useState('')
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const newAppCardRef = useRef<HTMLDivElement>(null)
const [workflowOnlineUsersMap, setWorkflowOnlineUsersMap] = useState<Record<string, WorkflowOnlineUser[]>>({})
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs }))
const setTagIDs = useCallback((nextTagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
}, [setQuery])
const setCreatorIDs = useCallback((nextCreatorIDs: string[]) => {
setQuery(prev => ({ ...prev, creatorIDs: nextCreatorIDs }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
@ -87,15 +95,16 @@ const List: FC<Props> = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: isCurrentWorkspaceEditor,
enabled: isAppsPage && isCurrentWorkspaceEditor,
})
const appListQueryParams = {
page: 1,
limit: 30,
name: searchKeywords,
name: appKeywords,
tag_ids: tagIDs,
is_created_by_me: isCreatedByMe,
is_created_by_me: queryIsCreatedByMe,
...(creatorIDs.length > 0 ? { creator_id: creatorIDs.join(',') } : {}),
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
@ -108,84 +117,121 @@ const List: FC<Props> = ({
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
} = useInfiniteAppList(appListQueryParams, {
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
})
const {
data: snippetData,
isLoading: isSnippetListLoading,
isFetching: isSnippetListFetching,
isFetchingNextPage: isSnippetListFetchingNextPage,
fetchNextPage: fetchSnippetNextPage,
hasNextPage: hasSnippetNextPage,
error: snippetError,
} = useInfiniteSnippetList({
page: 1,
limit: 30,
keyword: snippetKeywords || undefined,
creator_id: creatorIDs.length > 0 ? creatorIDs.join(',') : undefined,
}, {
enabled: !isAppsPage,
})
useEffect(() => {
if (controlRefreshList > 0) {
if (isAppsPage && controlRefreshList > 0)
refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
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]" /> },
]
}, [controlRefreshList, isAppsPage, refetch])
useEffect(() => {
if (!isAppsPage)
return
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch()
}
}, [refetch])
}, [isAppsPage, refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true)
const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading
const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage
const currentError = isAppsPage ? error : snippetError
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
if (currentError) {
observer?.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
if (entries[0]!.isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) {
if (isAppsPage)
fetchNextPage()
else
fetchSnippetNextPage()
}
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1, // Trigger when 10% of the anchor element is visible
threshold: 0.1,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
}, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError])
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
const { run: handleAppSearch } = useDebounceFn((value: string) => {
setAppKeywords(value)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const { run: handleTagsUpdate } = useDebounceFn(() => {
setTagIDs(tagFilterValue)
const { run: handleSnippetSearch } = useDebounceFn((value: string) => {
setSnippetKeywords(value)
}, { wait: 500 })
const handleTagsChange = (value: string[]) => {
const handleKeywordsChange = useCallback((value: string) => {
if (isAppsPage) {
setKeywords(value)
handleAppSearch(value)
return
}
setSnippetKeywordsInput(value)
handleSnippetSearch(value)
}, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords])
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
setTagIDs(value)
}, { wait: 500 })
const handleTagsChange = useCallback((value: string[]) => {
setTagFilterValue(value)
handleTagsUpdate()
}
handleTagsUpdate(value)
}, [handleTagsUpdate])
const handleCreatedByMeChange = useCallback(() => {
const newValue = !isCreatedByMe
setIsCreatedByMe(newValue)
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery])
const snippetItems = useMemo(() => {
return (snippetData?.pages ?? []).flatMap(({ data }) => data)
}, [snippetData?.pages])
const showSkeleton = isAppsPage
? (isLoading || (isFetching && data?.pages?.length === 0))
: (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0))
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
const hasAnySnippet = snippetItems.length > 0
const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput
const showEmptyState = !showSkeleton && (isAppsPage ? !hasAnyApp : !hasAnySnippet)
const emptyStateMessage = isAppsPage
? t('newApp.noAppsFound', { ns: 'app' })
: t('tabs.noSnippetsFound', { ns: 'workflow' })
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const workflowOnlineUserAppIds = useMemo(() => {
@ -235,10 +281,6 @@ const List: FC<Props> = ({
return () => window.clearInterval(timer)
}, [refetch, refreshWorkflowOnlineUsers, systemFeatures.enable_collaboration_mode])
const hasAnyApp = (pages[0]?.total ?? 0) > 0
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = isLoading || (isFetching && pages.length === 0)
return (
<>
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
@ -248,72 +290,91 @@ const List: FC<Props> = ({
)}
<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={activeTab}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setActiveTab(nextValue)
}}
options={options}
/>
<div className="flex flex-wrap items-center gap-2">
<StudioRouteSwitch
pageType={pageType}
appsLabel={t('studio.apps', { ns: 'app' })}
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
showSnippets={canAccessSnippetsAndEvaluation}
/>
{isAppsPage && (
<AppTypeFilter
activeTab={activeTab}
onChange={(value) => {
void setActiveTab(value)
}}
/>
)}
<CreatorsFilter value={creatorIDs} onChange={setCreatorIDs} />
{isAppsPage && (
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
)}
</div>
<div className="flex items-center gap-2">
<label className="mr-2 flex h-7 items-center space-x-2">
<Checkbox checked={isCreatedByMe} onCheck={handleCreatedByMeChange} />
<div className="text-sm font-normal text-text-secondary">
{t('showMyCreatedAppsOnly', { ns: 'app' })}
</div>
</label>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
value={currentKeywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
</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',
!hasAnyApp && 'overflow-hidden',
showEmptyState && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
isAppsPage
? (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
)
: canAccessSnippetsAndEvaluation && <SnippetCreateCard />
)}
{(() => {
if (showSkeleton)
return <AppCardSkeleton count={6} />
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refetch}
/>
))
}
{showSkeleton && <AppCardSkeleton count={6} />}
// No apps - show empty state
return <Empty />
})()}
{isFetchingNextPage && (
{!showSkeleton && isAppsPage && hasAnyApp && pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refetch}
/>
))}
{!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => (
<SnippetCard key={snippet.id} snippet={snippet} />
))}
{showEmptyState && <Empty message={emptyStateMessage} />}
{isAppsPage && isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
{!isAppsPage && isSnippetListFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{isCurrentWorkspaceEditor && (
{isAppsPage && isCurrentWorkspaceEditor && (
<div
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
className={cn(
'flex items-center justify-center gap-2 py-4',
dragging ? 'text-text-accent' : 'text-text-quaternary',
)}
role="region"
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
>
@ -321,17 +382,18 @@ const List: FC<Props> = ({
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
)}
{!systemFeatures.branding.enabled && (
<Footer />
)}
<CheckModal />
<div ref={anchorRef} className="h-0"> </div>
{showTagManagementModal && (
{isAppsPage && showTagManagementModal && (
<TagManagementModal type="app" show={showTagManagementModal} />
)}
</div>
{showCreateFromDSLModal && (
{isAppsPage && showCreateFromDSLModal && (
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => {

View File

@ -0,0 +1,48 @@
'use client'
import type { StudioPageType } from '.'
import { cn } from '@langgenius/dify-ui/cn'
import Link from '@/next/link'
type Props = {
pageType: StudioPageType
appsLabel: string
snippetsLabel: string
showSnippets?: boolean
}
const StudioRouteSwitch = ({
pageType,
appsLabel,
snippetsLabel,
showSnippets = true,
}: Props) => {
return (
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-px">
<Link
href="/apps"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'apps' && 'font-medium',
)}
>
{appsLabel}
</Link>
{showSnippets && (
<Link
href="/snippets"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'snippets' && 'font-medium',
)}
>
{snippetsLabel}
</Link>
)}
</div>
)
}
export default StudioRouteSwitch

View File

@ -1,2 +1 @@
export { default as BracketsX } from './BracketsX'
export { default as CodeBrowser } from './CodeBrowser'

View File

@ -0,0 +1,40 @@
'use client'
import type { ReactNode } from 'react'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import { useRouter } from '@/next/navigation'
type SnippetAndEvaluationPlanGuardProps = {
children: ReactNode
fallbackHref: string
}
const SnippetAndEvaluationPlanGuard = ({
children,
fallbackHref,
}: SnippetAndEvaluationPlanGuardProps) => {
const router = useRouter()
const { canAccess, isReady } = useSnippetAndEvaluationPlanAccess()
useEffect(() => {
if (isReady && !canAccess)
router.replace(fallbackHref)
}, [canAccess, fallbackHref, isReady, router])
if (!isReady) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
}
if (!canAccess)
return null
return <>{children}</>
}
export default SnippetAndEvaluationPlanGuard

View File

@ -1,6 +1,7 @@
import type { BasicPlan, BillingQuota, CurrentPlanInfoBackend } from '../type'
import dayjs from 'dayjs'
import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '../type'
/**
* Parse vectorSpace string from ALL_PLANS config and convert to MB
@ -116,3 +117,21 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => {
},
}
}
export const canAccessSnippetsAndEvaluation = ({
enableBilling,
isFetchedPlan,
planType,
}: {
enableBilling: boolean
isFetchedPlan: boolean
planType: Plan
}) => {
if (!isFetchedPlan)
return !enableBilling
if (!enableBilling)
return true
return planType === Plan.professional || planType === Plan.team || planType === Plan.enterprise
}

View File

@ -0,0 +1,34 @@
import { getDefaultMetricDescription, getDefaultMetricDescriptionI18nKey, getTranslatedMetricDescription } from '../default-metric-descriptions'
describe('default metric descriptions', () => {
it('should resolve descriptions for kebab-case metric ids', () => {
expect(getDefaultMetricDescription('context-precision')).toContain('retrieval pipeline returns little noise')
expect(getDefaultMetricDescription('answer-correctness')).toContain('factual accuracy and completeness')
})
it('should normalize snake_case metric ids from backend payloads', () => {
expect(getDefaultMetricDescription('CONTEXT_RECALL')).toContain('does not miss important supporting evidence')
expect(getDefaultMetricDescription('TOOL_CORRECTNESS')).toContain('tool-use strategy matches the expected behavior')
})
it('should support the legacy relevance alias', () => {
expect(getDefaultMetricDescription('relevance')).toContain('addresses the user\'s question')
})
it('should resolve i18n keys for builtin metrics', () => {
expect(getDefaultMetricDescriptionI18nKey('context-precision')).toBe('metrics.builtin.description.contextPrecision')
expect(getDefaultMetricDescriptionI18nKey('ANSWER_RELEVANCY')).toBe('metrics.builtin.description.answerRelevancy')
})
it('should use translated content when translation key exists', () => {
const t = vi.fn((key: string, options?: { defaultValue?: string }) => {
if (key === 'metrics.builtin.description.faithfulness')
return '忠实性中文文案'
return options?.defaultValue ?? key
})
expect(getTranslatedMetricDescription(t as never, 'faithfulness')).toBe('忠实性中文文案')
expect(getTranslatedMetricDescription(t as never, 'latency', 'Latency fallback')).toBe('Latency fallback')
})
})

View File

@ -0,0 +1,747 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import Evaluation from '..'
import ConditionsSection from '../components/conditions-section'
import { useEvaluationStore } from '../store'
const mockUpload = vi.hoisted(() => vi.fn())
const mockUseDatasetEvaluationMetrics = vi.hoisted(() => vi.fn())
const mockUseDefaultEvaluationMetrics = vi.hoisted(() => vi.fn())
const mockUseEvaluationConfig = vi.hoisted(() => vi.fn())
const mockUseSaveEvaluationConfigMutation = vi.hoisted(() => vi.fn())
const mockUseStartEvaluationRunMutation = vi.hoisted(() => vi.fn())
const mockUseEvaluationTemplateColumns = vi.hoisted(() => vi.fn())
const mockUsePublishedPipelineInfo = vi.hoisted(() => vi.fn())
const mockUseSnippetPublishedWorkflow = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({
data: [{
provider: 'openai',
models: [{ model: 'gpt-4o-mini' }],
}],
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({
defaultModel,
onSelect,
}: {
defaultModel?: { provider: string, model: string }
onSelect: (model: { provider: string, model: string }) => void
}) => (
<div>
<div data-testid="evaluation-model-selector">
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
</div>
<button
type="button"
onClick={() => onSelect({ provider: 'openai', model: 'gpt-4o-mini' })}
>
select-model
</button>
</div>
),
}))
vi.mock('@/service/base', () => ({
upload: (...args: unknown[]) => mockUpload(...args),
}))
vi.mock('@/service/use-evaluation', () => ({
useEvaluationConfig: (...args: unknown[]) => mockUseEvaluationConfig(...args),
useDatasetEvaluationMetrics: (...args: unknown[]) => mockUseDatasetEvaluationMetrics(...args),
useDefaultEvaluationMetrics: (...args: unknown[]) => mockUseDefaultEvaluationMetrics(...args),
useSaveEvaluationConfigMutation: (...args: unknown[]) => mockUseSaveEvaluationConfigMutation(...args),
useStartEvaluationRunMutation: (...args: unknown[]) => mockUseStartEvaluationRunMutation(...args),
useEvaluationTemplateColumns: (...args: unknown[]) => mockUseEvaluationTemplateColumns(...args),
}))
vi.mock('@/service/use-pipeline', () => ({
usePublishedPipelineInfo: (...args: unknown[]) => mockUsePublishedPipelineInfo(...args),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string } }) => unknown) =>
selector({ dataset: { pipeline_id: 'pipeline-1' } }),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'query',
type: 'text-input',
}],
},
}],
},
},
isLoading: false,
}),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
const renderWithQueryClient = (ui: ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
return render(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
})
}
describe('Evaluation', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {}, initialResources: {} })
vi.clearAllMocks()
mockUseEvaluationConfig.mockReturnValue({
data: null,
})
mockUseDatasetEvaluationMetrics.mockReturnValue({
data: {
metrics: ['answer-correctness', 'faithfulness', 'context-precision', 'context-recall', 'context-relevance'],
},
isLoading: false,
})
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
{
metric: 'faithfulness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
},
{
metric: 'context-precision',
value_type: 'number',
node_info_list: [],
},
{
metric: 'context-recall',
value_type: 'number',
node_info_list: [],
},
{
metric: 'context-relevance',
value_type: 'number',
node_info_list: [],
},
],
},
isLoading: false,
})
mockUseSaveEvaluationConfigMutation.mockReturnValue({
isPending: false,
mutate: vi.fn(),
})
mockUseStartEvaluationRunMutation.mockReturnValue({
isPending: false,
mutate: vi.fn(),
})
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
columns: [
{ name: 'index', type: 'number' },
{ name: 'query', type: 'string' },
{ name: 'expected_output', type: 'string' },
],
},
isError: false,
isFetching: false,
isPending: false,
})
mockUsePublishedPipelineInfo.mockReturnValue({
data: {
graph: {
nodes: [{
id: 'knowledge-node',
data: {
type: 'knowledge-index',
title: 'Knowledge Base',
},
}],
edges: [],
},
},
})
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'query',
type: 'text-input',
}],
},
}],
},
input_fields: [],
},
isLoading: false,
})
mockUpload.mockResolvedValue({
id: 'uploaded-file-id',
name: 'evaluation.csv',
})
})
it('should search, select metric nodes, and save evaluation config', () => {
const saveConfig = vi.fn()
mockUseSaveEvaluationConfigMutation.mockReturnValue({
isPending: false,
mutate: saveConfig,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-1" />)
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: 'does-not-exist' },
})
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: 'faith' },
})
fireEvent.click(screen.getByTestId('evaluation-metric-node-faithfulness-node-faithfulness'))
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
expect(screen.getAllByText('Retriever Node').length).toBeGreaterThan(0)
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: '' },
})
fireEvent.click(screen.getByTestId('evaluation-metric-node-answer-correctness-node-answer'))
expect(screen.getAllByText('Answer Correctness').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(saveConfig).toHaveBeenCalledWith({
params: {
targetType: 'apps',
targetId: 'app-1',
},
body: {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [
{
metric: 'faithfulness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
},
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
],
customized_metrics: null,
judgment_config: null,
},
}, {
onSuccess: expect.any(Function),
onError: expect.any(Function),
})
})
it('should reset unsaved non-pipeline config changes to the hydrated config', () => {
mockUseEvaluationConfig.mockReturnValue({
data: {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [],
customized_metrics: null,
judgment_config: null,
},
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-reset" />)
const resetButton = screen.getByRole('button', { name: 'common.operation.reset' })
expect(resetButton).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: 'faith' },
})
fireEvent.click(screen.getByTestId('evaluation-metric-node-faithfulness-node-faithfulness'))
expect(useEvaluationStore.getState().resources['apps:app-reset']!.metrics).toHaveLength(1)
expect(resetButton).toBeEnabled()
fireEvent.click(resetButton)
expect(useEvaluationStore.getState().resources['apps:app-reset']!.metrics).toHaveLength(0)
expect(resetButton).toBeDisabled()
})
it('should hide the batch config warning when judge model and metrics are configured', () => {
const resourceType = 'apps'
const resourceId = 'app-batch-configured'
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
})
renderWithQueryClient(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
expect(screen.queryByText('evaluation.batch.noticeDescription')).not.toBeInTheDocument()
})
it('should use template columns for snippet batch templates', () => {
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource('snippets', 'snippet-fields')
store.setJudgeModel('snippets', 'snippet-fields', 'openai::gpt-4o-mini')
store.addBuiltinMetric('snippets', 'snippet-fields', 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
columns: [
{ name: 'index', type: 'number' },
{ name: 'snippet_topic', type: 'string' },
{ name: 'need_summary', type: 'boolean' },
],
},
isError: false,
isFetching: false,
isPending: false,
})
renderWithQueryClient(<Evaluation resourceType="snippets" resourceId="snippet-fields" />)
expect(mockUseEvaluationTemplateColumns).toHaveBeenCalledWith(
'snippets',
'snippet-fields',
expect.any(Object),
true,
)
expect(screen.getByText('snippet_topic')).toBeInTheDocument()
expect(screen.getByText('need_summary')).toBeInTheDocument()
})
it('should show empty template columns copy', () => {
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource('snippets', 'snippet-empty-fields')
store.setJudgeModel('snippets', 'snippet-empty-fields', 'openai::gpt-4o-mini')
store.addBuiltinMetric('snippets', 'snippet-empty-fields', 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
columns: [],
},
isError: false,
isFetching: false,
isPending: false,
})
renderWithQueryClient(<Evaluation resourceType="snippets" resourceId="snippet-empty-fields" />)
expect(screen.getByText('evaluation.batch.noTemplateColumns')).toBeInTheDocument()
})
it('should hide the value row for empty operators', () => {
const resourceType = 'apps'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
let conditionId = ''
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
store.addCondition(resourceType, resourceId)
const condition = useEvaluationStore.getState().resources['apps:app-2'].judgmentConfig.conditions[0]
conditionId = condition.id
store.updateConditionOperator(resourceType, resourceId, conditionId, '=')
})
let rerender: ReturnType<typeof render>['rerender']
act(() => {
({ rerender } = renderWithQueryClient(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
})
expect(screen.getByPlaceholderText('evaluation.conditions.valuePlaceholder')).toBeInTheDocument()
act(() => {
store.updateConditionOperator(resourceType, resourceId, conditionId, 'is null')
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
})
expect(screen.queryByPlaceholderText('evaluation.conditions.valuePlaceholder')).not.toBeInTheDocument()
})
it('should add a condition from grouped metric dropdown items', () => {
const resourceType = 'apps'
const resourceId = 'app-conditions-dropdown'
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-conditions-dropdown'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: 'workflow-1',
workflowAppId: 'workflow-app-1',
workflowName: 'Review Workflow',
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
})
render(<ConditionsSection resourceType={resourceType} resourceId={resourceId} />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.conditions.addCondition' }))
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
expect(screen.getByText('Review Workflow')).toBeInTheDocument()
expect(screen.getByText('Retriever Node')).toBeInTheDocument()
expect(screen.getByText('reason')).toBeInTheDocument()
expect(screen.getByText('evaluation.conditions.valueTypes.number')).toBeInTheDocument()
expect(screen.getByText('evaluation.conditions.valueTypes.string')).toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: /reason/i }))
const condition = useEvaluationStore.getState().resources['apps:app-conditions-dropdown'].judgmentConfig.conditions[0]
expect(condition.variableSelector).toEqual(['workflow-1', 'reason'])
expect(screen.getAllByText('Review Workflow').length).toBeGreaterThan(0)
})
it('should render the metric no-node empty state', () => {
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'context-precision',
value_type: 'number',
node_info_list: [],
},
],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-3" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByText('evaluation.metrics.noNodesInWorkflow')).toBeInTheDocument()
})
it('should add a node from a dynamically returned metric option', () => {
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
{
metric: 'context-precision',
value_type: 'number',
node_info_list: [
{ node_id: 'node-context', title: 'Context Node', type: 'knowledge-retrieval' },
],
},
],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-dynamic-metric" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
fireEvent.click(screen.getByTestId('evaluation-metric-node-context-precision-node-context'))
const metrics = useEvaluationStore.getState().resources['apps:app-dynamic-metric']!.metrics
expect(metrics).toHaveLength(1)
expect(metrics[0]).toMatchObject({
optionId: 'context-precision',
label: 'Context Precision',
nodeInfoList: [
{ node_id: 'node-context', title: 'Context Node', type: 'knowledge-retrieval' },
],
})
})
it('should render the global empty state when no metrics are available', () => {
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-4" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
})
it('should show more nodes when a metric has more than three nodes', () => {
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
{ node_id: 'node-3', title: 'LLM 3', type: 'llm' },
{ node_id: 'node-4', title: 'LLM 4', type: 'llm' },
],
},
],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-5" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByText('LLM 3')).toBeInTheDocument()
expect(screen.queryByText('LLM 4')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.showMore' }))
expect(screen.getByText('LLM 4')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.metrics.showLess' })).toBeInTheDocument()
})
it('should render the pipeline-specific layout without auto-selecting a judge model', () => {
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-1" />)
expect(mockUseDatasetEvaluationMetrics).toHaveBeenCalledWith('dataset-1')
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('empty')
expect(screen.getByText('evaluation.history.columns.time')).toBeInTheDocument()
expect(screen.getByText('Context Precision')).toBeInTheDocument()
expect(screen.getByText('Context Recall')).toBeInTheDocument()
expect(screen.getByText('Context Relevance')).toBeInTheDocument()
expect(screen.getByText('evaluation.results.empty')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' })).toBeDisabled()
})
it('should render selected pipeline metrics from config with the default threshold input', () => {
mockUseEvaluationConfig.mockReturnValue({
data: {
evaluation_model: null,
evaluation_model_provider: null,
default_metrics: [{
metric: 'context-precision',
}],
customized_metrics: null,
judgment_config: null,
},
})
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-2" />)
expect(screen.getByText('Context Precision')).toBeInTheDocument()
expect(screen.getByDisplayValue('0.85')).toBeInTheDocument()
})
it('should enable pipeline batch actions after selecting a judge model and metric', () => {
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-2" />)
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
fireEvent.click(screen.getByRole('button', { name: /Context Precision/i }))
expect(screen.getByDisplayValue('0.85')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.batch.downloadTemplate' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' })).toBeEnabled()
})
it('should download the fixed pipeline template columns', () => {
const createElement = document.createElement.bind(document)
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
columns: [
{ name: 'index', type: 'number' },
{ name: 'query', type: 'string' },
{ name: 'expected_output', type: 'string' },
],
},
isError: false,
isFetching: false,
isPending: false,
})
let downloadLink: HTMLAnchorElement | undefined
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
const element = createElement(tagName, options)
if (tagName === 'a') {
downloadLink = element as HTMLAnchorElement
vi.spyOn(downloadLink, 'click').mockImplementation(() => {})
}
return element
})
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-template" />)
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
fireEvent.click(screen.getByRole('button', { name: /Context Precision/i }))
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.downloadTemplate' }))
const templateContent = decodeURIComponent(downloadLink?.href ?? '').replace('data:text/csv;charset=utf-8,', '')
expect(downloadLink?.download).toBe('pipeline-evaluation-template.csv')
expect(templateContent.trim().split(',')).toEqual(['index', 'query', 'expected_output'])
expect(mockUseEvaluationTemplateColumns).toHaveBeenLastCalledWith(
'datasets',
'dataset-template',
expect.objectContaining({
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
}),
true,
)
createElementSpy.mockRestore()
})
it('should upload and start a pipeline evaluation run', async () => {
const startRun = vi.fn()
mockUseStartEvaluationRunMutation.mockReturnValue({
isPending: false,
mutate: startRun,
})
mockUpload.mockResolvedValue({
id: 'file-1',
name: 'pipeline-evaluation.csv',
})
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-run" />)
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
fireEvent.click(screen.getByRole('button', { name: /Context Precision/i }))
fireEvent.click(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' }))
expect(screen.getAllByText('query').length).toBeGreaterThan(0)
expect(screen.getAllByText('expected_output').length).toBeGreaterThan(0)
const fileInput = document.querySelector<HTMLInputElement>('input[type="file"][accept=".csv"]')
expect(fileInput).toBeInTheDocument()
fireEvent.change(fileInput!, {
target: {
files: [new File(['index,query,expected_output'], 'pipeline-evaluation.csv', { type: 'text/csv' })],
},
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith({
xhr: expect.any(XMLHttpRequest),
data: expect.any(FormData),
})
})
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
await waitFor(() => {
expect(startRun).toHaveBeenCalledWith({
params: {
targetType: 'datasets',
targetId: 'dataset-run',
},
body: {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'context-precision',
value_type: 'number',
node_info_list: [
{ node_id: 'knowledge-node', title: 'Knowledge Base', type: 'knowledge-index' },
],
}],
customized_metrics: null,
judgment_config: {
logical_operator: 'and',
conditions: [{
variable_selector: ['knowledge-node', 'context-precision'],
comparison_operator: '≥',
value: '0.85',
}],
},
file_id: 'file-1',
},
}, {
onSuccess: expect.any(Function),
onError: expect.any(Function),
})
})
})
})

View File

@ -0,0 +1,456 @@
import type { EvaluationConfig } from '@/types/evaluation'
import {
getAllowedOperators,
isCustomMetricConfigured,
requiresConditionValue,
useEvaluationStore,
} from '../store'
import { buildEvaluationConfigPayload, buildEvaluationRunRequest } from '../store-utils'
const customWorkflow = {
id: 'workflow-precision-review',
appId: 'custom-workflow-app-id',
name: 'Precision Review Workflow',
}
describe('evaluation store', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {}, initialResources: {} })
})
it('should configure a custom metric mapping to a valid state', () => {
const resourceType = 'apps'
const resourceId = 'app-1'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const initialMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
expect(initialMetric).toBeDefined()
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, {
workflowId: customWorkflow.id,
workflowAppId: customWorkflow.appId,
workflowName: customWorkflow.name,
})
store.syncCustomMetricMappings(resourceType, resourceId, initialMetric!.id, ['query'])
store.syncCustomMetricOutputs(resourceType, resourceId, initialMetric!.id, [{
id: 'score',
valueType: 'number',
}])
const syncedMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, syncedMetric!.customConfig!.mappings[0].id, {
outputVariableId: 'answer',
})
const configuredMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
expect(configuredMetric!.customConfig!.workflowAppId).toBe(customWorkflow.appId)
expect(configuredMetric!.customConfig!.workflowName).toBe(customWorkflow.name)
expect(configuredMetric!.customConfig!.outputs).toEqual([{ id: 'score', valueType: 'number' }])
})
it('should only add one custom metric', () => {
const resourceType = 'apps'
const resourceId = 'app-custom-limit'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
expect(
useEvaluationStore
.getState()
.resources['apps:app-custom-limit']
.metrics
.filter(metric => metric.kind === 'custom-workflow'),
).toHaveLength(1)
})
it('should add and remove builtin metrics', () => {
const resourceType = 'apps'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness')
const addedMetric = useEvaluationStore.getState().resources['apps:app-2'].metrics.find(metric => metric.optionId === 'faithfulness')
expect(addedMetric).toBeDefined()
store.removeMetric(resourceType, resourceId, addedMetric!.id)
expect(useEvaluationStore.getState().resources['apps:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
})
it('should upsert builtin metric node selections and prune stale conditions', () => {
const resourceType = 'apps'
const resourceId = 'app-4'
const store = useEvaluationStore.getState()
const metricId = 'answer-correctness'
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, metricId, [
{ node_id: 'node-1', title: 'Answer Node', type: 'answer' },
])
store.addCondition(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, metricId, [
{ node_id: 'node-2', title: 'Retriever Node', type: 'retriever' },
])
const state = useEvaluationStore.getState().resources['apps:app-4']
const metric = state.metrics.find(item => item.optionId === metricId)
expect(metric?.nodeInfoList).toEqual([
{ node_id: 'node-2', title: 'Retriever Node', type: 'retriever' },
])
expect(state.metrics.filter(item => item.optionId === metricId)).toHaveLength(1)
expect(state.judgmentConfig.conditions).toHaveLength(0)
})
it('should build numeric conditions from selected metrics', () => {
const resourceType = 'apps'
const resourceId = 'app-conditions'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
store.setConditionLogicalOperator(resourceType, resourceId, 'or')
store.addCondition(resourceType, resourceId)
const state = useEvaluationStore.getState().resources['apps:app-conditions']
const condition = state.judgmentConfig.conditions[0]
expect(state.judgmentConfig.logicalOperator).toBe('or')
expect(condition.variableSelector).toEqual(['node-answer', 'answer-correctness'])
expect(condition.comparisonOperator).toBe('=')
expect(getAllowedOperators(state.metrics, condition.variableSelector)).toEqual(['=', '≠', '>', '<', '≥', '≤', 'is null', 'is not null'])
})
it('should add a condition from the selected custom metric output', () => {
const resourceType = 'apps'
const resourceId = 'app-condition-selector'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-condition-selector'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: customWorkflow.id,
workflowAppId: customWorkflow.appId,
workflowName: customWorkflow.name,
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
store.addCondition(resourceType, resourceId, [customWorkflow.id, 'reason'])
const condition = useEvaluationStore.getState().resources['apps:app-condition-selector'].judgmentConfig.conditions[0]
expect(condition.variableSelector).toEqual([customWorkflow.id, 'reason'])
expect(condition.comparisonOperator).toBe('contains')
expect(condition.value).toBeNull()
})
it('should clear values for operators without values', () => {
const resourceType = 'apps'
const resourceId = 'app-3'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-3'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: customWorkflow.id,
workflowAppId: customWorkflow.appId,
workflowName: customWorkflow.name,
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
store.addCondition(resourceType, resourceId)
const condition = useEvaluationStore.getState().resources['apps:app-3'].judgmentConfig.conditions[0]
store.updateConditionMetric(resourceType, resourceId, condition.id, [customWorkflow.id, 'reason'])
store.updateConditionValue(resourceType, resourceId, condition.id, 'needs follow-up')
store.updateConditionOperator(resourceType, resourceId, condition.id, 'empty')
const updatedCondition = useEvaluationStore.getState().resources['apps:app-3'].judgmentConfig.conditions[0]
expect(requiresConditionValue('empty')).toBe(false)
expect(updatedCondition.value).toBeNull()
})
it('should hydrate resource state from judgment_config', () => {
const resourceType = 'apps'
const resourceId = 'app-5'
const store = useEvaluationStore.getState()
const config: EvaluationConfig = {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'faithfulness',
node_info_list: [
{ node_id: 'node-1', title: 'Retriever', type: 'retriever' },
],
}],
customized_metrics: {
evaluation_workflow_id: 'workflow-precision-review',
input_fields: {
query: 'answer',
},
output_fields: [{
variable: 'reason',
value_type: 'string',
}],
},
judgment_config: {
logical_operator: 'or',
conditions: [{
variable_selector: ['node-1', 'faithfulness'],
comparison_operator: '≥',
value: '0.9',
}],
},
}
store.ensureResource(resourceType, resourceId)
store.setBatchTab(resourceType, resourceId, 'history')
store.setUploadedFileName(resourceType, resourceId, 'batch.csv')
useEvaluationStore.setState(state => ({
resources: {
...state.resources,
'apps:app-5': {
...state.resources['apps:app-5'],
batchRecords: [{
id: 'batch-1',
fileName: 'batch.csv',
status: 'success',
startedAt: '10:00:00',
summary: 'App evaluation batch',
}],
},
},
}))
store.hydrateResource(resourceType, resourceId, config)
const hydratedState = useEvaluationStore.getState().resources['apps:app-5']
expect(hydratedState.judgeModelId).toBe('openai::gpt-4o-mini')
expect(hydratedState.metrics).toHaveLength(2)
expect(hydratedState.metrics[0].optionId).toBe('faithfulness')
expect(hydratedState.metrics[0].threshold).toBe(0.85)
expect(hydratedState.metrics[0].nodeInfoList).toEqual([
{ node_id: 'node-1', title: 'Retriever', type: 'retriever' },
])
expect(hydratedState.metrics[1].kind).toBe('custom-workflow')
expect(hydratedState.metrics[1].customConfig?.workflowId).toBe('workflow-precision-review')
expect(hydratedState.metrics[1].customConfig?.workflowAppId).toBe('workflow-precision-review')
expect(hydratedState.metrics[1].customConfig?.mappings[0].inputVariableId).toBe('query')
expect(hydratedState.metrics[1].customConfig?.mappings[0].outputVariableId).toBe('answer')
expect(hydratedState.metrics[1].customConfig?.outputs).toEqual([{ id: 'reason', valueType: 'string' }])
expect(hydratedState.judgmentConfig.logicalOperator).toBe('or')
expect(hydratedState.judgmentConfig.conditions[0]).toMatchObject({
variableSelector: ['node-1', 'faithfulness'],
comparisonOperator: '≥',
value: '0.9',
})
expect(hydratedState.activeBatchTab).toBe('history')
expect(hydratedState.uploadedFileName).toBe('batch.csv')
expect(hydratedState.batchRecords).toHaveLength(1)
})
it('should build an evaluation config save payload from resource state', () => {
const resourceType = 'apps'
const resourceId = 'app-save-config'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-save-config'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: 'workflow-precision-review',
workflowAppId: 'evaluation-workflow-app-id',
workflowName: 'Precision Review',
})
store.syncCustomMetricMappings(resourceType, resourceId, customMetric.id, ['query'])
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'score',
valueType: 'number',
}])
const syncedMetric = useEvaluationStore.getState().resources['apps:app-save-config'].metrics.find(metric => metric.id === customMetric.id)!
store.updateCustomMetricMapping(resourceType, resourceId, customMetric.id, syncedMetric.customConfig!.mappings[0].id, {
outputVariableId: '{{#node-answer.output#}}',
})
store.addCondition(resourceType, resourceId, ['workflow-precision-review', 'score'])
const condition = useEvaluationStore.getState().resources['apps:app-save-config'].judgmentConfig.conditions[0]
store.updateConditionOperator(resourceType, resourceId, condition.id, '≥')
store.updateConditionValue(resourceType, resourceId, condition.id, '0.8')
const resource = useEvaluationStore.getState().resources['apps:app-save-config']
const expectedPayload = {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'faithfulness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
}],
customized_metrics: {
evaluation_workflow_id: 'evaluation-workflow-app-id',
input_fields: {
query: '{{#node-answer.output#}}',
},
output_fields: [{
variable: 'score',
value_type: 'number',
}],
},
judgment_config: {
logical_operator: 'and',
conditions: [{
variable_selector: ['evaluation-workflow-app-id', 'score'],
comparison_operator: '≥',
value: '0.8',
}],
},
}
expect(buildEvaluationConfigPayload(resource, resourceType)).toEqual(expectedPayload)
expect(buildEvaluationRunRequest(resource, 'file-1', resourceType)).toEqual({
...expectedPayload,
file_id: 'file-1',
})
})
it('should hydrate pipeline metrics from fixed knowledge-index conditions', () => {
const resourceType = 'datasets'
const resourceId = 'dataset-hydrate'
const store = useEvaluationStore.getState()
const config: EvaluationConfig = {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'context-precision',
node_info_list: [
{ node_id: 'knowledge-node', title: 'Knowledge Base', type: 'knowledge-index' },
],
}],
customized_metrics: {
evaluation_workflow_id: 'should-be-ignored',
input_fields: {
query: 'answer',
},
output_fields: [{
variable: 'score',
value_type: 'number',
}],
},
judgment_config: {
logical_operator: 'or',
conditions: [{
variable_selector: ['knowledge-node', 'context-precision'],
comparison_operator: '≥',
value: '0.92',
}],
},
}
store.hydrateResource(resourceType, resourceId, config)
const hydratedState = useEvaluationStore.getState().resources['datasets:dataset-hydrate']
expect(hydratedState.judgeModelId).toBe('openai::gpt-4o-mini')
expect(hydratedState.metrics).toHaveLength(1)
expect(hydratedState.metrics[0]).toMatchObject({
optionId: 'context-precision',
kind: 'builtin',
valueType: 'number',
threshold: 0.92,
nodeInfoList: [
{ node_id: 'knowledge-node', title: 'Knowledge Base', type: 'knowledge-index' },
],
})
})
it('should build pipeline judgment payload from metric thresholds', () => {
const resourceType = 'datasets'
const resourceId = 'dataset-save-config'
const store = useEvaluationStore.getState()
const knowledgeNodeInfo = [{ node_id: 'knowledge-node', title: 'Knowledge Base', type: 'knowledge-index' }]
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'context-precision', knowledgeNodeInfo)
store.addBuiltinMetric(resourceType, resourceId, 'context-recall', knowledgeNodeInfo)
const resourceWithMetrics = useEvaluationStore.getState().resources['datasets:dataset-save-config']
const contextPrecisionMetric = resourceWithMetrics.metrics.find(metric => metric.optionId === 'context-precision')!
const contextRecallMetric = resourceWithMetrics.metrics.find(metric => metric.optionId === 'context-recall')!
store.updateMetricThreshold(resourceType, resourceId, contextPrecisionMetric.id, 0.91)
store.updateMetricThreshold(resourceType, resourceId, contextRecallMetric.id, 0.88)
const resource = useEvaluationStore.getState().resources['datasets:dataset-save-config']
const expectedPayload = {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [
{
metric: 'context-precision',
value_type: 'number',
node_info_list: knowledgeNodeInfo,
},
{
metric: 'context-recall',
value_type: 'number',
node_info_list: knowledgeNodeInfo,
},
],
customized_metrics: null,
judgment_config: {
logical_operator: 'and',
conditions: [
{
variable_selector: ['knowledge-node', 'context-precision'],
comparison_operator: '≥',
value: '0.91',
},
{
variable_selector: ['knowledge-node', 'context-recall'],
comparison_operator: '≥',
value: '0.88',
},
],
},
}
expect(buildEvaluationConfigPayload(resource, resourceType)).toEqual(expectedPayload)
expect(buildEvaluationRunRequest(resource, 'file-1', resourceType)).toEqual({
...expectedPayload,
file_id: 'file-1',
})
})
})

View File

@ -0,0 +1,219 @@
import type { EvaluationResourceProps } from '../../types'
import type { EvaluationLog, EvaluationRunStatus } from '@/types/evaluation'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Pagination from '@/app/components/base/pagination'
import useTimestamp from '@/hooks/use-timestamp'
import { consoleClient, consoleQuery } from '@/service/client'
import { useMembers } from '@/service/use-common'
import { downloadUrl } from '@/utils/download'
import { useEvaluationResource, useEvaluationStore } from '../../store'
const PAGE_SIZE = 16
const LOADING_ROW_IDS = ['1', '2', '3', '4', '5', '6']
const CREATED_AT_FORMAT = 'YYYY-MM-DD'
type FormatTimestamp = (value: number, format: string) => string
const STATUS_ICON_CLASS_NAMES: Record<EvaluationRunStatus, string> = {
pending: 'i-ri-time-line text-text-tertiary',
running: 'i-ri-loader-4-line animate-spin text-text-accent',
completed: 'i-ri-checkbox-circle-fill text-util-colors-green-green-600',
failed: 'i-ri-close-circle-fill text-text-destructive',
cancelled: 'i-ri-forbid-2-line text-text-tertiary',
}
const formatCreatedAt = (createdAt: number | null | undefined, formatTime: FormatTimestamp) => {
if (createdAt == null)
return '-'
return formatTime(createdAt, CREATED_AT_FORMAT)
}
const getLogRunId = (record: EvaluationLog) => {
return record.id
}
const HistoryTab = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { formatTime } = useTimestamp()
const { data: membersData } = useMembers()
const [page, setPage] = useState(0)
const resource = useEvaluationResource(resourceType, resourceId)
const setSelectedRunId = useEvaluationStore(state => state.setSelectedRunId)
const logsQuery = useQuery({
...consoleQuery.evaluation.logs.queryOptions({
input: {
params: {
targetType: resourceType,
targetId: resourceId,
},
query: {
page: page + 1,
page_size: PAGE_SIZE,
},
},
refetchOnWindowFocus: false,
}),
placeholderData: keepPreviousData,
})
const fileDownloadMutation = useMutation({
mutationFn: async (fileId: string) => {
const fileInfo = await consoleClient.evaluation.file({
params: {
targetType: resourceType,
targetId: resourceId,
fileId,
},
})
downloadUrl({ url: fileInfo.download_url, fileName: fileInfo.name })
},
})
const records = useMemo(() => logsQuery.data?.data ?? [], [logsQuery.data?.data])
const memberNameById = useMemo(() => {
return new Map((membersData?.accounts ?? []).map(member => [member.id, member.name]))
}, [membersData?.accounts])
const total = logsQuery.data?.total ?? 0
const isInitialLoading = logsQuery.isLoading && !logsQuery.data
useEffect(() => {
if (resource.selectedRunId)
return
const firstRunId = records.map(getLogRunId).find((runId): runId is string => !!runId)
if (firstRunId)
setSelectedRunId(resourceType, resourceId, firstRunId)
}, [records, resource.selectedRunId, resourceId, resourceType, setSelectedRunId])
return (
<div className="flex min-h-full flex-col">
<div className="min-h-0 flex-1 overflow-hidden">
<table className="w-full table-fixed border-collapse overflow-hidden rounded-md">
<colgroup>
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[67px]" />
<col className="w-[40px]" />
</colgroup>
<thead>
<tr className="border-b border-divider-regular">
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">
<span className="inline-flex items-center gap-0.5">
{t('history.columns.time')}
<span aria-hidden="true" className="i-ri-arrow-down-line h-3.5 w-3.5" />
</span>
</th>
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">{t('history.columns.creator')}</th>
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">{t('history.columns.status')}</th>
<th className="h-7 text-center text-text-tertiary">
<span aria-hidden="true" className="i-ri-download-2-line inline-block h-3.5 w-3.5" />
</th>
</tr>
</thead>
<tbody>
{isInitialLoading && LOADING_ROW_IDS.map(rowId => (
<tr key={rowId} className="border-b border-divider-subtle">
<td colSpan={4} className="h-10 px-3">
<div className="h-4 animate-pulse rounded bg-background-section" />
</td>
</tr>
))}
{!isInitialLoading && records.map(record => (
<tr
key={record.id}
className={cn(
'border-b border-divider-subtle',
getLogRunId(record) && 'cursor-pointer hover:bg-state-base-hover',
getLogRunId(record) === resource.selectedRunId && 'bg-background-default-subtle',
)}
onClick={() => {
const runId = getLogRunId(record)
if (runId)
setSelectedRunId(resourceType, resourceId, runId)
}}
>
<td className="h-10 truncate px-3 system-sm-regular text-text-secondary">{formatCreatedAt(record.created_at, formatTime)}</td>
<td className="h-10 truncate px-3 system-sm-regular text-text-secondary">{memberNameById.get(record.created_by) ?? record.created_by}</td>
<td className="h-10 px-3">
<div className="flex h-10 items-center justify-center">
<span aria-label={t(`history.status.${record.status}`)} className={cn('inline-block h-4 w-4', STATUS_ICON_CLASS_NAMES[record.status])} />
</div>
</td>
<td className="h-10 text-center">
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label={t('history.actions.open')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={event => event.stopPropagation()}
/>
)}
>
<span aria-hidden="true" className="i-ri-more-2-fill h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent popupClassName="w-[180px] rounded-lg border-[0.5px] border-components-panel-border py-1 shadow-lg">
<DropdownMenuItem
className="gap-2"
disabled={!record.dataset_file_id}
onClick={(event) => {
event.stopPropagation()
if (record.dataset_file_id)
fileDownloadMutation.mutate(record.dataset_file_id)
}}
>
<span aria-hidden="true" className="i-ri-file-download-line h-4 w-4" />
{t('history.actions.downloadTestFile')}
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2"
disabled={!record.result_file_id}
onClick={(event) => {
event.stopPropagation()
if (record.result_file_id)
fileDownloadMutation.mutate(record.result_file_id)
}}
>
<span aria-hidden="true" className="i-ri-download-2-line h-4 w-4" />
{t('history.actions.downloadResultFile')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
{!isInitialLoading && records.length === 0 && (
<div className="mt-4 rounded-2xl border border-dashed border-divider-subtle px-4 py-10 text-center system-sm-regular text-text-tertiary">
{t('history.empty')}
</div>
)}
</div>
{total > PAGE_SIZE && (
<Pagination
className="px-0 py-3"
current={page}
limit={PAGE_SIZE}
total={total}
onChange={setPage}
/>
)}
</div>
)
}
export default HistoryTab

View File

@ -0,0 +1,76 @@
'use client'
import type { BatchTestTab, EvaluationResourceProps } from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { isEvaluationRunnable, useEvaluationResource, useEvaluationStore } from '../../store'
import { TAB_CLASS_NAME } from '../../utils'
import HistoryTab from './history-tab'
import InputFieldsTab from './input-fields-tab'
const BATCH_TABS: BatchTestTab[] = ['input-fields', 'history']
const BatchTestPanel = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const tabLabels: Record<BatchTestTab, string> = {
'input-fields': t('batch.tabs.input-fields'),
'history': t('batch.tabs.history'),
}
const resource = useEvaluationResource(resourceType, resourceId)
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
const isRunnable = isEvaluationRunnable(resource)
const hasBatchConfig = !!resource.judgeModelId && resource.metrics.length > 0
return (
<div className="flex h-full min-h-0 flex-col bg-background-default">
<div className="px-6 py-4">
<div className="min-w-0">
<div className="system-xl-semibold text-text-primary">{t('batch.title')}</div>
<div className="mt-1 system-sm-regular text-text-tertiary">{t('batch.description')}</div>
</div>
{!hasBatchConfig && (
<div className="mt-4 rounded-xl border border-divider-subtle bg-components-card-bg p-3">
<div className="flex items-start gap-3">
<span aria-hidden="true" className="mt-0.5 i-ri-alert-fill h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-tertiary">{t('batch.noticeDescription')}</div>
</div>
</div>
)}
</div>
<div className="border-b border-divider-subtle px-6">
<div className="flex gap-4">
{BATCH_TABS.map(tab => (
<button
key={tab}
type="button"
className={cn(
TAB_CLASS_NAME,
'flex-none rounded-none border-b-2 border-transparent px-0 pt-2 pb-2.5 uppercase',
resource.activeBatchTab === tab ? 'border-text-accent-secondary text-text-primary' : 'text-text-tertiary',
)}
onClick={() => setBatchTab(resourceType, resourceId, tab)}
>
{tabLabels[tab]}
</button>
))}
</div>
</div>
<div className={cn('min-h-0 flex-1 overflow-y-auto px-6 py-4', !hasBatchConfig && 'opacity-50')}>
{resource.activeBatchTab === 'input-fields' && (
<InputFieldsTab
resourceType={resourceType}
resourceId={resourceId}
isPanelReady={hasBatchConfig}
isRunnable={isRunnable}
/>
)}
{resource.activeBatchTab === 'history' && <HistoryTab resourceType={resourceType} resourceId={resourceId} />}
</div>
</div>
)
}
export default BatchTestPanel

View File

@ -0,0 +1,65 @@
import type { EvaluationResourceProps } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { EVALUATION_TEMPLATE_FILE_NAMES } from '../../store-utils'
import InputFieldsRequirements from './input-fields/input-fields-requirements'
import UploadRunPopover from './input-fields/upload-run-popover'
import { useInputFieldsActions } from './input-fields/use-input-fields-actions'
type InputFieldsTabProps = EvaluationResourceProps & {
isPanelReady: boolean
isRunnable: boolean
}
const InputFieldsTab = ({
resourceType,
resourceId,
isPanelReady,
isRunnable,
}: InputFieldsTabProps) => {
const { t } = useTranslation('evaluation')
const actions = useInputFieldsActions({
resourceType,
resourceId,
isPanelReady,
isRunnable,
templateFileName: EVALUATION_TEMPLATE_FILE_NAMES[resourceType],
})
return (
<div className="space-y-5">
<InputFieldsRequirements
inputFields={actions.templateColumns}
isLoading={actions.isTemplateColumnsLoading}
/>
<div className="space-y-3">
<Button variant="secondary" className="w-full justify-center" disabled={!actions.canDownloadTemplate} onClick={actions.handleDownloadTemplate}>
<span aria-hidden="true" className="mr-1 i-ri-download-line h-4 w-4" />
{t('batch.downloadTemplate')}
</Button>
<UploadRunPopover
open={actions.isUploadPopoverOpen}
onOpenChange={actions.setIsUploadPopoverOpen}
triggerDisabled={actions.uploadButtonDisabled}
inputFields={actions.templateColumns}
currentFileName={actions.currentFileName}
currentFileExtension={actions.currentFileExtension}
currentFileSize={actions.currentFileSize}
isFileUploading={actions.isFileUploading}
isRunDisabled={actions.isRunDisabled}
isRunning={actions.isRunning}
onUploadFile={actions.handleUploadFile}
onClearUploadedFile={actions.handleClearUploadedFile}
onRun={actions.handleRun}
/>
</div>
{!isRunnable && (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary">
{t('batch.validation')}
</div>
)}
</div>
)
}
export default InputFieldsTab

View File

@ -0,0 +1,26 @@
import { buildTemplateCsvContent, getExampleValue } from '../input-fields-utils'
describe('input fields utils', () => {
describe('buildTemplateCsvContent', () => {
it('should build CSV content from API columns without injecting columns', () => {
expect(buildTemplateCsvContent([
{ name: 'index', type: 'number' },
{ name: 'query', type: 'string' },
{ name: 'expected_output', type: 'string' },
])).toBe('index,query,expected_output\n')
})
it('should escape CSV column names', () => {
expect(buildTemplateCsvContent([
{ name: 'query,text', type: 'string' },
{ name: 'answer "draft"', type: 'string' },
])).toBe('"query,text","answer ""draft"""\n')
})
})
describe('getExampleValue', () => {
it('should use a row number example for index fields', () => {
expect(getExampleValue({ name: 'index', type: 'number' }, 'True')).toBe('1')
})
})
})

View File

@ -0,0 +1,45 @@
import type { InputField } from './input-fields-utils'
import { useTranslation } from 'react-i18next'
type InputFieldsRequirementsProps = {
inputFields: InputField[]
isLoading: boolean
}
const InputFieldsRequirements = ({
inputFields,
isLoading,
}: InputFieldsRequirementsProps) => {
const { t } = useTranslation('evaluation')
return (
<div>
<div className="system-md-semibold text-text-primary">{t('batch.requirementsTitle')}</div>
<div className="mt-1 system-xs-regular text-text-tertiary">{t('batch.requirementsDescription')}</div>
<div className="mt-3 rounded-xl bg-background-section p-3">
{isLoading && (
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
{t('batch.loadingInputFields')}
</div>
)}
{!isLoading && inputFields.length === 0 && (
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
{t('batch.noTemplateColumns')}
</div>
)}
{!isLoading && inputFields.map(field => (
<div key={field.name} className="flex items-center py-1">
<div className="rounded px-1 py-0.5 system-xs-medium text-text-tertiary">
{field.name}
</div>
<div className="text-[10px] leading-3 text-text-quaternary">
{field.type}
</div>
</div>
))}
</div>
</div>
)
}
export default InputFieldsRequirements

View File

@ -0,0 +1,88 @@
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { InputVar, Node } from '@/app/components/workflow/types'
import type { EvaluationTemplateColumn } from '@/types/evaluation'
import type { SnippetInputField } from '@/types/snippet'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
export type InputField = {
name: string
type: string
}
export const INDEX_FIELD_NAME = 'index'
export const getGraphNodes = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
}
export const getStartNodeInputFields = (nodes?: Node[]): InputField[] => {
const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node<StartNodeType> | undefined
const variables = startNode?.data.variables
if (!Array.isArray(variables))
return []
return variables
.filter((variable): variable is InputVar => typeof variable.variable === 'string' && !!variable.variable)
.map(variable => ({
name: variable.variable,
type: inputVarTypeToVarType(variable.type ?? InputVarType.textInput),
}))
}
const PIPELINE_INPUT_VAR_TYPE_TO_FIELD_TYPE: Record<PipelineInputVarType, string> = {
[PipelineInputVarType.textInput]: 'string',
[PipelineInputVarType.paragraph]: 'string',
[PipelineInputVarType.select]: 'string',
[PipelineInputVarType.number]: 'number',
[PipelineInputVarType.singleFile]: 'file',
[PipelineInputVarType.multiFiles]: 'array[file]',
[PipelineInputVarType.checkbox]: 'boolean',
}
export const getSnippetInputFields = (fields?: SnippetInputField[]): InputField[] => {
if (!Array.isArray(fields))
return []
return fields
.filter((field): field is SnippetInputField & { variable: string } =>
typeof field.variable === 'string' && !!field.variable,
)
.map(field => ({
name: field.variable,
type: typeof field.type === 'string' && field.type in PIPELINE_INPUT_VAR_TYPE_TO_FIELD_TYPE
? PIPELINE_INPUT_VAR_TYPE_TO_FIELD_TYPE[field.type as PipelineInputVarType]
: 'string',
}))
}
const escapeCsvCell = (value: string) => {
if (!/[",\n\r]/.test(value))
return value
return `"${value.replace(/"/g, '""')}"`
}
export const buildTemplateCsvContent = (columns: EvaluationTemplateColumn[]) => {
return `${columns.map(column => escapeCsvCell(column.name)).join(',')}\n`
}
export const getFileExtension = (fileName: string) => {
const extension = fileName.split('.').pop()
return extension && extension !== fileName ? extension.toUpperCase() : ''
}
export const getExampleValue = (field: InputField, booleanLabel: string) => {
if (field.name === INDEX_FIELD_NAME)
return '1'
if (field.type === 'number')
return '0.7'
if (field.type === 'boolean')
return booleanLabel
return field.name
}

View File

@ -0,0 +1,187 @@
import type { ChangeEvent, DragEvent } from 'react'
import type { InputField } from './input-fields-utils'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getExampleValue } from './input-fields-utils'
type UploadRunPopoverProps = {
open: boolean
onOpenChange: (open: boolean) => void
triggerDisabled: boolean
triggerLabel?: string
inputFields: InputField[]
currentFileName: string | null | undefined
currentFileExtension: string
currentFileSize: string | number
isFileUploading: boolean
isRunDisabled: boolean
isRunning: boolean
onUploadFile: (file: File | undefined) => void
onClearUploadedFile: () => void
onRun: () => void
}
const UploadRunPopover = ({
open,
onOpenChange,
triggerDisabled,
triggerLabel,
inputFields,
currentFileName,
currentFileExtension,
currentFileSize,
isFileUploading,
isRunDisabled,
isRunning,
onUploadFile,
onClearUploadedFile,
onRun,
}: UploadRunPopoverProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const fileInputRef = useRef<HTMLInputElement>(null)
const previewFields = inputFields
const booleanExampleValue = t('conditions.boolean.true')
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
onUploadFile(event.target.files?.[0])
event.target.value = ''
}
const handleDropFile = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
onUploadFile(event.dataTransfer.files?.[0])
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
render={(
<Button className="w-full justify-center" variant="primary" disabled={triggerDisabled}>
{triggerLabel ?? t('batch.uploadAndRun')}
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={8}
popupClassName="w-[402px] overflow-hidden rounded-lg border border-components-panel-border p-0 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]"
>
<div className="flex flex-col bg-components-panel-bg">
<div className="flex flex-col gap-4 p-4">
<input
ref={fileInputRef}
hidden
type="file"
accept=".csv"
onChange={handleFileChange}
/>
{currentFileName
? (
<div className="flex h-20 items-center gap-3 rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3">
<div className="flex p-3">
<span aria-hidden="true" className="i-ri-file-excel-fill h-6 w-6 text-util-colors-green-green-600" />
</div>
<div className="min-w-0 flex-1 py-1 pr-2">
<div className="truncate system-xs-medium text-text-secondary">
{currentFileName}
</div>
<div className="mt-0.5 flex h-3 items-center gap-1 system-2xs-medium text-text-tertiary">
{!!currentFileExtension && <span className="uppercase">{currentFileExtension}</span>}
{!!currentFileExtension && !!currentFileSize && <span className="text-text-quaternary">·</span>}
{!!currentFileSize && <span>{currentFileSize}</span>}
</div>
</div>
<div className="flex items-center gap-1 pr-3">
{isFileUploading && (
<span aria-hidden="true" className="i-ri-loader-4-line h-4 w-4 animate-spin text-text-accent" />
)}
<button
type="button"
className="rounded-md p-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={onClearUploadedFile}
aria-label={t('batch.removeUploadedFile')}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</button>
</div>
</div>
)
: (
<div
className="flex h-20 w-full items-center justify-center gap-3 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg p-3 text-left hover:border-components-button-secondary-border"
onDragOver={event => event.preventDefault()}
onDrop={handleDropFile}
>
<button
type="button"
className="flex shrink-0 p-3"
onClick={() => fileInputRef.current?.click()}
>
<span aria-hidden="true" className="i-ri-file-upload-line h-6 w-6 text-text-tertiary" />
<span className="sr-only">{t('batch.uploadTitle')}</span>
</button>
<div className="min-w-0 flex-1 text-left">
<div className="system-md-regular text-text-secondary">
{t('batch.uploadDropzonePrefix')}
{' '}
<span className="system-md-semibold">{t('batch.uploadDropzoneEmphasis')}</span>
{' '}
{t('batch.uploadDropzoneSuffix')}
</div>
<div className="mt-0.5 system-xs-regular text-text-tertiary">
<button
type="button"
className="text-text-accent hover:underline"
onClick={() => fileInputRef.current?.click()}
>
{t('batch.uploadDropzoneUploadButton')}
</button>
{' '}
{t('batch.uploadHint')}
</div>
</div>
</div>
)}
{!!previewFields.length && (
<div className="space-y-1">
<div className="system-md-semibold text-text-secondary">{t('batch.example')}</div>
<div className="flex overflow-hidden rounded-lg border border-divider-regular">
{previewFields.map((field, index) => (
<div key={field.name} className={cn('min-w-0 flex-1', index < previewFields.length - 1 && 'border-r border-divider-subtle')}>
<div className="min-h-8 border-b border-divider-regular px-3 py-2 system-xs-medium-uppercase text-text-tertiary">
{field.name}
</div>
<div className="min-h-8 px-3 py-2 system-sm-regular text-text-secondary">
{getExampleValue(field, booleanExampleValue)}
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex items-end justify-end gap-2 border-t border-components-panel-border px-4 py-4">
<Button variant="secondary" className="rounded-lg" onClick={() => onOpenChange(false)}>
{tCommon('operation.cancel')}
</Button>
<Button className="flex-1 justify-center rounded-lg" variant="primary" disabled={isRunDisabled} loading={isRunning} onClick={onRun}>
<span aria-hidden="true" className="mr-1 i-ri-play-fill h-5 w-5" />
{t('batch.run')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export default UploadRunPopover

View File

@ -0,0 +1,175 @@
import type { EvaluationResourceProps } from '../../../types'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { upload } from '@/service/base'
import { useEvaluationTemplateColumns, useStartEvaluationRunMutation } from '@/service/use-evaluation'
import { formatFileSize } from '@/utils/format'
import { useEvaluationResource, useEvaluationStore } from '../../../store'
import { buildEvaluationConfigPayload, buildEvaluationRunRequest } from '../../../store-utils'
import { buildTemplateCsvContent, getFileExtension } from './input-fields-utils'
type UploadedFileMeta = {
name: string
size: number
}
type UseInputFieldsActionsParams = EvaluationResourceProps & {
isPanelReady: boolean
isRunnable: boolean
templateFileName: string
}
export const useInputFieldsActions = ({
resourceType,
resourceId,
isPanelReady,
isRunnable,
templateFileName,
}: UseInputFieldsActionsParams) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
const setSelectedRunId = useEvaluationStore(state => state.setSelectedRunId)
const setUploadedFile = useEvaluationStore(state => state.setUploadedFile)
const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName)
const startRunMutation = useStartEvaluationRunMutation()
const templateConfigPayload = useMemo(() => {
return isPanelReady ? buildEvaluationConfigPayload(resource, resourceType) : null
}, [isPanelReady, resource, resourceType])
const templateColumnsQuery = useEvaluationTemplateColumns(resourceType, resourceId, templateConfigPayload, isPanelReady)
const [isUploadPopoverOpen, setIsUploadPopoverOpen] = useState(false)
const [uploadedFileMeta, setUploadedFileMeta] = useState<UploadedFileMeta | null>(null)
const uploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return upload({
xhr: new XMLHttpRequest(),
data: formData,
})
},
onSuccess: (uploadedFile, file) => {
setUploadedFile(resourceType, resourceId, {
id: uploadedFile.id,
name: typeof uploadedFile.name === 'string' ? uploadedFile.name : file.name,
})
},
onError: () => {
setUploadedFileMeta(null)
setUploadedFile(resourceType, resourceId, null)
toast.error(t('batch.uploadError'))
},
})
const isFileUploading = uploadMutation.isPending
const isRunning = startRunMutation.isPending
const isTemplateColumnsLoading = templateColumnsQuery.isPending || templateColumnsQuery.isFetching
const templateColumns = templateColumnsQuery.data?.columns ?? []
const uploadedFileId = resource.uploadedFileId
const currentFileName = uploadedFileMeta?.name ?? resource.uploadedFileName
const canDownloadTemplate = isPanelReady && !isTemplateColumnsLoading && templateColumns.length > 0
const isRunDisabled = !isRunnable || !uploadedFileId || isFileUploading || isRunning
const uploadButtonDisabled = !isPanelReady || isTemplateColumnsLoading || isRunning
const handleDownloadTemplate = () => {
if (templateColumnsQuery.isError) {
toast.error(t('batch.templateColumnsError'))
return
}
if (!templateColumns.length) {
toast.warning(t('batch.noTemplateColumns'))
return
}
const content = buildTemplateCsvContent(templateColumns)
const link = document.createElement('a')
link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`
link.download = templateFileName
link.click()
}
const handleRun = () => {
if (!isRunnable) {
toast.warning(t('batch.validation'))
return
}
if (isFileUploading) {
toast.warning(t('batch.uploading'))
return
}
if (!uploadedFileId) {
toast.warning(t('batch.fileRequired'))
return
}
const body = buildEvaluationRunRequest(resource, uploadedFileId, resourceType)
if (!body) {
toast.warning(t('batch.validation'))
return
}
startRunMutation.mutate({
params: {
targetType: resourceType,
targetId: resourceId,
},
body,
}, {
onSuccess: (run) => {
toast.success(t('batch.runStarted'))
setSelectedRunId(resourceType, resourceId, run.id)
setIsUploadPopoverOpen(false)
setBatchTab(resourceType, resourceId, 'history')
},
onError: () => {
toast.error(t('batch.runFailed'))
},
})
}
const handleUploadFile = (file: File | undefined) => {
if (!file) {
setUploadedFileMeta(null)
setUploadedFile(resourceType, resourceId, null)
return
}
setUploadedFileMeta({
name: file.name,
size: file.size,
})
setUploadedFileName(resourceType, resourceId, file.name)
uploadMutation.mutate(file)
}
const handleClearUploadedFile = () => {
setUploadedFileMeta(null)
setUploadedFile(resourceType, resourceId, null)
}
return {
canDownloadTemplate,
currentFileExtension: currentFileName ? getFileExtension(currentFileName) : '',
currentFileName,
currentFileSize: uploadedFileMeta ? formatFileSize(uploadedFileMeta.size) : '',
handleClearUploadedFile,
handleDownloadTemplate,
handleRun,
handleUploadFile,
isFileUploading,
isRunning,
isRunDisabled,
isTemplateColumnsLoading,
isUploadPopoverOpen,
setIsUploadPopoverOpen,
templateColumns,
uploadButtonDisabled,
}
}

View File

@ -0,0 +1,29 @@
import type { EvaluationResourceType } from '../../../types'
import { useMemo } from 'react'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useAppWorkflow } from '@/service/use-workflow'
import { getSnippetInputFields, getStartNodeInputFields } from './input-fields-utils'
export const usePublishedInputFields = (
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const { data: currentAppWorkflow, isLoading: isAppWorkflowLoading } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
const { data: currentSnippetWorkflow, isLoading: isSnippetWorkflowLoading } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '')
const inputFields = useMemo(() => {
if (resourceType === 'apps')
return getStartNodeInputFields(currentAppWorkflow?.graph.nodes)
if (resourceType === 'snippets')
return getSnippetInputFields(currentSnippetWorkflow?.input_fields)
return []
}, [currentAppWorkflow?.graph.nodes, currentSnippetWorkflow?.input_fields, resourceType])
return {
inputFields,
isInputFieldsLoading: (resourceType === 'apps' && isAppWorkflowLoading)
|| (resourceType === 'snippets' && isSnippetWorkflowLoading),
}
}

View File

@ -0,0 +1,84 @@
'use client'
import type { ConditionMetricOptionGroup, EvaluationResourceProps } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEvaluationStore } from '../../store'
import { getConditionMetricValueTypeTranslationKey } from '../../utils'
type AddConditionSelectProps = EvaluationResourceProps & {
metricOptionGroups: ConditionMetricOptionGroup[]
disabled: boolean
}
const AddConditionSelect = ({
resourceType,
resourceId,
metricOptionGroups,
disabled,
}: AddConditionSelectProps) => {
const { t } = useTranslation('evaluation')
const addCondition = useEvaluationStore(state => state.addCondition)
const [open, setOpen] = useState(false)
const handleOpenChange = (nextOpen: boolean) => {
if (disabled)
return
setOpen(nextOpen)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
<Button
variant="ghost-accent"
aria-label={t('conditions.addCondition')}
disabled={disabled}
>
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('conditions.addCondition')}
</Button>
)}
/>
<PopoverContent
placement="bottom-start"
popupClassName="w-[320px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
<div className="max-h-[360px] overflow-y-auto bg-components-panel-bg p-1" role="menu">
{metricOptionGroups.map(group => (
<div key={group.label} role="group" aria-label={group.label}>
<div className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</div>
{group.options.map(option => (
<button
key={option.id}
type="button"
role="menuitem"
className="flex h-auto w-full items-center gap-3 overflow-hidden rounded-lg px-3 py-2 text-left hover:bg-components-panel-on-panel-item-bg-hover"
onClick={() => {
addCondition(resourceType, resourceId, option.variableSelector)
setOpen(false)
}}
>
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">{option.itemLabel}</span>
<span className="ml-auto shrink-0 system-xs-medium text-text-tertiary">
{t(getConditionMetricValueTypeTranslationKey(option.valueType))}
</span>
</button>
))}
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}
export default AddConditionSelect

View File

@ -0,0 +1,311 @@
'use client'
import type {
ComparisonOperator,
ConditionMetricOption,
EvaluationResourceProps,
JudgmentConditionItem,
} from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Input from '@/app/components/base/input'
import BlockIcon from '@/app/components/workflow/block-icon'
import { getAllowedOperators, requiresConditionValue, useEvaluationResource, useEvaluationStore } from '../../store'
import {
buildConditionMetricOptions,
getComparisonOperatorLabel,
getConditionMetricValueTypeTranslationKey,
groupConditionMetricOptions,
isSelectorEqual,
serializeVariableSelector,
} from '../../utils'
import { getEvaluationNodeBlockType } from '../metric-selector/utils'
type ConditionMetricLabelProps = {
metric?: ConditionMetricOption
placeholder: string
}
type ConditionMetricSelectProps = {
metric?: ConditionMetricOption
metricOptions: ConditionMetricOption[]
placeholder: string
onChange: (variableSelector: [string, string]) => void
}
type ConditionOperatorSelectProps = {
operator: ComparisonOperator
operators: ComparisonOperator[]
onChange: (operator: ComparisonOperator) => void
}
type ConditionValueInputProps = {
metric?: ConditionMetricOption
condition: JudgmentConditionItem
onChange: (value: string | string[] | boolean | null) => void
}
type ConditionGroupProps = EvaluationResourceProps
const getMetricVariableLabel = (variableName: string) => {
return variableName.replaceAll('-', '_')
}
const ConditionMetricLabel = ({
metric,
placeholder,
}: ConditionMetricLabelProps) => {
if (!metric)
return <span className="px-1 system-sm-regular text-components-input-text-placeholder">{placeholder}</span>
if (metric.kind === 'builtin' && metric.nodeInfo) {
return (
<div className="flex min-w-0 items-center px-1">
<div className="inline-flex h-6 min-w-0 items-center gap-0.5 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark py-1 pr-1.5 pl-[5px] shadow-xs">
<span className="truncate system-xs-medium text-text-secondary">{getMetricVariableLabel(metric.variableSelector[1])}</span>
<span className="system-xs-regular text-divider-deep">/</span>
<span className="flex min-w-0 shrink-0 items-center gap-0.5">
<BlockIcon type={getEvaluationNodeBlockType(metric.nodeInfo)} size="xs" className="size-3 rounded-[5px]" />
<span className="max-w-[96px] truncate system-xs-medium text-text-secondary">{metric.itemLabel}</span>
</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.valueType}</span>
</div>
</div>
)
}
return (
<div className="flex min-w-0 items-center px-1">
<div className="inline-flex h-6 min-w-0 items-center gap-0.5 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark py-1 pr-1.5 pl-[5px] shadow-xs">
<span className="truncate system-xs-medium text-text-secondary">{metric.itemLabel}</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.valueType}</span>
</div>
</div>
)
}
const ConditionMetricSelect = ({
metric,
metricOptions,
placeholder,
onChange,
}: ConditionMetricSelectProps) => {
const { t } = useTranslation('evaluation')
const groupedMetricOptions = useMemo(() => {
return groupConditionMetricOptions(metricOptions)
}, [metricOptions])
return (
<Select
value={serializeVariableSelector(metric?.variableSelector)}
onValueChange={(value) => {
const nextMetric = metricOptions.find(option => serializeVariableSelector(option.variableSelector) === value)
if (nextMetric)
onChange(nextMetric.variableSelector)
}}
>
<SelectTrigger className="h-auto bg-transparent px-1 py-1 hover:bg-transparent focus-visible:bg-transparent">
<ConditionMetricLabel metric={metric} placeholder={placeholder} />
</SelectTrigger>
<SelectContent popupClassName="w-[360px]">
{groupedMetricOptions.map(group => (
<SelectGroup key={group.label}>
<SelectLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</SelectLabel>
{group.options.map(option => (
<SelectItem key={option.id} value={serializeVariableSelector(option.variableSelector)}>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{option.itemLabel}</span>
<span className="ml-auto shrink-0 system-xs-medium text-text-quaternary">
{t(getConditionMetricValueTypeTranslationKey(option.valueType))}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
)
}
const ConditionOperatorSelect = ({
operator,
operators,
onChange,
}: ConditionOperatorSelectProps) => {
const { t } = useTranslation()
return (
<Select value={operator} onValueChange={value => value && onChange(value as ComparisonOperator)}>
<SelectTrigger className="h-8 w-auto min-w-[88px] gap-1 rounded-md bg-transparent px-1.5 py-0 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<span className="truncate system-xs-medium text-text-secondary">{getComparisonOperatorLabel(operator, t)}</span>
</SelectTrigger>
<SelectContent className="z-1002" popupClassName="w-[240px] bg-components-panel-bg-blur backdrop-blur-[10px]">
{operators.map(nextOperator => (
<SelectItem key={nextOperator} value={nextOperator}>
{getComparisonOperatorLabel(nextOperator, t)}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
const ConditionValueInput = ({
metric,
condition,
onChange,
}: ConditionValueInputProps) => {
const { t } = useTranslation('evaluation')
if (!metric || !requiresConditionValue(condition.comparisonOperator))
return null
if (metric.valueType === 'boolean') {
return (
<div className="px-2 py-1.5">
<Select value={condition.value === null ? '' : String(condition.value)} onValueChange={nextValue => onChange(nextValue === 'true')}>
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<SelectValue placeholder={t('conditions.selectValue')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">{t('conditions.boolean.true')}</SelectItem>
<SelectItem value="false">{t('conditions.boolean.false')}</SelectItem>
</SelectContent>
</Select>
</div>
)
}
const isMultiValue = condition.comparisonOperator === 'in' || condition.comparisonOperator === 'not in'
const inputValue = Array.isArray(condition.value)
? condition.value.join(', ')
: typeof condition.value === 'boolean'
? ''
: condition.value ?? ''
return (
<div className="px-2 py-1.5">
<Input
type={metric.valueType === 'number' && !isMultiValue ? 'number' : 'text'}
value={inputValue}
className="border-none bg-transparent shadow-none hover:border-none hover:bg-state-base-hover-alt focus:border-none focus:bg-state-base-hover-alt focus:shadow-none"
placeholder={t('conditions.valuePlaceholder')}
onChange={(e) => {
if (isMultiValue) {
onChange(e.target.value.split(',').map(item => item.trim()).filter(Boolean))
return
}
onChange(e.target.value === '' ? null : e.target.value)
}}
/>
</div>
)
}
const ConditionGroup = ({
resourceType,
resourceId,
}: ConditionGroupProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const metricOptions = useMemo(() => buildConditionMetricOptions(resource.metrics), [resource.metrics])
const logicalOperator = resource.judgmentConfig.logicalOperator
const logicalLabels = {
and: t('conditions.logical.and'),
or: t('conditions.logical.or'),
}
const hasMultipleConditions = resource.judgmentConfig.conditions.length > 1
const setConditionLogicalOperator = useEvaluationStore(state => state.setConditionLogicalOperator)
const removeCondition = useEvaluationStore(state => state.removeCondition)
const updateConditionMetric = useEvaluationStore(state => state.updateConditionMetric)
const updateConditionOperator = useEvaluationStore(state => state.updateConditionOperator)
const updateConditionValue = useEvaluationStore(state => state.updateConditionValue)
const toggleLogicalOperator = () => {
setConditionLogicalOperator(resourceType, resourceId, logicalOperator === 'and' ? 'or' : 'and')
}
return (
<div className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
<div className={cn('relative', hasMultipleConditions && 'pl-[48px]')}>
{hasMultipleConditions && (
<div className="absolute top-0 bottom-0 left-0 w-[48px]">
<div className="absolute top-4 bottom-4 left-[34px] w-2.5 rounded-l-[8px] border border-r-0 border-divider-deep" />
<div className="absolute top-1/2 right-0 h-[29px] w-4 -translate-y-1/2 bg-components-card-bg" />
<button
type="button"
aria-label={logicalLabels[logicalOperator]}
className="absolute top-1/2 right-1 flex h-[21px] -translate-y-1/2 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1 text-[10px] font-semibold text-text-accent-secondary shadow-xs select-none"
onClick={toggleLogicalOperator}
>
{logicalLabels[logicalOperator]}
<span aria-hidden="true" className="ml-0.5 i-ri-loop-left-line h-3 w-3" />
</button>
</div>
)}
<div className="space-y-3">
{resource.judgmentConfig.conditions.map((condition) => {
const metric = metricOptions.find(option => isSelectorEqual(option.variableSelector, condition.variableSelector))
const allowedOperators = getAllowedOperators(resource.metrics, condition.variableSelector)
const showValue = !!metric && requiresConditionValue(condition.comparisonOperator)
return (
<div key={condition.id} className="flex items-start overflow-hidden rounded-lg">
<div className="min-w-0 flex-1 rounded-lg bg-components-input-bg-normal">
<div className="flex items-center gap-0 pr-1">
<div className="min-w-0 flex-1 py-1">
<ConditionMetricSelect
metric={metric}
metricOptions={metricOptions}
placeholder={t('conditions.fieldPlaceholder')}
onChange={value => updateConditionMetric(resourceType, resourceId, condition.id, value)}
/>
</div>
<div className="h-3 w-px bg-divider-regular" />
<ConditionOperatorSelect
operator={condition.comparisonOperator}
operators={allowedOperators}
onChange={value => updateConditionOperator(resourceType, resourceId, condition.id, value)}
/>
</div>
{showValue && (
<div className="border-t border-divider-subtle">
<ConditionValueInput
metric={metric}
condition={condition}
onChange={value => updateConditionValue(resourceType, resourceId, condition.id, value)}
/>
</div>
)}
</div>
<div className="pt-1 pl-1">
<ActionButton
aria-label={t('conditions.removeCondition')}
onClick={() => removeCondition(resourceType, resourceId, condition.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</ActionButton>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
export default ConditionGroup

View File

@ -0,0 +1,51 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useEvaluationResource } from '../../store'
import { buildConditionMetricOptions, groupConditionMetricOptions } from '../../utils'
import { InlineSectionHeader } from '../section-header'
import AddConditionSelect from './add-condition-select'
import ConditionGroup from './condition-group'
const ConditionsSection = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const conditionMetricOptions = useMemo(() => buildConditionMetricOptions(resource.metrics), [resource.metrics])
const groupedConditionMetricOptions = useMemo(() => groupConditionMetricOptions(conditionMetricOptions), [conditionMetricOptions])
const canAddCondition = conditionMetricOptions.length > 0
return (
<section className="max-w-[700px] py-4">
<InlineSectionHeader
title={t('conditions.title')}
tooltip={t('conditions.description')}
/>
<div className="mt-2 space-y-4">
{resource.judgmentConfig.conditions.length === 0 && (
<div className="rounded-xl bg-background-section px-3 py-3 system-xs-regular text-text-tertiary">
{t('conditions.emptyDescription')}
</div>
)}
{resource.judgmentConfig.conditions.length > 0 && (
<ConditionGroup
resourceType={resourceType}
resourceId={resourceId}
/>
)}
<AddConditionSelect
resourceType={resourceType}
resourceId={resourceId}
metricOptionGroups={groupedConditionMetricOptions}
disabled={!canAddCondition}
/>
</div>
</section>
)
}
export default ConditionsSection

View File

@ -0,0 +1,80 @@
'use client'
import type { EvaluationResourceProps } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useTranslation } from 'react-i18next'
import { useSaveEvaluationConfigMutation } from '@/service/use-evaluation'
import {
isEvaluationRunnable,
useEvaluationResource,
useEvaluationStore,
useIsEvaluationConfigDirty,
} from '../store'
import { buildEvaluationConfigPayload } from '../store-utils'
const EvaluationConfigActions = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const resource = useEvaluationResource(resourceType, resourceId)
const isDirty = useIsEvaluationConfigDirty(resourceType, resourceId)
const resetResourceConfig = useEvaluationStore(state => state.resetResourceConfig)
const markResourceConfigSaved = useEvaluationStore(state => state.markResourceConfigSaved)
const saveConfigMutation = useSaveEvaluationConfigMutation()
const isRunnable = isEvaluationRunnable(resource)
const handleSave = () => {
if (!isRunnable) {
toast.warning(t('batch.validation'))
return
}
const body = buildEvaluationConfigPayload(resource, resourceType)
if (!body) {
toast.warning(t('batch.validation'))
return
}
saveConfigMutation.mutate({
params: {
targetType: resourceType,
targetId: resourceId,
},
body,
}, {
onSuccess: () => {
markResourceConfigSaved(resourceType, resourceId)
toast.success(tCommon('api.saved'))
},
onError: () => {
toast.error(t('config.saveFailed'))
},
})
}
return (
<div className="flex shrink-0 items-center gap-2">
<Button
variant="secondary"
disabled={!isDirty || saveConfigMutation.isPending}
onClick={() => resetResourceConfig(resourceType, resourceId)}
>
{tCommon('operation.reset')}
</Button>
<Button
variant="primary"
disabled={!isRunnable}
loading={saveConfigMutation.isPending}
onClick={handleSave}
>
{tCommon('operation.save')}
</Button>
</div>
)
}
export default EvaluationConfigActions

View File

@ -0,0 +1,441 @@
import type { EvaluationMetric } from '../../../types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { Node } from '@/app/components/workflow/types'
import type { SnippetWorkflow } from '@/types/snippet'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import CustomMetricEditorCard from '..'
import { useEvaluationStore } from '../../../store'
const mockUseAppWorkflow = vi.hoisted(() => vi.fn())
const mockUseAppDetail = vi.hoisted(() => vi.fn())
const mockUseSnippetPublishedWorkflow = vi.hoisted(() => vi.fn())
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseInfiniteScroll = vi.hoisted(() => vi.fn())
const mockPublishedGraphVariablePicker = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: (...args: unknown[]) => mockUseAppWorkflow(...args),
}))
vi.mock('@/service/use-apps', () => ({
useAppDetail: (...args: unknown[]) => mockUseAppDetail(...args),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
}))
vi.mock('ahooks', () => ({
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
}))
vi.mock('../published-graph-variable-picker', () => ({
default: (props: Record<string, unknown>) => {
mockPublishedGraphVariablePicker(props)
return <div data-testid="published-graph-variable-picker" />
},
}))
const createStartNode = (): Node<StartNodeType> => ({
id: 'start-node',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: 'Start',
desc: '',
variables: [
{
variable: 'user_question',
label: 'User Question',
type: InputVarType.textInput,
required: true,
},
{
variable: 'retrieved_context',
label: 'Retrieved Context',
type: InputVarType.textInput,
required: true,
},
],
},
})
const createEndNode = (
outputs: EndNodeType['outputs'],
): Node<EndNodeType> => ({
id: 'end-node',
type: 'custom',
position: { x: 100, y: 0 },
data: {
type: BlockEnum.End,
title: 'End',
desc: '',
outputs,
},
})
const createCodeNode = (
id: string,
title: string,
outputs: Record<string, { type: VarType }>,
): Node<CodeNodeType> => ({
id,
type: 'custom',
position: { x: 100, y: 0 },
data: {
type: BlockEnum.Code,
title,
desc: '',
code: '',
code_language: CodeLanguage.python3,
outputs: Object.fromEntries(
Object.entries(outputs).map(([key, value]) => [
key,
{
type: value.type,
children: null,
},
]),
),
variables: [],
},
})
const createWorkflow = (
nodes: Node[],
): FetchWorkflowDraftResponse => ({
id: 'workflow-1',
graph: {
nodes,
edges: [],
},
features: {},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'User One',
email: 'user-one@example.com',
},
hash: 'hash-1',
updated_at: 1710000001,
updated_by: {
id: 'user-2',
name: 'User Two',
email: 'user-two@example.com',
},
tool_published: true,
environment_variables: [],
conversation_variables: [],
version: '1',
marked_name: 'Evaluation Workflow',
marked_comment: 'Published',
})
const createSnippetWorkflow = (
nodes: Node[],
): SnippetWorkflow => ({
id: 'snippet-workflow-1',
graph: {
nodes,
edges: [],
},
features: {},
hash: 'snippet-hash-1',
created_at: 1710000000,
updated_at: 1710000001,
})
const createMetric = (): EvaluationMetric => ({
id: 'metric-1',
optionId: 'custom-1',
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
valueType: 'number',
customConfig: {
workflowId: 'workflow-1',
workflowAppId: 'workflow-app-1',
workflowName: 'Evaluation Workflow',
mappings: [{
id: 'mapping-1',
inputVariableId: 'user_question',
outputVariableId: 'current-node.answer',
}, {
id: 'mapping-2',
inputVariableId: 'retrieved_context',
outputVariableId: 'current-node.score',
}],
outputs: [],
},
})
describe('CustomMetricEditorCard', () => {
beforeEach(() => {
vi.clearAllMocks()
useEvaluationStore.setState({ resources: {} })
mockPublishedGraphVariablePicker.mockReset()
mockUseAppDetail.mockReturnValue({ data: undefined })
mockUseInfiniteScroll.mockImplementation(() => undefined)
mockUseAvailableEvaluationWorkflows.mockReturnValue({
data: {
pages: [{
items: [],
page: 1,
limit: 20,
has_more: false,
}],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
})
mockUseSnippetPublishedWorkflow.mockReturnValue({ data: undefined })
})
afterEach(() => {
vi.restoreAllMocks()
})
// Verify the selected evaluation workflow still drives the output summary section.
describe('Outputs', () => {
it('should render the selected workflow outputs from the end node', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentAppWorkflow = createWorkflow([
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
score: { type: VarType.number },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.getByText('evaluation.metrics.custom.outputTitle')).toBeInTheDocument()
expect(screen.getAllByText('answer_score').length).toBeGreaterThan(0)
expect(screen.getAllByText('number').length).toBeGreaterThan(0)
expect(screen.getAllByText('reason').length).toBeGreaterThan(0)
expect(screen.getAllByText('string').length).toBeGreaterThan(0)
})
it('should hide the output section when the selected workflow has no end outputs', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([]),
])
const currentAppWorkflow = createWorkflow([
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.queryByText('evaluation.metrics.custom.outputTitle')).not.toBeInTheDocument()
})
})
// Verify mapping rows use workflow start variables on the left and current published graph variables on the right.
describe('Variable Mapping', () => {
it('should preserve saved mappings and outputs while the selected workflow is loading', () => {
const baseMetric = createMetric()
const metric = {
...baseMetric,
customConfig: {
...baseMetric.customConfig!,
outputs: [{ id: 'score', valueType: 'number' }],
},
}
const syncMappingsSpy = vi.spyOn(useEvaluationStore.getState(), 'syncCustomMetricMappings')
const syncOutputsSpy = vi.spyOn(useEvaluationStore.getState(), 'syncCustomMetricOutputs')
mockUseAppWorkflow.mockReturnValue({ data: undefined })
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={metric}
/>,
)
expect(screen.getByText('Evaluation Workflow')).toBeInTheDocument()
expect(syncMappingsSpy).not.toHaveBeenCalled()
expect(syncOutputsSpy).not.toHaveBeenCalled()
})
it('should show the selected workflow app name from app detail when the config only has workflow id', () => {
const selectedWorkflow = {
...createWorkflow([createStartNode()]),
marked_name: '',
}
const baseMetric = createMetric()
const metric = {
...baseMetric,
customConfig: {
...baseMetric.customConfig!,
workflowName: null,
},
}
mockUseAppDetail.mockReturnValue({
data: {
id: 'workflow-app-1',
name: 'Review Workflow App',
},
})
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={metric}
/>,
)
expect(mockUseAppDetail).toHaveBeenCalledWith('workflow-app-1')
expect(screen.getByText('Review Workflow App')).toBeInTheDocument()
expect(screen.queryByText('workflow-1')).not.toBeInTheDocument()
})
it('should pass the current app published graph and saved selector values to the picker', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentAppWorkflow = createWorkflow([
createStartNode(),
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
score: { type: VarType.number },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.getByText('user_question')).toBeInTheDocument()
expect(screen.getByText('retrieved_context')).toBeInTheDocument()
expect(screen.getAllByText('string')).toHaveLength(3)
expect(mockPublishedGraphVariablePicker).toHaveBeenCalledTimes(2)
expect(mockPublishedGraphVariablePicker.mock.calls[0][0]).toMatchObject({
nodes: currentAppWorkflow.graph.nodes,
edges: currentAppWorkflow.graph.edges,
value: 'current-node.answer',
})
expect(mockPublishedGraphVariablePicker.mock.calls[1][0]).toMatchObject({
nodes: currentAppWorkflow.graph.nodes,
edges: currentAppWorkflow.graph.edges,
value: 'current-node.score',
})
})
it('should use the current snippet published graph when editing a snippet evaluation', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentSnippetWorkflow = createSnippetWorkflow([
createCodeNode('snippet-node', 'Snippet Node', {
result: { type: VarType.string },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
return { data: undefined }
})
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: currentSnippetWorkflow,
})
render(
<CustomMetricEditorCard
resourceType="snippets"
resourceId="snippet-under-test"
metric={createMetric()}
/>,
)
expect(mockPublishedGraphVariablePicker).toHaveBeenCalledTimes(2)
expect(mockPublishedGraphVariablePicker.mock.calls[0][0]).toMatchObject({
nodes: currentSnippetWorkflow.graph.nodes,
edges: currentSnippetWorkflow.graph.edges,
})
})
})
})

View File

@ -0,0 +1,176 @@
import type { ComponentProps } from 'react'
import type { AvailableEvaluationWorkflow } from '@/types/evaluation'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import WorkflowSelector from '../workflow-selector'
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseInfiniteScroll = vi.hoisted(() => vi.fn())
let loadMoreHandler: (() => Promise<{ list: unknown[] }>) | null = null
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
}))
vi.mock('ahooks', () => ({
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
}))
const createWorkflow = (
overrides: Partial<AvailableEvaluationWorkflow> = {},
): AvailableEvaluationWorkflow => ({
id: 'workflow-1',
app_id: 'app-1',
app_name: 'Review Workflow App',
type: 'evaluation',
version: '1',
marked_name: 'Review Workflow',
marked_comment: 'Production release',
hash: 'hash-1',
created_by: {
id: 'user-1',
name: 'User One',
email: 'user-one@example.com',
},
created_at: 1710000000,
updated_by: null,
updated_at: 1710000000,
...overrides,
})
const setupWorkflowQueryMock = (overrides?: {
workflows?: AvailableEvaluationWorkflow[]
hasNextPage?: boolean
isFetchingNextPage?: boolean
}) => {
const fetchNextPage = vi.fn()
mockUseAvailableEvaluationWorkflows.mockReturnValue({
data: {
pages: [{
items: overrides?.workflows ?? [createWorkflow()],
page: 1,
limit: 20,
has_more: overrides?.hasNextPage ?? false,
}],
},
fetchNextPage,
hasNextPage: overrides?.hasNextPage ?? false,
isFetching: false,
isFetchingNextPage: overrides?.isFetchingNextPage ?? false,
isLoading: false,
})
return { fetchNextPage }
}
const renderWorkflowSelector = (props?: Partial<ComponentProps<typeof WorkflowSelector>>) => {
return render(
<WorkflowSelector
value={null}
onSelect={vi.fn()}
{...props}
/>,
)
}
describe('WorkflowSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
loadMoreHandler = null
setupWorkflowQueryMock()
mockUseInfiniteScroll.mockImplementation((handler) => {
loadMoreHandler = handler as () => Promise<{ list: unknown[] }>
})
})
// Cover trigger rendering and selected label fallback.
describe('Rendering', () => {
it('should render the workflow placeholder when value is empty', () => {
renderWorkflowSelector()
expect(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' })).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.workflowPlaceholder')).toBeInTheDocument()
})
it('should render the selected workflow name from props when value is set', () => {
setupWorkflowQueryMock({ workflows: [] })
renderWorkflowSelector({
value: 'app-1',
selectedWorkflowName: 'Saved Review Workflow',
})
expect(screen.getByText('Saved Review Workflow')).toBeInTheDocument()
})
it('should resolve the selected workflow from app id', () => {
setupWorkflowQueryMock()
renderWorkflowSelector({
value: 'app-1',
})
expect(screen.getByText('Review Workflow')).toBeInTheDocument()
})
})
// Cover opening the popover and choosing one workflow option.
describe('Interactions', () => {
it('should call onSelect with the clicked workflow', async () => {
const onSelect = vi.fn()
renderWorkflowSelector({ onSelect })
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' }))
const option = await screen.findByRole('option', { name: 'Review Workflow' })
fireEvent.click(option)
expect(onSelect).toHaveBeenCalledWith(createWorkflow())
})
it('should mark the option selected when its app id matches the value', async () => {
renderWorkflowSelector({ value: 'app-1' })
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' }))
expect(await screen.findByRole('option', { name: 'Review Workflow', selected: true })).toBeInTheDocument()
})
})
// Cover the infinite-scroll callback used by the ScrollArea viewport.
describe('Pagination', () => {
it('should fetch the next page when the load-more callback runs and more pages exist', async () => {
const { fetchNextPage } = setupWorkflowQueryMock({ hasNextPage: true })
renderWorkflowSelector()
await waitFor(() => expect(loadMoreHandler).not.toBeNull())
await act(async () => {
await loadMoreHandler?.()
})
expect(fetchNextPage).toHaveBeenCalledTimes(1)
})
it('should not fetch the next page when the current request is already fetching', async () => {
const { fetchNextPage } = setupWorkflowQueryMock({
hasNextPage: true,
isFetchingNextPage: true,
})
renderWorkflowSelector()
await waitFor(() => expect(loadMoreHandler).not.toBeNull())
await act(async () => {
await loadMoreHandler?.()
})
expect(fetchNextPage).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,222 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { Edge, InputVar, Node } from '@/app/components/workflow/types'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { useAppDetail } from '@/service/use-apps'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useAppWorkflow } from '@/service/use-workflow'
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
import MappingRow from './mapping-row'
import WorkflowSelector from './workflow-selector'
type CustomMetricEditorCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
}
const getWorkflowInputVariables = (
nodes?: Array<Node>,
) => {
const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node<StartNodeType> | undefined
if (!startNode || !Array.isArray(startNode.data.variables))
return []
return startNode.data.variables.map((variable: InputVar) => ({
id: variable.variable,
valueType: inputVarTypeToVarType(variable.type ?? InputVarType.textInput),
}))
}
const getWorkflowOutputs = (nodes?: Array<Node>) => {
return (nodes ?? [])
.filter(node => node.data.type === BlockEnum.End)
.flatMap((node) => {
const endNode = node as Node<EndNodeType>
if (!Array.isArray(endNode.data.outputs))
return []
return endNode.data.outputs
.filter(output => typeof output.variable === 'string' && !!output.variable)
.map(output => ({
id: output.variable,
valueType: typeof output.value_type === 'string' ? output.value_type : null,
nodeId: endNode.id,
nodeTitle: typeof endNode.data.title === 'string' && endNode.data.title ? endNode.data.title : 'End',
}))
})
}
const getWorkflowName = (workflow: {
marked_name?: string
app_name?: string
id: string
}) => {
return workflow.marked_name || workflow.app_name || workflow.id
}
const getGraphNodes = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
}
const getGraphEdges = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.edges) ? graph.edges as Edge[] : []
}
const CustomMetricEditorCard = ({
resourceType,
resourceId,
metric,
}: CustomMetricEditorCardProps) => {
const { t } = useTranslation('evaluation')
const setCustomMetricWorkflow = useEvaluationStore(state => state.setCustomMetricWorkflow)
const syncCustomMetricMappings = useEvaluationStore(state => state.syncCustomMetricMappings)
const syncCustomMetricOutputs = useEvaluationStore(state => state.syncCustomMetricOutputs)
const updateCustomMetricMapping = useEvaluationStore(state => state.updateCustomMetricMapping)
const selectedWorkflowAppId = metric.customConfig?.workflowAppId ?? metric.customConfig?.workflowId ?? ''
const { data: selectedWorkflowApp } = useAppDetail(selectedWorkflowAppId)
const { data: selectedWorkflow } = useAppWorkflow(selectedWorkflowAppId)
const { data: currentAppWorkflow } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
const { data: currentSnippetWorkflow } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '')
const inputVariables = useMemo(() => {
return getWorkflowInputVariables(selectedWorkflow?.graph.nodes)
}, [selectedWorkflow?.graph.nodes])
const workflowOutputs = useMemo(() => {
return getWorkflowOutputs(selectedWorkflow?.graph.nodes)
}, [selectedWorkflow?.graph.nodes])
const publishedGraph = useMemo(() => {
if (resourceType === 'apps') {
return {
nodes: currentAppWorkflow?.graph.nodes ?? [],
edges: currentAppWorkflow?.graph.edges ?? [],
environmentVariables: currentAppWorkflow?.environment_variables ?? [],
conversationVariables: currentAppWorkflow?.conversation_variables ?? [],
}
}
return {
nodes: getGraphNodes(currentSnippetWorkflow?.graph),
edges: getGraphEdges(currentSnippetWorkflow?.graph),
environmentVariables: [],
conversationVariables: [],
}
}, [
currentAppWorkflow?.conversation_variables,
currentAppWorkflow?.environment_variables,
currentAppWorkflow?.graph.edges,
currentAppWorkflow?.graph.nodes,
currentSnippetWorkflow?.graph,
resourceType,
])
const inputVariableIds = useMemo(() => inputVariables.map(variable => variable.id), [inputVariables])
const isConfigured = isCustomMetricConfigured(metric)
const isSelectedWorkflowLoaded = !!selectedWorkflow
useEffect(() => {
if (!metric.customConfig?.workflowId || !isSelectedWorkflowLoaded)
return
const currentInputVariableIds = metric.customConfig.mappings
.map(mapping => mapping.inputVariableId)
.filter((value): value is string => !!value)
if (currentInputVariableIds.length === inputVariableIds.length
&& currentInputVariableIds.every((value, index) => value === inputVariableIds[index])) {
return
}
syncCustomMetricMappings(resourceType, resourceId, metric.id, inputVariableIds)
}, [inputVariableIds, isSelectedWorkflowLoaded, metric.customConfig?.mappings, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricMappings])
useEffect(() => {
if (!metric.customConfig?.workflowId || !isSelectedWorkflowLoaded)
return
const currentOutputs = metric.customConfig.outputs
if (
currentOutputs.length === workflowOutputs.length
&& currentOutputs.every((output, index) =>
output.id === workflowOutputs[index]?.id && output.valueType === workflowOutputs[index]?.valueType,
)
) {
return
}
syncCustomMetricOutputs(resourceType, resourceId, metric.id, workflowOutputs)
}, [isSelectedWorkflowLoaded, metric.customConfig?.outputs, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricOutputs, workflowOutputs])
if (!metric.customConfig)
return null
return (
<div className="px-3 pt-1 pb-3">
<WorkflowSelector
value={metric.customConfig.workflowId}
selectedWorkflowName={metric.customConfig.workflowName ?? selectedWorkflowApp?.name ?? null}
onSelect={workflow => setCustomMetricWorkflow(resourceType, resourceId, metric.id, {
workflowId: workflow.app_id,
workflowAppId: workflow.app_id,
workflowName: getWorkflowName(workflow),
})}
/>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('metrics.custom.mappingTitle')}</div>
</div>
<div className="space-y-2">
{inputVariables.map((inputVariable) => {
const mapping = metric.customConfig?.mappings.find(item => item.inputVariableId === inputVariable.id)
return (
<MappingRow
key={inputVariable.id}
inputVariable={inputVariable}
publishedGraph={publishedGraph}
value={mapping?.outputVariableId ?? null}
onUpdate={(outputVariableId) => {
if (!mapping)
return
updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, { outputVariableId })
}}
/>
)
})}
</div>
{!isConfigured && (
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 system-xs-regular text-text-tertiary">
{t('metrics.custom.mappingWarning')}
</div>
)}
</div>
{!!workflowOutputs.length && (
<div className="mt-4 py-1">
<div className="min-h-6 system-xs-medium-uppercase text-text-tertiary">
{t('metrics.custom.outputTitle')}
</div>
<div className="flex flex-wrap items-center gap-y-1 px-2 py-2 system-xs-regular text-text-tertiary">
{workflowOutputs.map((output, index) => (
<div key={`${output.nodeId}-${output.id}`} className="flex items-center">
<span className="px-1 system-xs-medium text-text-secondary">{output.id}</span>
{output.valueType && (
<span>{output.valueType}</span>
)}
{index < workflowOutputs.length - 1 && (
<span className="pl-0.5">,</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
export default CustomMetricEditorCard

View File

@ -0,0 +1,64 @@
'use client'
import type {
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
} from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import PublishedGraphVariablePicker from './published-graph-variable-picker'
type MappingRowProps = {
inputVariable: {
id: string
valueType: string
}
publishedGraph: {
nodes: Node[]
edges: Edge[]
environmentVariables: EnvironmentVariable[]
conversationVariables: ConversationVariable[]
}
value: string | null
onUpdate: (outputVariableId: string | null) => void
}
const MappingRow = ({
inputVariable,
publishedGraph,
value,
onUpdate,
}: MappingRowProps) => {
const { t } = useTranslation('evaluation')
return (
<div className="flex items-center">
<div className="flex h-8 w-[200px] items-center rounded-md px-2">
<div className="flex min-w-0 items-center gap-0.5 px-1">
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
<div className="truncate system-xs-medium text-text-secondary">{inputVariable.id}</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{inputVariable.valueType}</div>
</div>
</div>
<div className="flex h-8 w-9 items-center justify-center px-3 system-xs-medium text-text-tertiary">
<span aria-hidden="true"></span>
</div>
<PublishedGraphVariablePicker
className="grow"
nodes={publishedGraph.nodes}
edges={publishedGraph.edges}
environmentVariables={publishedGraph.environmentVariables}
conversationVariables={publishedGraph.conversationVariables}
value={value}
placeholder={t('metrics.custom.outputPlaceholder')}
onChange={onUpdate}
/>
</div>
)
}
export default MappingRow

View File

@ -0,0 +1,118 @@
'use client'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type {
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
ValueSelector,
} from '@/app/components/workflow/types'
import { useMemo } from 'react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createHooksStore, HooksStoreContext } from '@/app/components/workflow/hooks-store'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import { variableTransformer } from '@/app/components/workflow/utils/variable'
type PublishedGraphVariablePickerProps = {
className?: string
nodes: Node[]
edges: Edge[]
environmentVariables?: EnvironmentVariable[]
conversationVariables?: ConversationVariable[]
placeholder: string
value: string | null
onChange: (value: string | null) => void
}
const PICKER_NODE_ID = '__evaluation-variable-picker__'
const createPickerNode = (): Node<EndNodeType> => ({
id: PICKER_NODE_ID,
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.End,
title: 'End',
desc: '',
outputs: [],
},
})
const PublishedGraphVariablePicker = ({
className,
nodes,
edges,
environmentVariables = [],
conversationVariables = [],
placeholder,
value,
onChange,
}: PublishedGraphVariablePickerProps) => {
const workflowStore = useMemo(() => {
const store = createWorkflowStore({})
store.setState({
isWorkflowDataLoaded: true,
environmentVariables,
conversationVariables,
ragPipelineVariables: [],
dataSourceList: [],
})
return store
}, [conversationVariables, environmentVariables])
const hooksStore = useMemo(() => createHooksStore({}), [])
const pickerNodes = useMemo(() => {
return [...nodes, createPickerNode()]
}, [nodes])
const pickerValue = useMemo<ValueSelector>(() => {
if (!value)
return []
return variableTransformer(value) as ValueSelector
}, [value])
return (
<WorkflowContext.Provider value={workflowStore}>
<HooksStoreContext.Provider value={hooksStore}>
<div id="workflow-container" className={className}>
<ReactFlowProvider>
<div
aria-hidden="true"
className="pointer-events-none absolute h-px w-px overflow-hidden opacity-0"
>
<div style={{ width: 800, height: 600 }}>
<ReactFlow nodes={pickerNodes} edges={edges} fitView />
</div>
</div>
<VarReferencePicker
className="grow"
nodeId={PICKER_NODE_ID}
readonly={!nodes.length}
isShowNodeName
value={pickerValue}
onChange={(nextValue) => {
if (!Array.isArray(nextValue) || !nextValue.length) {
onChange(null)
return
}
onChange(nextValue.join('.'))
}}
availableNodes={nodes}
placeholder={placeholder}
/>
</ReactFlowProvider>
</div>
</HooksStoreContext.Provider>
</WorkflowContext.Provider>
)
}
export default PublishedGraphVariablePicker

View File

@ -0,0 +1,219 @@
'use client'
import type { AvailableEvaluationWorkflow } from '@/types/evaluation'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
ScrollAreaContent,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useInfiniteScroll } from 'ahooks'
import * as React from 'react'
import { useDeferredValue, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import { useAvailableEvaluationWorkflows } from '@/service/use-evaluation'
type WorkflowSelectorProps = {
value: string | null
selectedWorkflowName?: string | null
onSelect: (workflow: AvailableEvaluationWorkflow) => void
}
const PAGE_SIZE = 20
const getWorkflowName = (workflow: AvailableEvaluationWorkflow) => {
return workflow.marked_name || workflow.app_name || workflow.id
}
const isSelectedWorkflow = (
workflow: AvailableEvaluationWorkflow,
value: string | null,
) => workflow.app_id === value
const WorkflowSelector = ({
value,
selectedWorkflowName,
onSelect,
}: WorkflowSelectorProps) => {
const { t } = useTranslation('evaluation')
const [isOpen, setIsOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const deferredSearchText = useDeferredValue(searchText)
const viewportRef = useRef<HTMLDivElement>(null)
const keyword = deferredSearchText.trim() || undefined
const {
data,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
isLoading,
} = useAvailableEvaluationWorkflows(
{
page: 1,
limit: PAGE_SIZE,
keyword,
},
{ enabled: isOpen },
)
const workflows = useMemo(() => {
return (data?.pages ?? []).flatMap(page => page.items)
}, [data?.pages])
const currentWorkflowName = useMemo(() => {
if (!value)
return null
const selectedWorkflow = workflows.find(workflow => isSelectedWorkflow(workflow, value))
if (selectedWorkflow)
return getWorkflowName(selectedWorkflow)
return selectedWorkflowName ?? null
}, [selectedWorkflowName, value, workflows])
const isNoMore = hasNextPage === false
useInfiniteScroll(
async () => {
if (!hasNextPage || isFetchingNextPage)
return { list: [] }
await fetchNextPage()
return { list: [] }
},
{
target: viewportRef,
isNoMore: () => isNoMore,
reloadDeps: [isFetchingNextPage, isNoMore, keyword],
},
)
const handleOpenChange = (nextOpen: boolean) => {
setIsOpen(nextOpen)
if (!nextOpen)
setSearchText('')
}
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
<button
type="button"
className="group flex w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 text-left outline-hidden hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal"
aria-label={t('metrics.custom.workflowLabel')}
>
<div className="flex min-w-0 grow items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5 text-text-tertiary" />
</div>
</div>
<div className="min-w-0 flex-1 px-1 py-1 text-left">
<div className={cn(
'truncate system-sm-regular',
currentWorkflowName ? 'text-text-secondary' : 'text-components-input-text-placeholder',
)}
>
{currentWorkflowName ?? t('metrics.custom.workflowPlaceholder')}
</div>
</div>
</div>
<span className="shrink-0 px-1 text-text-quaternary transition-colors group-hover:text-text-secondary">
<span aria-hidden="true" className="i-ri-arrow-down-s-line h-4 w-4" />
</span>
</button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[360px] overflow-hidden p-0"
>
<div className="bg-components-panel-bg">
<div className="p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={searchText}
onChange={event => setSearchText(event.target.value)}
onClear={() => setSearchText('')}
/>
</div>
{(isLoading || (isFetching && workflows.length === 0))
? (
<div className="flex h-[120px] items-center justify-center">
<Loading type="area" />
</div>
)
: !workflows.length
? (
<div className="flex h-[120px] items-center justify-center system-sm-regular text-text-tertiary">
{t('noData', { ns: 'common' })}
</div>
)
: (
<ScrollAreaRoot className="relative max-h-[240px] overflow-hidden">
<ScrollAreaViewport ref={viewportRef}>
<ScrollAreaContent className="p-1" role="listbox" aria-label={t('metrics.custom.workflowLabel')}>
{workflows.map(workflow => (
<button
key={workflow.id}
type="button"
role="option"
aria-selected={isSelectedWorkflow(workflow, value)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-left hover:bg-state-base-hover"
onClick={() => {
onSelect(workflow)
setIsOpen(false)
setSearchText('')
}}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5 text-text-tertiary" />
</div>
</div>
<div className="min-w-0 flex-1 truncate px-1 py-1 system-sm-medium text-text-secondary">
{getWorkflowName(workflow)}
</div>
{isSelectedWorkflow(workflow, value) && (
<span aria-hidden="true" className="i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
)}
</button>
))}
{isFetchingNextPage && (
<div className="flex justify-center px-3 py-2">
<Loading />
</div>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)}
</div>
</PopoverContent>
</Popover>
)
}
export default React.memo(WorkflowSelector)

View File

@ -0,0 +1,30 @@
'use client'
import type { EvaluationResourceProps } from '../types'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useEvaluationResource, useEvaluationStore } from '../store'
import { decodeModelSelection, encodeModelSelection } from '../utils'
const JudgeModelSelector = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { data: modelList } = useModelList(ModelTypeEnum.textGeneration)
const resource = useEvaluationResource(resourceType, resourceId)
const setJudgeModel = useEvaluationStore(state => state.setJudgeModel)
const selectedModel = decodeModelSelection(resource.judgeModelId)
return (
<ModelSelector
defaultModel={selectedModel}
modelList={modelList}
onSelect={model => setJudgeModel(resourceType, resourceId, encodeModelSelection(model.provider, model.model))}
showDeprecatedWarnIcon
triggerClassName="h-8 w-full rounded-lg"
/>
)
}
export default JudgeModelSelector

View File

@ -0,0 +1,64 @@
'use client'
import type { NonPipelineEvaluationResourceProps } from '../../types'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import BatchTestPanel from '../batch-test-panel'
import ConditionsSection from '../conditions-section'
import EvaluationConfigActions from '../config-actions'
import JudgeModelSelector from '../judge-model-selector'
import MetricSection from '../metric-section'
import SectionHeader, { InlineSectionHeader } from '../section-header'
const NonPipelineEvaluation = ({
resourceType,
resourceId,
}: NonPipelineEvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const docLink = useDocLink()
return (
<div className="flex h-full min-h-0 flex-col bg-background-default xl:flex-row">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex min-h-full max-w-[748px] flex-col px-6 py-4">
<SectionHeader
title={t('title')}
description={(
<>
{t('description')}
{' '}
<a
className="text-text-accent"
href={docLink()}
target="_blank"
rel="noopener noreferrer"
>
{tCommon('operation.learnMore')}
</a>
</>
)}
descriptionClassName="max-w-[700px]"
action={<EvaluationConfigActions resourceType={resourceType} resourceId={resourceId} />}
/>
<section className="max-w-[700px] py-4">
<InlineSectionHeader title={t('judgeModel.title')} tooltip={t('judgeModel.description')} />
<div className="mt-1.5">
<JudgeModelSelector resourceType={resourceType} resourceId={resourceId} />
</div>
</section>
<div className="max-w-[700px] border-b border-divider-subtle" />
<MetricSection resourceType={resourceType} resourceId={resourceId} />
<div className="max-w-[700px] border-b border-divider-subtle" />
<ConditionsSection resourceType={resourceType} resourceId={resourceId} />
</div>
</div>
<div className="h-[420px] shrink-0 border-t border-divider-subtle xl:h-auto xl:w-[450px] xl:border-t-0 xl:border-l">
<BatchTestPanel resourceType={resourceType} resourceId={resourceId} />
</div>
</div>
)
}
export default NonPipelineEvaluation

View File

@ -0,0 +1,97 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { useEvaluationStore } from '../../store'
import HistoryTab from '../batch-test-panel/history-tab'
import EvaluationConfigActions from '../config-actions'
import JudgeModelSelector from '../judge-model-selector'
import PipelineBatchActions from '../pipeline/pipeline-batch-actions'
import PipelineMetricsSection from '../pipeline/pipeline-metrics-section'
import PipelineResultsPanel from '../pipeline/pipeline-results-panel'
import SectionHeader, { InlineSectionHeader } from '../section-header'
const PipelineEvaluation = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const docLink = useDocLink()
const ensureResource = useEvaluationStore(state => state.ensureResource)
useEffect(() => {
ensureResource(resourceType, resourceId)
}, [ensureResource, resourceId, resourceType])
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default xl:flex-row xl:overflow-hidden">
<div className="flex shrink-0 flex-col border-b border-divider-subtle bg-background-default xl:min-h-0 xl:w-[450px] xl:border-r xl:border-b-0">
<div className="px-6 pt-4 pb-2">
<SectionHeader
title={t('title')}
description={(
<>
{t('description')}
{' '}
<a
className="text-text-accent"
href={docLink()}
target="_blank"
rel="noopener noreferrer"
>
{tCommon('operation.learnMore')}
</a>
</>
)}
action={<EvaluationConfigActions resourceType={resourceType} resourceId={resourceId} />}
/>
</div>
<div className="px-6 pt-3 pb-4">
<div className="space-y-3">
<section>
<InlineSectionHeader title={t('judgeModel.title')} tooltip={t('judgeModel.description')} />
<div className="mt-1">
<JudgeModelSelector
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</section>
<PipelineMetricsSection
resourceType={resourceType}
resourceId={resourceId}
/>
<PipelineBatchActions
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</div>
<div className="border-t border-divider-subtle" />
<div className="px-6 py-4 xl:min-h-0 xl:flex-1">
<HistoryTab
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</div>
<div className="shrink-0 bg-background-default xl:min-h-0 xl:flex-1">
<PipelineResultsPanel
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</div>
)
}
export default PipelineEvaluation

View File

@ -0,0 +1,249 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen } from '@testing-library/react'
import MetricSection from '..'
import { useEvaluationStore } from '../../../store'
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseDefaultEvaluationMetrics = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
useDefaultEvaluationMetrics: (...args: unknown[]) => mockUseDefaultEvaluationMetrics(...args),
}))
const resourceType = 'apps' as const
const resourceId = 'metric-section-resource'
const renderMetricSection = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<MetricSection resourceType={resourceType} resourceId={resourceId} />
</QueryClientProvider>,
)
}
describe('MetricSection', () => {
beforeEach(() => {
vi.clearAllMocks()
useEvaluationStore.setState({ resources: {} })
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
],
},
isLoading: false,
})
mockUseAvailableEvaluationWorkflows.mockReturnValue({
data: {
pages: [{ items: [], page: 1, limit: 20, has_more: false }],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
})
})
// Verify the empty state block extracted from MetricSection.
describe('Empty State', () => {
it('should render the metric empty state when no metrics are selected', () => {
renderMetricSection()
expect(screen.getByText('evaluation.metrics.description')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.metrics.add' })).toBeInTheDocument()
})
})
// Verify the extracted builtin metric card presentation and removal flow.
describe('Builtin Metric Card', () => {
it('should render node badges for a builtin metric and remove it when delete is clicked', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
// Act
renderMetricSection()
// Assert
expect(screen.getByText('Answer Correctness')).toBeInTheDocument()
expect(screen.getByText('Answer Node')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.remove' }))
expect(screen.queryByText('Answer Correctness')).not.toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.description')).toBeInTheDocument()
})
it('should render the all-nodes label when a builtin metric has no node selection', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [])
})
// Act
renderMetricSection()
// Assert
expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument()
})
it('should collapse and expand the node section when the metric header is clicked', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
// Act
renderMetricSection()
const toggleButton = screen.getByRole('button', { name: 'evaluation.metrics.collapseNodes' })
fireEvent.click(toggleButton)
// Assert
expect(screen.queryByText('Answer Node')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.metrics.expandNodes' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.expandNodes' }))
expect(screen.getByText('Answer Node')).toBeInTheDocument()
})
it('should remove the builtin metric when removing its last selected node', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
// Act
renderMetricSection()
fireEvent.click(screen.getByRole('button', { name: 'Answer Node' }))
// Assert
expect(screen.queryByText('Answer Correctness')).not.toBeInTheDocument()
expect(useEvaluationStore.getState().resources[`${resourceType}:${resourceId}`]!.metrics).toHaveLength(0)
})
it('should show only unselected nodes in the add-node dropdown and append the selected node', () => {
// Arrange
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
},
],
},
isLoading: false,
})
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
])
})
// Act
renderMetricSection()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.addNode' }))
// Assert
expect(screen.queryByRole('menuitem', { name: 'LLM 1' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: 'LLM 2' }))
expect(screen.getByText('LLM 2')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'evaluation.metrics.addNode' })).not.toBeInTheDocument()
})
it('should hide the add-node button when the builtin metric already targets all nodes', () => {
// Arrange
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
},
],
},
isLoading: false,
})
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [])
})
// Act
renderMetricSection()
// Assert
expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'evaluation.metrics.addNode' })).not.toBeInTheDocument()
})
})
// Verify the extracted custom metric editor card renders inside the metric card.
describe('Custom Metric Card', () => {
it('should render the custom metric editor card when a custom metric is added', () => {
act(() => {
useEvaluationStore.getState().addCustomMetric(resourceType, resourceId)
})
renderMetricSection()
expect(screen.getByText('Custom Evaluator')).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.warningBadge')).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.workflowPlaceholder')).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.mappingTitle')).toBeInTheDocument()
})
it('should disable adding another custom metric when one already exists', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addCustomMetric(resourceType, resourceId)
})
// Act
renderMetricSection()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
// Assert
expect(screen.getByRole('button', { name: /evaluation.metrics.custom.footerTitle/i })).toBeDisabled()
expect(screen.getByText('evaluation.metrics.custom.limitDescription')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,165 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useEvaluationStore } from '../../store'
import { dedupeNodeInfoList, getEvaluationNodeBlockType, getMetricVisual, getToneClasses } from '../metric-selector/utils'
type BuiltinMetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
availableNodeInfoList?: NodeInfo[]
}
const BuiltinMetricCard = ({
resourceType,
resourceId,
metric,
availableNodeInfoList = [],
}: BuiltinMetricCardProps) => {
const { t } = useTranslation('evaluation')
const updateBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
const removeMetric = useEvaluationStore(state => state.removeMetric)
const [isExpanded, setIsExpanded] = useState(true)
const metricVisual = getMetricVisual(metric.optionId)
const metricToneClasses = getToneClasses(metricVisual.tone)
const selectedNodeInfoList = metric.nodeInfoList ?? []
const selectedNodeIdSet = new Set(selectedNodeInfoList.map(nodeInfo => nodeInfo.node_id))
const selectableNodeInfoList = selectedNodeInfoList.length > 0
? availableNodeInfoList.filter(nodeInfo => !selectedNodeIdSet.has(nodeInfo.node_id))
: []
const shouldShowAddNode = selectableNodeInfoList.length > 0
const handleRemoveNode = (nodeId: string) => {
const nextSelectedNodeInfoList = selectedNodeInfoList.filter(item => item.node_id !== nodeId)
if (nextSelectedNodeInfoList.length === 0) {
removeMetric(resourceType, resourceId, metric.id)
return
}
updateBuiltinMetric(resourceType, resourceId, metric.optionId, nextSelectedNodeInfoList)
}
return (
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
<div className={cn('flex items-center justify-between gap-3 px-3 pt-3', isExpanded ? 'pb-1' : 'pb-3')}>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left"
aria-expanded={isExpanded}
aria-label={isExpanded ? t('metrics.collapseNodes') : t('metrics.expandNodes')}
onClick={() => setIsExpanded(current => !current)}
>
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px]', metricToneClasses.soft)}>
<span aria-hidden="true" className={cn(metricVisual.icon, 'h-3.5 w-3.5')} />
</div>
<div className="flex min-w-0 items-center gap-0.5">
<div className="truncate system-md-medium text-text-secondary uppercase">{metric.label}</div>
<span
aria-hidden="true"
className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-quaternary transition-transform', !isExpanded && '-rotate-90')}
/>
</div>
</button>
<Button
size="small"
variant="ghost"
aria-label={t('metrics.remove')}
className="h-6 w-6 shrink-0 rounded-md p-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100 hover:text-text-secondary focus-visible:opacity-100"
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
{isExpanded && (
<div className="flex flex-wrap gap-1 px-3 pt-1 pb-3">
{selectedNodeInfoList.length
? selectedNodeInfoList.map((nodeInfo) => {
return (
<div
key={nodeInfo.node_id}
className="inline-flex min-w-[18px] items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1.5 shadow-xs"
>
<BlockIcon
type={getEvaluationNodeBlockType(nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="px-1 system-xs-regular text-text-primary">{nodeInfo.title}</span>
<button
type="button"
className="flex h-4 w-4 items-center justify-center rounded-sm text-text-quaternary transition-colors hover:text-text-secondary"
aria-label={nodeInfo.title}
onClick={() => handleRemoveNode(nodeInfo.node_id)}
>
<span aria-hidden="true" className="i-custom-vender-solid-general-x-circle h-3.5 w-3.5" />
</button>
</div>
)
})
: (
<span className="px-1 system-xs-regular text-text-tertiary">{t('metrics.nodesAll')}</span>
)}
{shouldShowAddNode && (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label={t('metrics.addNode')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-background-default-hover text-text-tertiary transition-colors hover:bg-state-base-hover"
/>
)}
>
<span aria-hidden="true" className="i-ri-add-line h-4 w-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
popupClassName="w-[252px] rounded-md border-[0.5px] border-components-panel-border py-1 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
{selectableNodeInfoList.map((nodeInfo) => {
return (
<DropdownMenuItem
key={nodeInfo.node_id}
className="h-auto gap-0 rounded-md px-3 py-1.5"
onClick={() => updateBuiltinMetric(
resourceType,
resourceId,
metric.optionId,
dedupeNodeInfoList([...selectedNodeInfoList, nodeInfo]),
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2.5 pr-1">
<BlockIcon
type={getEvaluationNodeBlockType(nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="truncate system-sm-medium text-text-secondary">{nodeInfo.title}</span>
</div>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</div>
)
}
export default BuiltinMetricCard

View File

@ -0,0 +1,63 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
import CustomMetricEditorCard from '../custom-metric-editor'
import { getToneClasses } from '../metric-selector/utils'
type CustomMetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
}
const CustomMetricCard = ({
resourceType,
resourceId,
metric,
}: CustomMetricCardProps) => {
const { t } = useTranslation('evaluation')
const removeMetric = useEvaluationStore(state => state.removeMetric)
const isCustomMetricInvalid = !isCustomMetricConfigured(metric)
const metricToneClasses = getToneClasses('indigo')
return (
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
<div className="flex items-center justify-between gap-3 px-3 pt-3 pb-1">
<div className="flex min-w-0 flex-1 items-center gap-2 px-1">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px]', metricToneClasses.soft)}>
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5" />
</div>
<div className="truncate system-md-medium text-text-secondary">{metric.label}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{isCustomMetricInvalid && (
<Badge className="badge-warning">
{t('metrics.custom.warningBadge')}
</Badge>
)}
<Button
size="small"
variant="ghost"
aria-label={t('metrics.remove')}
className="h-6 w-6 shrink-0 rounded-md p-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100 hover:text-text-secondary focus-visible:opacity-100"
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
</div>
<CustomMetricEditorCard
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
/>
</div>
)
}
export default CustomMetricCard

View File

@ -0,0 +1,51 @@
'use client'
import type { NonPipelineEvaluationResourceProps } from '../../types'
import { useTranslation } from 'react-i18next'
import { useDefaultEvaluationMetrics } from '@/service/use-evaluation'
import { useEvaluationResource } from '../../store'
import MetricSelector from '../metric-selector'
import { getDefaultMetricNodeInfoMap } from '../metric-selector/utils'
import { InlineSectionHeader } from '../section-header'
import MetricCard from './metric-card'
import MetricSectionEmptyState from './metric-section-empty-state'
const MetricSection = ({
resourceType,
resourceId,
}: NonPipelineEvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const hasMetrics = resource.metrics.length > 0
const hasBuiltinMetrics = resource.metrics.some(metric => metric.kind === 'builtin')
const { data: defaultMetricsData } = useDefaultEvaluationMetrics(resourceType, resourceId, hasBuiltinMetrics)
const nodeInfoMap = getDefaultMetricNodeInfoMap(defaultMetricsData?.default_metrics ?? [])
return (
<section className="max-w-[700px] py-4">
<InlineSectionHeader
title={t('metrics.title')}
tooltip={t('metrics.description')}
/>
<div className="mt-1 space-y-1">
{!hasMetrics && <MetricSectionEmptyState description={t('metrics.description')} />}
{resource.metrics.map(metric => (
<MetricCard
key={metric.id}
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
availableNodeInfoList={metric.kind === 'builtin' ? (nodeInfoMap[metric.optionId] ?? []) : undefined}
/>
))}
<MetricSelector
resourceType={resourceType}
resourceId={resourceId}
triggerClassName="rounded-md px-3 py-2"
/>
</div>
</section>
)
}
export default MetricSection

View File

@ -0,0 +1,39 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import BuiltinMetricCard from './builtin-metric-card'
import CustomMetricCard from './custom-metric-card'
type MetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
availableNodeInfoList?: NodeInfo[]
}
const MetricCard = ({
resourceType,
resourceId,
metric,
availableNodeInfoList,
}: MetricCardProps) => {
if (metric.kind === 'custom-workflow') {
return (
<CustomMetricCard
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
/>
)
}
return (
<BuiltinMetricCard
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
availableNodeInfoList={availableNodeInfoList}
/>
)
}
export default MetricCard

View File

@ -0,0 +1,18 @@
type MetricSectionEmptyStateProps = {
description: string
}
const MetricSectionEmptyState = ({ description }: MetricSectionEmptyStateProps) => {
return (
<div className="flex items-center gap-5 rounded-xl bg-background-section p-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-md">
<span aria-hidden="true" className="i-ri-bar-chart-horizontal-line h-6 w-6 text-text-primary" />
</div>
<div className="min-w-0 flex-1 text-text-tertiary system-xs-regular">
{description}
</div>
</div>
)
}
export default MetricSectionEmptyState

View File

@ -0,0 +1,137 @@
'use client'
import type { ChangeEvent } from 'react'
import type { MetricSelectorProps } from './types'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { useEvaluationResource, useEvaluationStore } from '../../store'
import SelectorEmptyState from './selector-empty-state'
import SelectorFooter from './selector-footer'
import SelectorMetricSection from './selector-metric-section'
import { useMetricSelectorData } from './use-metric-selector-data'
const MetricSelector = ({
resourceType,
resourceId,
triggerClassName,
}: MetricSelectorProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const addCustomMetric = useEvaluationStore(state => state.addCustomMetric)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [collapsedMetricMap, setCollapsedMetricMap] = useState<Record<string, boolean>>({})
const [expandedMetricNodesMap, setExpandedMetricNodesMap] = useState<Record<string, boolean>>({})
const hasCustomMetric = resource.metrics.some(metric => metric.kind === 'custom-workflow')
const {
builtinMetricMap,
filteredSections,
isRemoteLoading,
toggleNodeSelection,
} = useMetricSelectorData({
open,
query,
resourceType,
resourceId,
})
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (nextOpen) {
setQuery('')
setCollapsedMetricMap({})
setExpandedMetricNodesMap({})
}
}
const handleQueryChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
<Button variant="ghost-accent" className={triggerClassName}>
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('metrics.add')}
</Button>
)}
/>
<PopoverContent popupClassName="w-[360px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]">
<div className="flex min-h-[560px] flex-col bg-components-panel-bg">
<div className="border-b border-divider-subtle bg-background-section-burn px-2 py-2">
<Input
value={query}
showLeftIcon
placeholder={t('metrics.searchNodeOrMetrics')}
onChange={handleQueryChange}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{isRemoteLoading && (
<div className="space-y-3 px-3 py-4" data-testid="evaluation-metric-loading">
{['metric-skeleton-1', 'metric-skeleton-2', 'metric-skeleton-3'].map(key => (
<div key={key} className="h-20 animate-pulse rounded-xl bg-background-default-subtle" />
))}
</div>
)}
{!isRemoteLoading && filteredSections.length === 0 && (
<SelectorEmptyState message={t('metrics.noResults')} />
)}
{!isRemoteLoading && filteredSections.map((section, index) => {
const { metric } = section
const isExpanded = collapsedMetricMap[metric.id] !== true
const isShowingAllNodes = expandedMetricNodesMap[metric.id] === true
return (
<SelectorMetricSection
key={metric.id}
section={section}
index={index}
addedMetric={builtinMetricMap.get(metric.id)}
isExpanded={isExpanded}
isShowingAllNodes={isShowingAllNodes}
onToggleExpanded={() => setCollapsedMetricMap(current => ({
...current,
[metric.id]: isExpanded,
}))}
onToggleNodeSelection={toggleNodeSelection}
onToggleShowAllNodes={() => setExpandedMetricNodesMap(current => ({
...current,
[metric.id]: !isShowingAllNodes,
}))}
t={t}
/>
)
})}
</div>
<SelectorFooter
title={t('metrics.custom.footerTitle')}
description={hasCustomMetric ? t('metrics.custom.limitDescription') : t('metrics.custom.footerDescription')}
disabled={hasCustomMetric}
onClick={() => {
addCustomMetric(resourceType, resourceId)
setOpen(false)
}}
/>
</div>
</PopoverContent>
</Popover>
)
}
export default MetricSelector

View File

@ -0,0 +1,26 @@
type SelectorEmptyStateProps = {
message: string
}
const EmptySearchStateIcon = () => {
return (
<div className="relative h-8 w-8 text-text-quaternary">
<span aria-hidden="true" className="absolute right-0 bottom-0 i-ri-search-line h-6 w-6" />
<span aria-hidden="true" className="absolute top-[9px] left-0 h-[2px] w-[7px] rounded-full bg-current opacity-80" />
<span aria-hidden="true" className="absolute top-[16px] left-0 h-[2px] w-[4px] rounded-full bg-current opacity-80" />
</div>
)
}
const SelectorEmptyState = ({
message,
}: SelectorEmptyStateProps) => {
return (
<div className="flex h-full min-h-[524px] flex-col items-center justify-center gap-2 px-4 pb-20 text-center">
<EmptySearchStateIcon />
<div className="system-sm-regular text-text-secondary">{message}</div>
</div>
)
}
export default SelectorEmptyState

View File

@ -0,0 +1,33 @@
type SelectorFooterProps = {
title: string
description: string
disabled?: boolean
onClick: () => void
}
const SelectorFooter = ({
title,
description,
disabled = false,
onClick,
}: SelectorFooterProps) => {
return (
<button
type="button"
disabled={disabled}
className="relative flex items-center gap-3 overflow-hidden border-t border-divider-subtle bg-background-default-subtle px-4 py-5 text-left enabled:hover:bg-state-base-hover-alt disabled:cursor-not-allowed disabled:opacity-60"
onClick={onClick}
>
<div className="absolute -top-6 -left-6 h-28 w-28 rounded-full bg-util-colors-indigo-indigo-100 opacity-50 blur-2xl" />
<div className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-[0px_3px_10px_-2px_rgba(9,9,11,0.08),0px_2px_4px_-2px_rgba(9,9,11,0.06)]">
<span aria-hidden="true" className="i-ri-add-line h-[18px] w-[18px] text-text-tertiary" />
</div>
<div className="relative min-w-0">
<div className="system-sm-semibold text-text-secondary">{title}</div>
<div className="mt-0.5 system-xs-regular text-text-tertiary">{description}</div>
</div>
</button>
)
}
export default SelectorFooter

View File

@ -0,0 +1,152 @@
import type { TFunction } from 'i18next'
import type { EvaluationMetric } from '../../types'
import type { MetricSelectorSection } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import { getEvaluationNodeBlockType, getMetricVisual, getToneClasses } from './utils'
type SelectorMetricSectionProps = {
section: MetricSelectorSection
index: number
addedMetric?: EvaluationMetric
isExpanded: boolean
isShowingAllNodes: boolean
onToggleExpanded: () => void
onToggleShowAllNodes: () => void
onToggleNodeSelection: (metric: MetricSelectorSection['metric'], nodeInfo: MetricSelectorSection['visibleNodes'][number]) => void
t: TFunction<'evaluation'>
}
const SelectorMetricSection = ({
section,
index,
addedMetric,
isExpanded,
isShowingAllNodes,
onToggleExpanded,
onToggleShowAllNodes,
onToggleNodeSelection,
t,
}: SelectorMetricSectionProps) => {
const { metric, visibleNodes, hasNoNodeInfo } = section
const selectedNodeIds = new Set(
addedMetric?.nodeInfoList?.length
? addedMetric.nodeInfoList.map(nodeInfo => nodeInfo.node_id)
: [],
)
const metricVisual = getMetricVisual(metric.id)
const toneClasses = getToneClasses(metricVisual.tone)
const hasMoreNodes = visibleNodes.length > 3
const shownNodes = hasMoreNodes && !isShowingAllNodes ? visibleNodes.slice(0, 3) : visibleNodes
return (
<div data-testid={`evaluation-metric-option-${metric.id}`}>
{index > 0 && (
<div className="px-3 pt-1">
<div className="h-px w-full bg-divider-subtle" />
</div>
)}
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<button
type="button"
className="flex min-w-0 items-center gap-2"
onClick={onToggleExpanded}
>
<div className={cn('flex h-[18px] w-[18px] items-center justify-center rounded-md', toneClasses.soft)}>
<span aria-hidden="true" className={cn(metricVisual.icon, 'h-3.5 w-3.5')} />
</div>
<div className="flex items-center gap-1">
<span className="truncate system-xs-medium-uppercase text-text-secondary">{metric.label}</span>
<span
aria-hidden="true"
className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary transition-transform', !isExpanded && '-rotate-90')}
/>
</div>
</button>
{metric.description && (
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
className="p-px text-text-quaternary transition-colors hover:text-text-tertiary"
aria-label={metric.label}
>
<span aria-hidden="true" className="i-ri-question-line h-[14px] w-[14px]" />
</button>
)}
/>
<TooltipContent className="max-w-[260px]">
{metric.description}
</TooltipContent>
</Tooltip>
)}
</div>
{isExpanded && (
<div className="px-1 py-1">
{hasNoNodeInfo && (
<div className="px-3 pt-0.5 pb-2 system-sm-regular text-text-tertiary">
{t('metrics.noNodesInWorkflow')}
</div>
)}
{shownNodes.map((nodeInfo) => {
const isAdded = addedMetric
? addedMetric.nodeInfoList?.length
? selectedNodeIds.has(nodeInfo.node_id)
: true
: false
return (
<button
key={nodeInfo.node_id}
data-testid={`evaluation-metric-node-${metric.id}-${nodeInfo.node_id}`}
type="button"
className={cn(
'flex w-full items-center gap-1 rounded-md px-3 py-1.5 text-left transition-colors hover:bg-state-base-hover-alt',
isAdded && 'opacity-50',
)}
onClick={() => onToggleNodeSelection(metric, nodeInfo)}
>
<div className="flex min-w-0 flex-1 items-center gap-2.5 pr-1">
<BlockIcon
type={getEvaluationNodeBlockType(nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="truncate text-[13px] leading-4 font-medium text-text-secondary">
{nodeInfo.title}
</span>
</div>
{isAdded && (
<span className="shrink-0 px-1 system-xs-regular text-text-quaternary">{t('metrics.added')}</span>
)}
</button>
)
})}
{hasMoreNodes && (
<button
type="button"
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left hover:bg-state-base-hover-alt"
onClick={onToggleShowAllNodes}
>
<div className="flex min-w-0 flex-1 items-center gap-1.5 pr-1">
<div className="flex items-center px-1 text-text-tertiary">
<span aria-hidden="true" className={cn(isShowingAllNodes ? 'i-ri-subtract-line' : 'i-ri-more-line', 'h-4 w-4')} />
</div>
<span className="truncate system-xs-regular text-text-tertiary">
{isShowingAllNodes ? t('metrics.showLess') : t('metrics.showMore')}
</span>
</div>
</button>
)}
</div>
)}
</div>
)
}
export default SelectorMetricSection

View File

@ -0,0 +1,16 @@
import type { EvaluationMetric, MetricOption, NonPipelineEvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
export type MetricSelectorProps = NonPipelineEvaluationResourceProps & {
triggerClassName?: string
}
export type MetricVisualTone = 'indigo' | 'green'
export type MetricSelectorSection = {
metric: MetricOption
hasNoNodeInfo: boolean
visibleNodes: NodeInfo[]
}
export type BuiltinMetricMap = Map<string, EvaluationMetric>

View File

@ -0,0 +1,135 @@
import type { MetricOption, NonPipelineEvaluationResourceType } from '../../types'
import type { BuiltinMetricMap, MetricSelectorSection } from './types'
import type { NodeInfo } from '@/types/evaluation'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useDefaultEvaluationMetrics } from '@/service/use-evaluation'
import { getTranslatedMetricDescription } from '../../default-metric-descriptions'
import { useEvaluationResource, useEvaluationStore } from '../../store'
import {
buildMetricOption,
dedupeNodeInfoList,
getDefaultMetricNodeInfoMap,
} from './utils'
type UseMetricSelectorDataOptions = {
open: boolean
query: string
resourceType: NonPipelineEvaluationResourceType
resourceId: string
}
type UseMetricSelectorDataResult = {
builtinMetricMap: BuiltinMetricMap
filteredSections: MetricSelectorSection[]
isRemoteLoading: boolean
toggleNodeSelection: (metric: MetricOption, nodeInfo: NodeInfo) => void
}
export const useMetricSelectorData = ({
open,
query,
resourceType,
resourceId,
}: UseMetricSelectorDataOptions): UseMetricSelectorDataResult => {
const { t } = useTranslation('evaluation')
const metrics = useEvaluationResource(resourceType, resourceId).metrics
const addBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
const removeMetric = useEvaluationStore(state => state.removeMetric)
const { data: defaultMetricsData, isLoading: isDefaultMetricsLoading } = useDefaultEvaluationMetrics(resourceType, resourceId, open)
const builtinMetrics = useMemo(() => {
return metrics.filter(metric => metric.kind === 'builtin')
}, [metrics])
const builtinMetricMap = useMemo(() => {
return new Map(builtinMetrics.map(metric => [metric.optionId, metric] as const))
}, [builtinMetrics])
const defaultMetrics = useMemo(() => defaultMetricsData?.default_metrics ?? [], [defaultMetricsData?.default_metrics])
const nodeInfoMap = useMemo(() => getDefaultMetricNodeInfoMap(defaultMetrics), [defaultMetrics])
const resolvedMetrics = useMemo(() => {
return defaultMetrics
.map((defaultMetric) => {
if (!defaultMetric.metric)
return null
return buildMetricOption(defaultMetric.metric, defaultMetric.value_type)
})
.filter((metric): metric is MetricOption => !!metric)
}, [defaultMetrics])
const filteredSections = useMemo(() => {
const keyword = query.trim().toLowerCase()
return resolvedMetrics.map((metric) => {
const metricDescription = getTranslatedMetricDescription(t, metric.id, metric.description)
const metricMatches = !keyword
|| metric.label.toLowerCase().includes(keyword)
|| metricDescription.toLowerCase().includes(keyword)
const metricNodes = nodeInfoMap[metric.id] ?? []
const hasNoNodeInfo = metricNodes.length === 0
if (hasNoNodeInfo) {
if (!metricMatches)
return null
return {
metric: {
...metric,
description: metricDescription,
},
hasNoNodeInfo: true,
visibleNodes: [] as NodeInfo[],
}
}
const visibleNodes = metricMatches
? metricNodes
: metricNodes.filter((nodeInfo) => {
return nodeInfo.title.toLowerCase().includes(keyword)
|| nodeInfo.type.toLowerCase().includes(keyword)
|| nodeInfo.node_id.toLowerCase().includes(keyword)
})
if (!metricMatches && visibleNodes.length === 0)
return null
return {
metric: {
...metric,
description: metricDescription,
},
hasNoNodeInfo: false,
visibleNodes,
}
}).filter((section): section is MetricSelectorSection => !!section)
}, [nodeInfoMap, query, resolvedMetrics, t])
const toggleNodeSelection = (metric: MetricOption, nodeInfo: NodeInfo) => {
const metricId = metric.id
const addedMetric = builtinMetricMap.get(metricId)
const currentSelectedNodes = addedMetric?.nodeInfoList ?? []
const nextSelectedNodes = addedMetric && currentSelectedNodes.length === 0
? [nodeInfo]
: currentSelectedNodes.some(item => item.node_id === nodeInfo.node_id)
? currentSelectedNodes.filter(item => item.node_id !== nodeInfo.node_id)
: dedupeNodeInfoList([...currentSelectedNodes, nodeInfo])
if (addedMetric && nextSelectedNodes.length === 0) {
removeMetric(resourceType, resourceId, addedMetric.id)
return
}
addBuiltinMetric(resourceType, resourceId, metricId, nextSelectedNodes, metric)
}
return {
builtinMetricMap,
filteredSections,
isRemoteLoading: isDefaultMetricsLoading,
toggleNodeSelection,
}
}

View File

@ -0,0 +1,95 @@
import type { ConditionMetricValueType, MetricOption } from '../../types'
import type { MetricVisualTone } from './types'
import type { EvaluationDefaultMetric, NodeInfo } from '@/types/evaluation'
import { BlockEnum } from '@/app/components/workflow/types'
import { getDefaultMetricDescription } from '../../default-metric-descriptions'
const defaultConditionMetricValueType: ConditionMetricValueType = 'number'
export const normalizeMetricValueType = (valueType: string | undefined): ConditionMetricValueType => {
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean')
return valueType
return defaultConditionMetricValueType
}
const humanizeMetricId = (metricId: string) => {
return metricId
.split(/[-_]/g)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
export const buildMetricOption = (metricId: string, valueType?: string): MetricOption => ({
id: metricId,
label: humanizeMetricId(metricId),
description: getDefaultMetricDescription(metricId),
valueType: normalizeMetricValueType(valueType),
})
export const dedupeNodeInfoList = (nodeInfoList: NodeInfo[]) => {
return Array.from(new Map(nodeInfoList.map(nodeInfo => [nodeInfo.node_id, nodeInfo])).values())
}
export const getDefaultMetricNodeInfoMap = (defaultMetrics: EvaluationDefaultMetric[]) => {
const nodeInfoMap: Record<string, NodeInfo[]> = {}
defaultMetrics.forEach((defaultMetric) => {
if (!defaultMetric.metric)
return
nodeInfoMap[defaultMetric.metric] = dedupeNodeInfoList([
...(nodeInfoMap[defaultMetric.metric] ?? []),
...(defaultMetric.node_info_list ?? []),
])
})
return nodeInfoMap
}
export const getMetricVisual = (metricId: string): { icon: string, tone: MetricVisualTone } => {
if (['context_precision', 'context_recall'].includes(metricId)) {
return {
icon: metricId === 'context_recall' ? 'i-ri-arrow-go-back-line' : 'i-ri-focus-2-line',
tone: 'green',
}
}
if (metricId === 'faithfulness')
return { icon: 'i-ri-anchor-line', tone: 'indigo' }
if (metricId === 'tool_correctness')
return { icon: 'i-ri-tools-line', tone: 'indigo' }
if (metricId === 'task_completion')
return { icon: 'i-ri-task-line', tone: 'indigo' }
if (metricId === 'argument_correctness')
return { icon: 'i-ri-scales-3-line', tone: 'indigo' }
return { icon: 'i-ri-checkbox-circle-line', tone: 'indigo' }
}
const workflowBlockTypeSet = new Set<string>(Object.values(BlockEnum))
export const getEvaluationNodeBlockType = (nodeInfo: Pick<NodeInfo, 'type'>): BlockEnum => {
if (workflowBlockTypeSet.has(nodeInfo.type))
return nodeInfo.type as BlockEnum
return BlockEnum.LLM
}
export const getToneClasses = (tone: MetricVisualTone) => {
if (tone === 'green') {
return {
soft: 'bg-util-colors-green-green-50 text-util-colors-green-green-500',
solid: 'bg-util-colors-green-green-500 text-white',
}
}
return {
soft: 'bg-util-colors-indigo-indigo-50 text-util-colors-indigo-indigo-500',
solid: 'bg-util-colors-indigo-indigo-500 text-white',
}
}

View File

@ -0,0 +1,60 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { isEvaluationRunnable, useEvaluationResource } from '../../store'
import { EVALUATION_TEMPLATE_FILE_NAMES } from '../../store-utils'
import UploadRunPopover from '../batch-test-panel/input-fields/upload-run-popover'
import { useInputFieldsActions } from '../batch-test-panel/input-fields/use-input-fields-actions'
const PipelineBatchActions = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const isConfigReady = !!resource.judgeModelId && resource.metrics.some(metric => metric.kind === 'builtin')
const isRunnable = isEvaluationRunnable(resource)
const actions = useInputFieldsActions({
resourceType,
resourceId,
isPanelReady: isConfigReady,
isRunnable,
templateFileName: EVALUATION_TEMPLATE_FILE_NAMES[resourceType],
})
return (
<div className="flex gap-2 pt-2">
<Button
className="flex-1 justify-center"
variant="secondary"
disabled={!actions.canDownloadTemplate}
onClick={actions.handleDownloadTemplate}
>
<span aria-hidden="true" className="mr-1 i-ri-file-excel-2-line h-4 w-4" />
{t('batch.downloadTemplate')}
</Button>
<div className="flex-1">
<UploadRunPopover
open={actions.isUploadPopoverOpen}
onOpenChange={actions.setIsUploadPopoverOpen}
triggerDisabled={actions.uploadButtonDisabled}
triggerLabel={t('pipeline.uploadAndRun')}
inputFields={actions.templateColumns}
currentFileName={actions.currentFileName}
currentFileExtension={actions.currentFileExtension}
currentFileSize={actions.currentFileSize}
isFileUploading={actions.isFileUploading}
isRunDisabled={actions.isRunDisabled}
isRunning={actions.isRunning}
onUploadFile={actions.handleUploadFile}
onClearUploadedFile={actions.handleClearUploadedFile}
onRun={actions.handleRun}
/>
</div>
</div>
)
}
export default PipelineBatchActions

View File

@ -0,0 +1,92 @@
'use client'
import type { MetricOption } from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import { getTranslatedMetricDescription } from '../../default-metric-descriptions'
import { DEFAULT_PIPELINE_METRIC_THRESHOLD } from '../../store-utils'
type PipelineMetricItemProps = {
metric: MetricOption
selected: boolean
onToggle: () => void
disabledCondition: boolean
threshold?: number
onThresholdChange: (value: number) => void
}
const PipelineMetricItem = ({
metric,
selected,
onToggle,
disabledCondition,
threshold = DEFAULT_PIPELINE_METRIC_THRESHOLD,
onThresholdChange,
}: PipelineMetricItemProps) => {
const { t } = useTranslation('evaluation')
const metricDescription = getTranslatedMetricDescription(t, metric.id, metric.description)
return (
<div className="flex h-8 items-center justify-between gap-3 px-1 py-1">
<button
type="button"
className="flex min-w-0 items-center gap-2 text-left"
onClick={onToggle}
>
<Checkbox checked={selected} />
<span className="truncate system-sm-medium text-text-secondary">{metric.label}</span>
<Tooltip>
<TooltipTrigger
render={(
<span className="flex h-4 w-4 items-center justify-center text-text-quaternary">
<span aria-hidden="true" className="i-ri-question-line h-3.5 w-3.5" />
</span>
)}
/>
<TooltipContent>
{metricDescription}
</TooltipContent>
</Tooltip>
</button>
{selected
? (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-accent">{t('pipeline.passIf')}</span>
<div className="w-[64px]">
<Input
value={String(threshold)}
type="number"
min={0}
max={1}
step={0.01}
onChange={(event) => {
const parsedValue = Number(event.target.value)
if (!Number.isNaN(parsedValue))
onThresholdChange(parsedValue)
}}
/>
</div>
</div>
)
: (
<button
type="button"
disabled={disabledCondition}
className={cn(
'flex items-center gap-0.5 system-xs-medium text-text-tertiary',
disabledCondition && 'cursor-not-allowed text-components-button-secondary-accent-text-disabled',
)}
>
<span aria-hidden="true" className="i-ri-add-line h-3.5 w-3.5" />
{t('conditions.addCondition')}
</button>
)}
</div>
)
}
export default PipelineMetricItem

View File

@ -0,0 +1,120 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import type { Node } from '@/app/components/workflow/types'
import type { NodeInfo } from '@/types/evaluation'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { BlockEnum } from '@/app/components/workflow/types'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetEvaluationMetrics } from '@/service/use-evaluation'
import { usePublishedPipelineInfo } from '@/service/use-pipeline'
import { useEvaluationResource, useEvaluationStore } from '../../store'
import { buildMetricOption } from '../metric-selector/utils'
import { InlineSectionHeader } from '../section-header'
import PipelineMetricItem from './pipeline-metric-item'
const getKnowledgeIndexNodeInfo = (nodes: Node[] | undefined): NodeInfo[] => {
const knowledgeIndexNode = nodes?.find(node => node.data.type === BlockEnum.KnowledgeBase)
if (!knowledgeIndexNode?.id)
return []
return [{
node_id: knowledgeIndexNode.id,
title: typeof knowledgeIndexNode.data?.title === 'string' && knowledgeIndexNode.data.title
? knowledgeIndexNode.data.title
: knowledgeIndexNode.id,
type: 'knowledge-index',
}]
}
const isSameNodeInfoList = (left: NodeInfo[] | undefined, right: NodeInfo[]) => {
if ((left?.length ?? 0) !== right.length)
return false
return (left ?? []).every((nodeInfo, index) => {
const target = right[index]
return nodeInfo.node_id === target?.node_id
&& nodeInfo.title === target?.title
&& nodeInfo.type === target?.type
})
}
const PipelineMetricsSection = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const pipelineId = useDatasetDetailContextWithSelector(state => state.dataset?.pipeline_id)
const addBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
const removeMetric = useEvaluationStore(state => state.removeMetric)
const updateMetricThreshold = useEvaluationStore(state => state.updateMetricThreshold)
const { data: datasetMetricsData } = useDatasetEvaluationMetrics(resourceId)
const { data: publishedPipeline } = usePublishedPipelineInfo(pipelineId || '')
const resource = useEvaluationResource(resourceType, resourceId)
const knowledgeIndexNodeInfoList = useMemo(
() => getKnowledgeIndexNodeInfo(publishedPipeline?.graph.nodes),
[publishedPipeline?.graph.nodes],
)
const builtinMetricMap = useMemo(() => new Map(
resource.metrics
.filter(metric => metric.kind === 'builtin')
.map(metric => [metric.optionId, metric]),
), [resource.metrics])
const availableBuiltinMetrics = useMemo(() => {
const metricIds = new Set([
...(datasetMetricsData?.metrics ?? []),
...builtinMetricMap.keys(),
])
return Array.from(metricIds).map(metricId => buildMetricOption(metricId))
}, [datasetMetricsData?.metrics, builtinMetricMap])
useEffect(() => {
if (!knowledgeIndexNodeInfoList.length)
return
resource.metrics.forEach((metric) => {
if (metric.kind !== 'builtin' || isSameNodeInfoList(metric.nodeInfoList, knowledgeIndexNodeInfoList))
return
addBuiltinMetric(resourceType, resourceId, metric.optionId, knowledgeIndexNodeInfoList, metric)
})
}, [addBuiltinMetric, knowledgeIndexNodeInfoList, resource.metrics, resourceId, resourceType])
const handleToggleMetric = (metricId: string) => {
const selectedMetric = builtinMetricMap.get(metricId)
if (selectedMetric) {
removeMetric(resourceType, resourceId, selectedMetric.id)
return
}
const metricOption = availableBuiltinMetrics.find(metric => metric.id === metricId)
addBuiltinMetric(resourceType, resourceId, metricId, knowledgeIndexNodeInfoList, metricOption)
}
return (
<section>
<InlineSectionHeader title={t('metrics.title')} tooltip={t('metrics.description')} />
<div className="mt-1 space-y-0.5">
{availableBuiltinMetrics.map((metric) => {
const selectedMetric = builtinMetricMap.get(metric.id)
return (
<PipelineMetricItem
key={metric.id}
metric={metric}
selected={!!selectedMetric}
threshold={selectedMetric?.threshold}
disabledCondition
onToggle={() => handleToggleMetric(metric.id)}
onThresholdChange={value => updateMetricThreshold(resourceType, resourceId, selectedMetric?.id ?? '', value)}
/>
)
})}
</div>
</section>
)
}
export default PipelineMetricsSection

View File

@ -0,0 +1,134 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { consoleClient, consoleQuery } from '@/service/client'
import { downloadUrl } from '@/utils/download'
import { useEvaluationResource } from '../../store'
import { decodeModelSelection } from '../../utils'
import PipelineResultsTable from './pipeline-results-table'
import { getMetricColumns, getRunDate } from './pipeline-results-utils'
const PAGE_SIZE = 100
const PipelineResultsPanel = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const selectedModel = decodeModelSelection(resource.judgeModelId)
const selectedRunId = resource.selectedRunId
const runDetailQuery = useQuery(consoleQuery.evaluation.runDetail.queryOptions({
input: selectedRunId
? {
params: {
targetType: resourceType,
targetId: resourceId,
runId: selectedRunId,
},
query: {
page: 1,
page_size: PAGE_SIZE,
},
}
: skipToken,
refetchOnWindowFocus: false,
}))
const resultFileDownloadMutation = useMutation({
mutationFn: async (fileId: string) => {
const fileInfo = await consoleClient.evaluation.file({
params: {
targetType: resourceType,
targetId: resourceId,
fileId,
},
})
downloadUrl({ url: fileInfo.download_url, fileName: fileInfo.name })
},
})
const runDetail = runDetailQuery.data
const items = runDetail?.items.data ?? []
const metricColumns = getMetricColumns(resource, items)
const thresholdColumns = metricColumns.filter(column => column.threshold !== undefined)
const isEmpty = !selectedRunId || (!runDetailQuery.isLoading && items.length === 0)
if (isEmpty) {
return (
<div className="flex min-h-[360px] w-full items-center justify-center xl:h-full xl:min-h-0">
<div className="flex flex-col items-center gap-4 px-4 text-center">
<span aria-hidden="true" className="i-ri-file-list-3-line h-12 w-12 text-text-quaternary" />
<div className="system-md-medium text-text-quaternary">{t('results.empty')}</div>
</div>
</div>
)
}
return (
<div className="flex min-h-[360px] flex-col border-l border-divider-subtle bg-background-default xl:h-full xl:min-h-0">
<div className="shrink-0 px-6 pt-4 pb-2">
<h2 className="system-xl-semibold text-text-primary">{t('results.title')}</h2>
</div>
{runDetailQuery.isError && (
<div className="px-6 py-4 system-sm-regular text-text-destructive">{t('results.loadFailed')}</div>
)}
{!runDetailQuery.isError && (
<div className="flex flex-col px-6 py-1 xl:min-h-0 xl:flex-1">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 py-1">
<div className="flex min-w-0 flex-wrap items-center gap-2 system-xs-regular text-text-secondary">
<span>{getRunDate(runDetail?.run.started_at ?? runDetail?.run.created_at ?? null)}</span>
<span aria-hidden="true">·</span>
<span>{t('results.queryCount', { count: runDetail?.run.total_items ?? runDetail?.items.total ?? items.length })}</span>
{selectedModel && (
<>
<span aria-hidden="true">·</span>
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-lg bg-background-section-burn px-2 py-1">
<span aria-hidden="true" className="i-ri-robot-2-line h-4 w-4 shrink-0 text-text-accent" />
<span className="truncate">{selectedModel.model}</span>
</span>
</>
)}
{thresholdColumns.length > 0 && (
<>
<span aria-hidden="true">·</span>
<span className="flex min-w-0 flex-wrap items-center gap-1">
{thresholdColumns.map(column => (
<span
key={column.id}
className="rounded-lg border-[0.5px] border-divider-subtle bg-background-section px-2 py-1 text-text-tertiary"
>
{t('results.metricThreshold', { metric: column.label, threshold: column.threshold })}
</span>
))}
</span>
</>
)}
</div>
<button
type="button"
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 system-xs-medium text-components-button-secondary-text shadow-xs disabled:cursor-not-allowed disabled:opacity-50"
disabled={!runDetail?.run.result_file_id || resultFileDownloadMutation.isPending}
onClick={() => {
if (runDetail?.run.result_file_id)
resultFileDownloadMutation.mutate(runDetail.run.result_file_id)
}}
>
<span aria-hidden="true" className="i-ri-download-2-line h-3.5 w-3.5" />
{t('results.export')}
</button>
</div>
<PipelineResultsTable
items={items}
metricColumns={metricColumns}
isLoading={runDetailQuery.isLoading}
/>
</div>
)}
</div>
)
}
export default PipelineResultsPanel

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