dify
10
dify/web/app/components/datasets/documents/assets/atSign.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2427_26665)">
|
||||
<path d="M10.6668 5.33333V8.66666C10.6668 9.19709 10.8776 9.7058 11.2526 10.0809C11.6277 10.4559 12.1364 10.6667 12.6668 10.6667C13.1973 10.6667 13.706 10.4559 14.0811 10.0809C14.4561 9.7058 14.6668 9.19709 14.6668 8.66666V7.99999C14.6667 6.49535 14.1577 5.03498 13.2224 3.85635C12.287 2.67772 10.9805 1.85014 9.51526 1.50819C8.04999 1.16624 6.51213 1.33002 5.15173 1.9729C3.79134 2.61579 2.68843 3.69996 2.02234 5.04914C1.35625 6.39832 1.16615 7.93315 1.48295 9.40407C1.79975 10.875 2.60482 12.1955 3.76726 13.1508C4.92969 14.1062 6.38112 14.6402 7.88555 14.6661C9.38997 14.692 10.8589 14.2082 12.0535 13.2933M10.6668 7.99999C10.6668 9.47275 9.47293 10.6667 8.00017 10.6667C6.52741 10.6667 5.3335 9.47275 5.3335 7.99999C5.3335 6.52723 6.52741 5.33333 8.00017 5.33333C9.47293 5.33333 10.6668 6.52723 10.6668 7.99999Z" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2427_26665">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.42857 3.5L2.57143 8.5M3 9.5H8.9999M9.42857 8.5L6.57143 3.5M1.8 10.5H2.2C2.48003 10.5 2.62004 10.5 2.727 10.4455C2.82108 10.3976 2.89757 10.3211 2.9455 10.227C3 10.12 3 9.98003 3 9.7V9.3C3 9.01997 3 8.87996 2.9455 8.773C2.89757 8.67892 2.82108 8.60243 2.727 8.5545C2.62004 8.5 2.48003 8.5 2.2 8.5H1.8C1.51997 8.5 1.37996 8.5 1.273 8.5545C1.17892 8.60243 1.10243 8.67892 1.0545 8.773C1 8.87996 1 9.01997 1 9.3V9.7C1 9.98003 1 10.12 1.0545 10.227C1.10243 10.3211 1.17892 10.3976 1.273 10.4455C1.37996 10.5 1.51997 10.5 1.8 10.5ZM9.8 10.5H10.2C10.48 10.5 10.62 10.5 10.727 10.4455C10.8211 10.3976 10.8976 10.3211 10.9455 10.227C11 10.12 11 9.98003 11 9.7V9.3C11 9.01997 11 8.87996 10.9455 8.773C10.8976 8.67892 10.8211 8.60243 10.727 8.5545C10.62 8.5 10.48 8.5 10.2 8.5H9.8C9.51997 8.5 9.37996 8.5 9.273 8.5545C9.17892 8.60243 9.10243 8.67892 9.0545 8.773C9 8.87996 9 9.01997 9 9.3V9.7C9 9.98003 9 10.12 9.0545 10.227C9.10243 10.3211 9.17892 10.3976 9.273 10.4455C9.37996 10.5 9.51997 10.5 9.8 10.5ZM5.8 3.5H6.2C6.48003 3.5 6.62004 3.5 6.727 3.4455C6.82108 3.39757 6.89757 3.32108 6.9455 3.227C7 3.12004 7 2.98003 7 2.7V2.3C7 2.01997 7 1.87996 6.9455 1.773C6.89757 1.67892 6.82108 1.60243 6.727 1.5545C6.62004 1.5 6.48003 1.5 6.2 1.5H5.8C5.51997 1.5 5.37996 1.5 5.273 1.5545C5.17892 1.60243 5.10243 1.67892 5.0545 1.773C5 1.87996 5 2.01997 5 2.3V2.7C5 2.98003 5 3.12004 5.0545 3.227C5.10243 3.32108 5.17892 3.39757 5.273 3.4455C5.37996 3.5 5.51997 3.5 5.8 3.5Z" stroke="#667085" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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="M8.00016 14L7.93346 13.8999C7.47037 13.2053 7.23882 12.858 6.9329 12.6065C6.66207 12.3839 6.35001 12.2169 6.01457 12.1151C5.63566 12 5.21823 12 4.38338 12H3.46683C2.72009 12 2.34672 12 2.06151 11.8547C1.81063 11.7268 1.60665 11.5229 1.47882 11.272C1.3335 10.9868 1.3335 10.6134 1.3335 9.86667V4.13333C1.3335 3.3866 1.3335 3.01323 1.47882 2.72801C1.60665 2.47713 1.81063 2.27316 2.06151 2.14532C2.34672 2 2.72009 2 3.46683 2H3.7335C5.22697 2 5.97371 2 6.54414 2.29065C7.0459 2.54631 7.45385 2.95426 7.70951 3.45603C8.00016 4.02646 8.00016 4.77319 8.00016 6.26667M8.00016 14V6.26667M8.00016 14L8.06687 13.8999C8.52996 13.2053 8.76151 12.858 9.06743 12.6065C9.33826 12.3839 9.65032 12.2169 9.98576 12.1151C10.3647 12 10.7821 12 11.6169 12H12.5335C13.2802 12 13.6536 12 13.9388 11.8547C14.1897 11.7268 14.3937 11.5229 14.5215 11.272C14.6668 10.9868 14.6668 10.6134 14.6668 9.86667V4.13333C14.6668 3.3866 14.6668 3.01323 14.5215 2.72801C14.3937 2.47713 14.1897 2.27316 13.9388 2.14532C13.6536 2 13.2802 2 12.5335 2H12.2668C10.7734 2 10.0266 2 9.45619 2.29065C8.95442 2.54631 8.54647 2.95426 8.29081 3.45603C8.00016 4.02646 8.00016 4.77319 8.00016 6.26667" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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.3335 14V4.66667C5.3335 4.04669 5.3335 3.7367 5.40164 3.48236C5.58658 2.79218 6.12567 2.25308 6.81586 2.06815C7.07019 2 7.38018 2 8.00016 2C8.62014 2 8.93013 2 9.18447 2.06815C9.87465 2.25308 10.4137 2.79218 10.5987 3.48236C10.6668 3.7367 10.6668 4.04669 10.6668 4.66667V14M3.46683 14H12.5335C13.2802 14 13.6536 14 13.9388 13.8547C14.1897 13.7268 14.3937 13.5229 14.5215 13.272C14.6668 12.9868 14.6668 12.6134 14.6668 11.8667V6.8C14.6668 6.05326 14.6668 5.6799 14.5215 5.39468C14.3937 5.1438 14.1897 4.93982 13.9388 4.81199C13.6536 4.66667 13.2802 4.66667 12.5335 4.66667H3.46683C2.72009 4.66667 2.34672 4.66667 2.06151 4.81199C1.81063 4.93982 1.60665 5.1438 1.47882 5.39468C1.3335 5.6799 1.3335 6.05326 1.3335 6.8V11.8667C1.3335 12.6134 1.3335 12.9868 1.47882 13.272C1.60665 13.5229 1.81063 13.7268 2.06151 13.8547C2.34672 14 2.72009 14 3.46683 14Z" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="232" height="120" viewBox="0 0 232 120" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="6" width="232" height="8" rx="3" fill="#EAECF0"/>
|
||||
<rect y="26" width="232" height="8" rx="3" fill="#EAECF0"/>
|
||||
<rect y="46" width="232" height="8" rx="3" fill="#EAECF0"/>
|
||||
<rect y="66" width="232" height="8" rx="3" fill="#EAECF0"/>
|
||||
<rect y="86" width="232" height="8" rx="3" fill="#EAECF0"/>
|
||||
<g clip-path="url(#clip0_2368_22256)">
|
||||
<rect y="106" width="256" height="8" rx="3" fill="#EAECF0"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2368_22256">
|
||||
<rect width="232" height="20" fill="white" transform="translate(0 100)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 672 B |
@@ -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="M9.33317 1.51306V4.26676C9.33317 4.64012 9.33317 4.82681 9.40583 4.96942C9.46975 5.09486 9.57173 5.19684 9.69718 5.26076C9.83978 5.33342 10.0265 5.33342 10.3998 5.33342H13.1535M10.6665 8.66671H5.33317M10.6665 11.3334H5.33317M6.6665 6.00004H5.33317M9.33317 1.33337H5.8665C4.7464 1.33337 4.18635 1.33337 3.75852 1.55136C3.3822 1.74311 3.07624 2.04907 2.88449 2.42539C2.6665 2.85322 2.6665 3.41327 2.6665 4.53337V11.4667C2.6665 12.5868 2.6665 13.1469 2.88449 13.5747C3.07624 13.951 3.3822 14.257 3.75852 14.4487C4.18635 14.6667 4.7464 14.6667 5.8665 14.6667H10.1332C11.2533 14.6667 11.8133 14.6667 12.2412 14.4487C12.6175 14.257 12.9234 13.951 13.1152 13.5747C13.3332 13.1469 13.3332 12.5868 13.3332 11.4667V5.33337L9.33317 1.33337Z" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 929 B |
10
dify/web/app/components/datasets/documents/assets/globe.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2427_26661)">
|
||||
<path d="M1.79133 10.4301L3.06346 9.69574C3.13237 9.65596 3.21323 9.64214 3.29143 9.65678L5.79443 10.1252C6.00013 10.1637 6.19001 10.0054 6.18908 9.7961L6.17934 7.60301C6.17907 7.54342 6.19478 7.48486 6.22484 7.43341L7.48799 5.27085C7.55373 5.1583 7.54784 5.01776 7.47291 4.91111L5.34611 1.88383M12.667 3.23941C9.0003 5.00007 11.0002 7.3334 11.667 7.66674C12.9184 8.29232 14.6586 8.33337 14.6586 8.33337C14.664 8.22294 14.6668 8.11182 14.6668 8.00004C14.6668 4.31814 11.6821 1.33337 8.00016 1.33337C4.31826 1.33337 1.3335 4.31814 1.3335 8.00004C1.3335 11.6819 4.31826 14.6667 8.00016 14.6667C8.11194 14.6667 8.22307 14.664 8.3335 14.6585M11.172 14.6266L9.06083 9.06071L14.6267 11.1719L12.1586 12.1585L11.172 14.6266Z" stroke="#155EEF" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2427_26661">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -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="M11.3335 9.66667V7.66295C11.3335 7.5433 11.3335 7.48348 11.3153 7.43066C11.2992 7.38395 11.2729 7.34141 11.2383 7.30611C11.1992 7.26619 11.1457 7.23944 11.0387 7.18593L8.00016 5.66667M2.66683 6.33334V10.8711C2.66683 11.119 2.66683 11.243 2.70551 11.3515C2.7397 11.4475 2.79543 11.5343 2.86842 11.6054C2.95097 11.6858 3.06368 11.7374 3.28906 11.8408L7.55573 13.7963C7.71922 13.8712 7.80097 13.9087 7.88612 13.9235C7.96159 13.9366 8.03874 13.9366 8.1142 13.9235C8.19936 13.9087 8.28111 13.8712 8.44459 13.7963L12.7113 11.8408C12.9367 11.7374 13.0494 11.6858 13.1319 11.6054C13.2049 11.5343 13.2606 11.4475 13.2948 11.3515C13.3335 11.243 13.3335 11.119 13.3335 10.8711V6.33334M1.3335 5.66667L7.76165 2.45259C7.8491 2.40887 7.89283 2.387 7.9387 2.3784C7.97932 2.37078 8.02101 2.37078 8.06163 2.3784C8.1075 2.387 8.15122 2.40887 8.23868 2.45259L14.6668 5.66667L8.23868 8.88075C8.15122 8.92447 8.1075 8.94634 8.06163 8.95494C8.02101 8.96256 7.97932 8.96256 7.9387 8.95494C7.89283 8.94634 7.8491 8.92447 7.76165 8.88075L1.3335 5.66667Z" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="252" height="120" viewBox="0 0 252 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="6" width="252" height="8" rx="3" fill="#EAECF0"/>
|
||||
<rect y="26" width="252" height="8" rx="3" fill="#EAECF0"/>
|
||||
<rect y="46" width="252" height="8" rx="3" fill="#EAECF0"/>
|
||||
<rect y="66" width="252" height="8" rx="3" fill="#EAECF0"/>
|
||||
<rect y="86" width="252" height="8" rx="3" fill="#EAECF0"/>
|
||||
<g clip-path="url(#clip0_1562_6752)">
|
||||
<rect y="106" width="256" height="8" rx="3" fill="#EAECF0"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1562_6752">
|
||||
<rect width="252" height="20" fill="white" transform="translate(0 100)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 643 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.12" d="M10 2H10.8C11.9201 2 12.4802 2 12.908 2.21799C13.2843 2.40973 13.5903 2.71569 13.782 3.09202C14 3.51984 14 4.0799 14 5.2V10.8C14 11.9201 14 12.4802 13.782 12.908C13.5903 13.2843 13.2843 13.5903 12.908 13.782C12.4802 14 11.9201 14 10.8 14H10V2Z" fill="#344054"/>
|
||||
<path d="M10 2V14M5.2 2H10.8C11.9201 2 12.4802 2 12.908 2.21799C13.2843 2.40973 13.5903 2.71569 13.782 3.09202C14 3.51984 14 4.0799 14 5.2V10.8C14 11.9201 14 12.4802 13.782 12.908C13.5903 13.2843 13.2843 13.5903 12.908 13.782C12.4802 14 11.9201 14 10.8 14H5.2C4.07989 14 3.51984 14 3.09202 13.782C2.71569 13.5903 2.40973 13.2843 2.21799 12.908C2 12.4802 2 11.9201 2 10.8V5.2C2 4.07989 2 3.51984 2.21799 3.09202C2.40973 2.71569 2.71569 2.40973 3.09202 2.21799C3.51984 2 4.0799 2 5.2 2Z" stroke="#344054" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 960 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.12" d="M10 2H10.8C11.9201 2 12.4802 2 12.908 2.21799C13.2843 2.40973 13.5903 2.71569 13.782 3.09202C14 3.51984 14 4.0799 14 5.2V10.8C14 11.9201 14 12.4802 13.782 12.908C13.5903 13.2843 13.2843 13.5903 12.908 13.782C12.4802 14 11.9201 14 10.8 14H10V2Z" fill="#004EEB"/>
|
||||
<path d="M10 2V14M5.2 2H10.8C11.9201 2 12.4802 2 12.908 2.21799C13.2843 2.40973 13.5903 2.71569 13.782 3.09202C14 3.51984 14 4.0799 14 5.2V10.8C14 11.9201 14 12.4802 13.782 12.908C13.5903 13.2843 13.2843 13.5903 12.908 13.782C12.4802 14 11.9201 14 10.8 14H5.2C4.07989 14 3.51984 14 3.09202 13.782C2.71569 13.5903 2.40973 13.2843 2.21799 12.908C2 12.4802 2 11.9201 2 10.8V5.2C2 4.07989 2 3.51984 2.21799 3.09202C2.40973 2.71569 2.71569 2.40973 3.09202 2.21799C3.51984 2 4.0799 2 5.2 2Z" stroke="#155EEF" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 960 B |
@@ -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.33333 6.33333H8M5.33333 8.66667H10M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 8.7981 2.15582 9.5598 2.43871 10.2563C2.49285 10.3897 2.51992 10.4563 2.532 10.5102C2.54381 10.5629 2.54813 10.6019 2.54814 10.6559C2.54814 10.7111 2.53812 10.7713 2.51807 10.8916L2.12275 13.2635C2.08135 13.5119 2.06065 13.6361 2.09917 13.7259C2.13289 13.8045 2.19552 13.8671 2.27412 13.9008C2.36393 13.9393 2.48812 13.9186 2.73651 13.8772L5.10843 13.4819C5.22872 13.4619 5.28887 13.4519 5.34409 13.4519C5.3981 13.4519 5.43711 13.4562 5.48981 13.468C5.54369 13.4801 5.61035 13.5072 5.74366 13.5613C6.4402 13.8442 7.2019 14 8 14Z" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 849 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.75 4.5C6.7165 4.5 7.5 3.7165 7.5 2.75C7.5 1.7835 6.7165 1 5.75 1C4.7835 1 4 1.7835 4 2.75C4 3.7165 4.7835 4.5 5.75 4.5Z" fill="#444CE7"/>
|
||||
<path d="M3.48775 4.314C3.36842 4.14172 3.30875 4.05558 3.24448 4.02712C3.18679 4.00157 3.12605 3.99844 3.06603 4.01794C2.99918 4.03965 2.94661 4.10099 2.84146 4.22367C2.41951 4.71598 2.13172 5.32705 2.03543 6.00009H2C1.72386 6.00009 1.5 5.77623 1.5 5.50009C1.5 5.31565 1.59961 5.15388 1.75036 5.06668C1.98939 4.9284 2.07107 4.62254 1.9328 4.38351C1.79453 4.14448 1.48867 4.0628 1.24964 4.20107C0.802591 4.45967 0.5 4.94425 0.5 5.50009C0.5 6.32852 1.17157 7.00009 2 7.00009H2.03545C2.14342 7.75422 2.49192 8.43113 2.99997 8.94961L2.99997 10.1117C2.99994 10.1712 2.99992 10.2424 3.00504 10.305C3.01097 10.3776 3.02619 10.4816 3.08171 10.5906C3.15362 10.7317 3.26835 10.8465 3.40948 10.9184C3.51845 10.9739 3.62245 10.9891 3.69505 10.9951C3.7577 11.0002 3.82881 11.0001 3.88836 11.0001H4.86154C4.92109 11.0001 4.99224 11.0002 5.05488 10.9951C5.12749 10.9891 5.23149 10.9739 5.34046 10.9184C5.48158 10.8465 5.59632 10.7317 5.66822 10.5906C5.72375 10.4816 5.73897 10.3776 5.7449 10.305C5.75002 10.2424 5.75 10.1712 5.74997 10.1117L5.74997 10.0001H6.24998L6.24997 10.1115C6.24995 10.1711 6.24992 10.2422 6.25504 10.3048C6.26097 10.3775 6.2762 10.4815 6.33172 10.5904C6.40363 10.7315 6.51836 10.8463 6.65948 10.9182C6.76846 10.9737 6.87245 10.9889 6.94506 10.9949C7.0077 11 7.0788 11 7.13835 10.9999H8.11159C8.17113 11 8.24229 11 8.30493 10.9949C8.37753 10.9889 8.48153 10.9737 8.5905 10.9182C8.73162 10.8463 8.84636 10.7315 8.91827 10.5904C8.97379 10.4815 8.98901 10.3775 8.99494 10.3048C9.00006 10.2422 9.00004 10.1711 9.00001 10.1115L9.00001 9.66299C9.55312 9.40029 10.0258 8.99721 10.3726 8.49993L10.6116 8.49994C10.6711 8.49996 10.7423 8.49999 10.8049 8.49487C10.8775 8.48893 10.9815 8.47371 11.0905 8.41819C11.2316 8.34628 11.3464 8.23155 11.4183 8.09043C11.4738 7.98145 11.489 7.87746 11.4949 7.80485C11.5001 7.74221 11.5 7.67109 11.5 7.61154V5.88181C11.5 5.82509 11.5001 5.75735 11.4954 5.69761C11.49 5.62851 11.4763 5.5294 11.4257 5.42448C11.352 5.27143 11.2285 5.14794 11.0755 5.07422C10.9705 5.02369 10.8714 5.00992 10.8023 5.00454C10.7577 5.00106 10.7087 5.0002 10.6631 4.99999C10.4953 4.64662 10.2702 4.32616 10 4.05044L10 3.51615C10 3.43874 10.0001 3.35111 9.99335 3.27574C9.98593 3.19252 9.96656 3.06385 9.88754 2.93633C9.78902 2.77733 9.63465 2.66089 9.4547 2.60984C9.31038 2.56889 9.18134 2.58561 9.09929 2.60134C9.02497 2.61559 8.94073 2.63969 8.8663 2.66098L8.78839 2.68324C8.6859 2.71252 8.63465 2.72716 8.59861 2.75356C8.5638 2.77904 8.54252 2.80415 8.52309 2.84266C8.50297 2.88255 8.49603 2.94339 8.48215 3.06506C8.32585 4.43546 7.16224 5.5 5.75 5.5C4.81225 5.5 3.98413 5.03063 3.48775 4.314Z" fill="#444CE7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
11
dify/web/app/components/datasets/documents/assets/star.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 0.5C6.27614 0.5 6.5 0.723858 6.5 1V2C6.5 2.27614 6.27614 2.5 6 2.5C5.72386 2.5 5.5 2.27614 5.5 2V1C5.5 0.723858 5.72386 0.5 6 0.5Z" fill="#FB6514"/>
|
||||
<path d="M2.81791 2.11092C2.62265 1.91566 2.30606 1.91566 2.1108 2.11092C1.91554 2.30619 1.91554 2.62277 2.1108 2.81803L2.81791 3.52514C3.01317 3.7204 3.32975 3.7204 3.52502 3.52514C3.72028 3.32988 3.72028 3.01329 3.52502 2.81803L2.81791 2.11092Z" fill="#FB6514"/>
|
||||
<path d="M0.5 6C0.5 5.72386 0.723858 5.5 1 5.5H2C2.27614 5.5 2.5 5.72386 2.5 6C2.5 6.27614 2.27614 6.5 2 6.5H1C0.723858 6.5 0.5 6.27614 0.5 6Z" fill="#FB6514"/>
|
||||
<path d="M10 5.5C9.72386 5.5 9.5 5.72386 9.5 6C9.5 6.27614 9.72386 6.5 10 6.5H11C11.2761 6.5 11.5 6.27614 11.5 6C11.5 5.72386 11.2761 5.5 11 5.5H10Z" fill="#FB6514"/>
|
||||
<path d="M9.18192 8.47482C8.98666 8.27955 8.67008 8.27955 8.47482 8.47482C8.27955 8.67008 8.27955 8.98666 8.47482 9.18192L9.18192 9.88903C9.37718 10.0843 9.69377 10.0843 9.88903 9.88903C10.0843 9.69377 10.0843 9.37718 9.88903 9.18192L9.18192 8.47482Z" fill="#FB6514"/>
|
||||
<path d="M9.88903 2.81803C10.0843 2.62277 10.0843 2.30619 9.88903 2.11092C9.69377 1.91566 9.37718 1.91566 9.18192 2.11092L8.47482 2.81803C8.27955 3.01329 8.27955 3.32988 8.47482 3.52514C8.67008 3.7204 8.98666 3.7204 9.18192 3.52514L9.88903 2.81803Z" fill="#FB6514"/>
|
||||
<path d="M6 9.5C6.27614 9.5 6.5 9.72386 6.5 10V11C6.5 11.2761 6.27614 11.5 6 11.5C5.72386 11.5 5.5 11.2761 5.5 11V10C5.5 9.72386 5.72386 9.5 6 9.5Z" fill="#FB6514"/>
|
||||
<path d="M3.52502 9.18192C3.72028 8.98666 3.72028 8.67008 3.52502 8.47482C3.32975 8.27955 3.01317 8.27955 2.81791 8.47482L2.1108 9.18192C1.91554 9.37718 1.91554 9.69377 2.1108 9.88903C2.30606 10.0843 2.62265 10.0843 2.81791 9.88903L3.52502 9.18192Z" fill="#FB6514"/>
|
||||
<path d="M6.44837 3.27869C6.36413 3.10804 6.19032 3 6.00001 3C5.8097 3 5.6359 3.10804 5.55166 3.27869L4.89538 4.60823L3.4277 4.82276C3.23942 4.85028 3.08308 4.98228 3.02439 5.16328C2.9657 5.34429 3.01484 5.54291 3.15115 5.67568L4.21275 6.70968L3.96221 8.17048C3.93004 8.35807 4.00716 8.54766 4.16115 8.65953C4.31514 8.77139 4.51928 8.78613 4.68774 8.69754L6.00001 8.00742L7.31229 8.69754C7.48075 8.78613 7.68489 8.77139 7.83888 8.65953C7.99287 8.54766 8.06999 8.35807 8.03782 8.17048L7.78728 6.70968L8.84888 5.67568C8.98519 5.54291 9.03433 5.34429 8.97564 5.16328C8.91695 4.98228 8.76061 4.85028 8.57233 4.82276L7.10465 4.60823L6.44837 3.27869Z" fill="#FB6514"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
10
dify/web/app/components/datasets/documents/assets/target.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1745_6117)">
|
||||
<path d="M7.99998 4V2.5L9.49998 1L9.99998 2L11 2.5L9.49998 4H7.99998ZM7.99998 4L5.99999 5.99997M11 6C11 8.76142 8.76142 11 6 11C3.23858 11 1 8.76142 1 6C1 3.23858 3.23858 1 6 1M8.5 6C8.5 7.38071 7.38071 8.5 6 8.5C4.61929 8.5 3.5 7.38071 3.5 6C3.5 4.61929 4.61929 3.5 6 3.5" stroke="#667085" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1745_6117">
|
||||
<rect width="12" height="12" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 635 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 3.5H8M6 3.5V8.5M3.9 10.5H8.1C8.94008 10.5 9.36012 10.5 9.68099 10.3365C9.96323 10.1927 10.1927 9.96323 10.3365 9.68099C10.5 9.36012 10.5 8.94008 10.5 8.1V3.9C10.5 3.05992 10.5 2.63988 10.3365 2.31901C10.1927 2.03677 9.96323 1.8073 9.68099 1.66349C9.36012 1.5 8.94008 1.5 8.1 1.5H3.9C3.05992 1.5 2.63988 1.5 2.31901 1.66349C2.03677 1.8073 1.8073 2.03677 1.66349 2.31901C1.5 2.63988 1.5 3.05992 1.5 3.9V8.1C1.5 8.94008 1.5 9.36012 1.66349 9.68099C1.8073 9.96323 2.03677 10.1927 2.31901 10.3365C2.63988 10.5 3.05992 10.5 3.9 10.5Z" stroke="#667085" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 717 B |
@@ -0,0 +1,90 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { RiArrowRightLine } from '@remixicon/react'
|
||||
import Link from 'next/link'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
|
||||
type ActionsProps = {
|
||||
disabled?: boolean
|
||||
handleNextStep: () => void
|
||||
showSelect?: boolean
|
||||
totalOptions?: number
|
||||
selectedOptions?: number
|
||||
onSelectAll?: () => void
|
||||
tip?: string
|
||||
}
|
||||
|
||||
const Actions = ({
|
||||
disabled,
|
||||
handleNextStep,
|
||||
showSelect = false,
|
||||
totalOptions,
|
||||
selectedOptions,
|
||||
onSelectAll,
|
||||
tip = '',
|
||||
}: ActionsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { datasetId } = useParams()
|
||||
|
||||
const indeterminate = useMemo(() => {
|
||||
if (!showSelect) return false
|
||||
if (selectedOptions === undefined || totalOptions === undefined) return false
|
||||
return selectedOptions > 0 && selectedOptions < totalOptions
|
||||
}, [showSelect, selectedOptions, totalOptions])
|
||||
|
||||
const checked = useMemo(() => {
|
||||
if (!showSelect) return false
|
||||
if (selectedOptions === undefined || totalOptions === undefined) return false
|
||||
return selectedOptions > 0 && selectedOptions === totalOptions
|
||||
}, [showSelect, selectedOptions, totalOptions])
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-2 overflow-hidden'>
|
||||
{showSelect && (
|
||||
<>
|
||||
<div className='flex shrink-0 items-center gap-x-2 py-[3px] pl-4 pr-2'>
|
||||
<Checkbox
|
||||
onCheck={onSelectAll}
|
||||
indeterminate={indeterminate}
|
||||
checked={checked}
|
||||
/>
|
||||
<span className='system-sm-medium text-text-accent'>
|
||||
{t('common.operation.selectAll')}
|
||||
</span>
|
||||
</div>
|
||||
{tip && (
|
||||
<div title={tip} className='system-xs-regular max-w-full truncate text-text-tertiary'>
|
||||
{tip}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className='flex grow items-center justify-end gap-x-2'>
|
||||
<Link
|
||||
href={`/datasets/${datasetId}/documents`}
|
||||
replace
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='px-3 py-2'
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
variant='primary'
|
||||
onClick={handleNextStep}
|
||||
className='gap-x-0.5'
|
||||
>
|
||||
<span className='px-0.5'>{t('datasetCreation.stepOne.button')}</span>
|
||||
<RiArrowRightLine className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Actions)
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type DatasourceIconProps = {
|
||||
size?: string
|
||||
className?: string
|
||||
iconUrl: string
|
||||
}
|
||||
|
||||
const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = {
|
||||
xs: 'w-4 h-4 rounded-[5px] shadow-xs',
|
||||
sm: 'w-5 h-5 rounded-md shadow-xs',
|
||||
md: 'w-6 h-6 rounded-lg shadow-md',
|
||||
}
|
||||
|
||||
const DatasourceIcon: FC<DatasourceIconProps> = ({
|
||||
size = 'sm',
|
||||
className,
|
||||
iconUrl,
|
||||
}) => {
|
||||
return (
|
||||
<div className={
|
||||
cn(
|
||||
'flex items-center justify-center shadow-none',
|
||||
ICON_CONTAINER_CLASSNAME_SIZE_MAP[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='h-full w-full shrink-0 rounded-md bg-cover bg-center'
|
||||
style={{
|
||||
backgroundImage: `url(${iconUrl})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(DatasourceIcon)
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { useDataSourceList } from '@/service/use-pipeline'
|
||||
import { transformDataSourceToTool } from '@/app/components/workflow/block-selector/utils'
|
||||
|
||||
export const useDatasourceIcon = (data: DataSourceNodeType) => {
|
||||
const { data: dataSourceListData, isSuccess } = useDataSourceList(true)
|
||||
|
||||
const datasourceIcon = useMemo(() => {
|
||||
if (!isSuccess) return
|
||||
const dataSourceList = [...(dataSourceListData || [])]
|
||||
dataSourceList.forEach((item) => {
|
||||
const icon = item.declaration.identity.icon
|
||||
if (typeof icon == 'string' && !icon.includes(basePath))
|
||||
item.declaration.identity.icon = `${basePath}${icon}`
|
||||
})
|
||||
const formattedDataSourceList = dataSourceList.map(item => transformDataSourceToTool(item))
|
||||
return formattedDataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon
|
||||
}, [data.plugin_id, dataSourceListData, isSuccess])
|
||||
|
||||
return datasourceIcon
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useDatasourceOptions } from '../hooks'
|
||||
import OptionCard from './option-card'
|
||||
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
|
||||
type DataSourceOptionsProps = {
|
||||
pipelineNodes: Node<DataSourceNodeType>[]
|
||||
datasourceNodeId: string
|
||||
onSelect: (option: Datasource) => void
|
||||
}
|
||||
|
||||
const DataSourceOptions = ({
|
||||
pipelineNodes,
|
||||
datasourceNodeId,
|
||||
onSelect,
|
||||
}: DataSourceOptionsProps) => {
|
||||
const options = useDatasourceOptions(pipelineNodes)
|
||||
|
||||
const handelSelect = useCallback((value: string) => {
|
||||
const selectedOption = options.find(option => option.value === value)
|
||||
if (!selectedOption)
|
||||
return
|
||||
const datasource = {
|
||||
nodeId: selectedOption.value,
|
||||
nodeData: selectedOption.data,
|
||||
}
|
||||
onSelect(datasource)
|
||||
}, [onSelect, options])
|
||||
|
||||
useEffect(() => {
|
||||
if (options.length > 0 && !datasourceNodeId)
|
||||
handelSelect(options[0].value)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='grid w-full grid-cols-4 gap-1'>
|
||||
{options.map(option => (
|
||||
<OptionCard
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
selected={datasourceNodeId === option.value}
|
||||
nodeData={option.data}
|
||||
onClick={handelSelect.bind(null, option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataSourceOptions
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import DatasourceIcon from './datasource-icon'
|
||||
import { useDatasourceIcon } from './hooks'
|
||||
|
||||
type OptionCardProps = {
|
||||
label: string
|
||||
selected: boolean
|
||||
nodeData: DataSourceNodeType
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const OptionCard = ({
|
||||
label,
|
||||
selected,
|
||||
nodeData,
|
||||
onClick,
|
||||
}: OptionCardProps) => {
|
||||
const iconUrl = useDatasourceIcon(nodeData) as string
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-3 shadow-shadow-shadow-3',
|
||||
selected
|
||||
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs ring-[0.5px] ring-inset ring-components-option-card-option-selected-border'
|
||||
: 'hover:bg-components-option-card-bg-hover hover:border-components-option-card-option-border-hover hover:shadow-xs',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className='flex size-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border bg-background-default-dodge p-1.5'>
|
||||
<DatasourceIcon iconUrl={iconUrl} />
|
||||
</div>
|
||||
<div
|
||||
className={cn('system-sm-medium line-clamp-2 grow text-text-secondary', selected && 'text-text-primary')}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(OptionCard)
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type { DataSourceCredential } from '@/types/pipeline'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import Trigger from './trigger'
|
||||
import List from './list'
|
||||
|
||||
export type CredentialSelectorProps = {
|
||||
pluginName: string
|
||||
currentCredentialId: string
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
credentials: Array<DataSourceCredential>
|
||||
}
|
||||
|
||||
const CredentialSelector = ({
|
||||
pluginName,
|
||||
currentCredentialId,
|
||||
onCredentialChange,
|
||||
credentials,
|
||||
}: CredentialSelectorProps) => {
|
||||
const [open, { toggle }] = useBoolean(false)
|
||||
|
||||
const currentCredential = useMemo(() => {
|
||||
return credentials.find(cred => cred.id === currentCredentialId)
|
||||
}, [credentials, currentCredentialId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentCredential && credentials.length)
|
||||
onCredentialChange(credentials[0].id)
|
||||
}, [currentCredential, credentials])
|
||||
|
||||
const handleCredentialChange = useCallback((credentialId: string) => {
|
||||
onCredentialChange(credentialId)
|
||||
toggle()
|
||||
}, [onCredentialChange, toggle])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={toggle}
|
||||
placement='bottom-start'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={toggle} className='grow overflow-hidden'>
|
||||
<Trigger
|
||||
currentCredential={currentCredential}
|
||||
pluginName={pluginName}
|
||||
isOpen={open}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-10'>
|
||||
<List
|
||||
currentCredentialId={currentCredentialId}
|
||||
credentials={credentials}
|
||||
pluginName={pluginName}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CredentialSelector)
|
||||
@@ -0,0 +1,52 @@
|
||||
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
|
||||
import type { DataSourceCredential } from '@/types/pipeline'
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ItemProps = {
|
||||
credential: DataSourceCredential
|
||||
pluginName: string
|
||||
isSelected: boolean
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const Item = ({
|
||||
credential,
|
||||
pluginName,
|
||||
isSelected,
|
||||
onCredentialChange,
|
||||
}: ItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { avatar_url, name } = credential
|
||||
|
||||
const handleCredentialChange = useCallback(() => {
|
||||
onCredentialChange(credential.id)
|
||||
}, [credential.id, onCredentialChange])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='flex cursor-pointer items-center gap-x-2 rounded-lg p-2 hover:bg-state-base-hover'
|
||||
onClick={handleCredentialChange}
|
||||
>
|
||||
<CredentialIcon
|
||||
avatar_url={avatar_url}
|
||||
name={name}
|
||||
size={20}
|
||||
/>
|
||||
<span className='system-sm-medium grow truncate text-text-secondary'>
|
||||
{t('datasetPipeline.credentialSelector.name', {
|
||||
credentialName: name,
|
||||
pluginName,
|
||||
})}
|
||||
</span>
|
||||
{
|
||||
isSelected && (
|
||||
<RiCheckLine className='size-4 shrink-0 text-text-accent' />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Item)
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DataSourceCredential } from '@/types/pipeline'
|
||||
import React from 'react'
|
||||
import Item from './item'
|
||||
|
||||
type ListProps = {
|
||||
currentCredentialId: string
|
||||
credentials: Array<DataSourceCredential>
|
||||
pluginName: string
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const List = ({
|
||||
currentCredentialId,
|
||||
credentials,
|
||||
pluginName,
|
||||
onCredentialChange,
|
||||
}: ListProps) => {
|
||||
return (
|
||||
<div className='flex w-[280px] flex-col gap-y-1 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
|
||||
{
|
||||
credentials.map((credential) => {
|
||||
const isSelected = credential.id === currentCredentialId
|
||||
return (
|
||||
<Item
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
pluginName={pluginName}
|
||||
isSelected={isSelected}
|
||||
onCredentialChange={onCredentialChange}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(List)
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
import type { DataSourceCredential } from '@/types/pipeline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
|
||||
|
||||
type TriggerProps = {
|
||||
currentCredential: DataSourceCredential | undefined
|
||||
pluginName: string
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const Trigger = ({
|
||||
currentCredential,
|
||||
pluginName,
|
||||
isOpen,
|
||||
}: TriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
avatar_url,
|
||||
name = '',
|
||||
} = currentCredential || {}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-x-2 rounded-md p-1 pr-2',
|
||||
isOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<CredentialIcon
|
||||
avatar_url={avatar_url}
|
||||
name={name}
|
||||
size={20}
|
||||
/>
|
||||
<div className='flex grow items-center gap-x-1 overflow-hidden'>
|
||||
<span className='system-md-semibold grow truncate text-text-secondary'>
|
||||
{t('datasetPipeline.credentialSelector.name', {
|
||||
credentialName: name,
|
||||
pluginName,
|
||||
})}
|
||||
</span>
|
||||
<RiArrowDownSLine className='size-4 shrink-0 text-text-secondary' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Trigger)
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react'
|
||||
import type { CredentialSelectorProps } from './credential-selector'
|
||||
import CredentialSelector from './credential-selector'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type HeaderProps = {
|
||||
docTitle: string
|
||||
docLink: string
|
||||
onClickConfiguration?: () => void
|
||||
} & CredentialSelectorProps
|
||||
|
||||
const Header = ({
|
||||
docTitle,
|
||||
docLink,
|
||||
onClickConfiguration,
|
||||
...rest
|
||||
}: HeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-x-2'>
|
||||
<div className='flex items-center gap-x-1 overflow-hidden'>
|
||||
<CredentialSelector
|
||||
{...rest}
|
||||
/>
|
||||
<Divider type='vertical' className='mx-1 h-3.5 shrink-0' />
|
||||
<Tooltip
|
||||
popupContent={t('datasetPipeline.configurationTip', { pluginName: rest.pluginName })}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='small'
|
||||
className='size-6 shrink-0 px-1'
|
||||
>
|
||||
<RiEqualizer2Line
|
||||
className='h-4 w-4'
|
||||
onClick={onClickConfiguration}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<a
|
||||
className='system-xs-medium flex shrink-0 items-center gap-x-1 text-text-accent'
|
||||
href={docLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<RiBookOpenLine className='size-3.5 shrink-0' />
|
||||
<span title={docTitle}>{docTitle}</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
@@ -0,0 +1,369 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiDeleteBinLine, RiErrorWarningFill, RiUploadCloud2Line } from '@remixicon/react'
|
||||
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { CustomFile as File, FileItem } from '@/models/datasets'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { upload } from '@/service/base'
|
||||
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
|
||||
import I18n from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { Theme } from '@/types/app'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store'
|
||||
import { produce } from 'immer'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false })
|
||||
|
||||
const FILES_NUMBER_LIMIT = 20
|
||||
|
||||
export type LocalFileProps = {
|
||||
allowedExtensions: string[]
|
||||
notSupportBatchUpload?: boolean
|
||||
}
|
||||
|
||||
const LocalFile = ({
|
||||
allowedExtensions,
|
||||
notSupportBatchUpload,
|
||||
}: LocalFileProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { locale } = useContext(I18n)
|
||||
const localFileList = useDataSourceStoreWithSelector(state => state.localFileList)
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const dragRef = useRef<HTMLDivElement>(null)
|
||||
const fileUploader = useRef<HTMLInputElement>(null)
|
||||
const fileListRef = useRef<FileItem[]>([])
|
||||
|
||||
const hideUpload = notSupportBatchUpload && localFileList.length > 0
|
||||
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
const supportTypesShowNames = useMemo(() => {
|
||||
const extensionMap: { [key: string]: string } = {
|
||||
md: 'markdown',
|
||||
pptx: 'pptx',
|
||||
htm: 'html',
|
||||
xlsx: 'xlsx',
|
||||
docx: 'docx',
|
||||
}
|
||||
|
||||
return allowedExtensions
|
||||
.map(item => extensionMap[item] || item) // map to standardized extension
|
||||
.map(item => item.toLowerCase()) // convert to lower case
|
||||
.filter((item, index, self) => self.indexOf(item) === index) // remove duplicates
|
||||
.map(item => item.toUpperCase()) // convert to upper case
|
||||
.join(locale !== LanguagesSupported[1] ? ', ' : '、 ')
|
||||
}, [locale, allowedExtensions])
|
||||
const ACCEPTS = allowedExtensions.map((ext: string) => `.${ext}`)
|
||||
const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
|
||||
file_size_limit: 15,
|
||||
batch_count_limit: 5,
|
||||
}, [fileUploadConfigResponse])
|
||||
|
||||
const updateFile = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => {
|
||||
const { setLocalFileList } = dataSourceStore.getState()
|
||||
const newList = produce(list, (draft) => {
|
||||
const targetIndex = draft.findIndex(file => file.fileID === fileItem.fileID)
|
||||
draft[targetIndex] = {
|
||||
...draft[targetIndex],
|
||||
progress,
|
||||
}
|
||||
})
|
||||
setLocalFileList(newList)
|
||||
}, [dataSourceStore])
|
||||
|
||||
const updateFileList = useCallback((preparedFiles: FileItem[]) => {
|
||||
const { setLocalFileList } = dataSourceStore.getState()
|
||||
setLocalFileList(preparedFiles)
|
||||
}, [dataSourceStore])
|
||||
|
||||
const handlePreview = useCallback((file: File) => {
|
||||
const { setCurrentLocalFile } = dataSourceStore.getState()
|
||||
if (file.id)
|
||||
setCurrentLocalFile(file)
|
||||
}, [dataSourceStore])
|
||||
|
||||
// utils
|
||||
const getFileType = (currentFile: File) => {
|
||||
if (!currentFile)
|
||||
return ''
|
||||
|
||||
const arr = currentFile.name.split('.')
|
||||
return arr[arr.length - 1]
|
||||
}
|
||||
|
||||
const getFileSize = (size: number) => {
|
||||
if (size / 1024 < 10)
|
||||
return `${(size / 1024).toFixed(2)}KB`
|
||||
|
||||
return `${(size / 1024 / 1024).toFixed(2)}MB`
|
||||
}
|
||||
|
||||
const isValid = useCallback((file: File) => {
|
||||
const { size } = file
|
||||
const ext = `.${getFileType(file)}`
|
||||
const isValidType = ACCEPTS.includes(ext.toLowerCase())
|
||||
if (!isValidType)
|
||||
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.typeError') })
|
||||
|
||||
const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024
|
||||
if (!isValidSize)
|
||||
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.size', { size: fileUploadConfig.file_size_limit }) })
|
||||
|
||||
return isValidType && isValidSize
|
||||
}, [fileUploadConfig, notify, t, ACCEPTS])
|
||||
|
||||
type UploadResult = Awaited<ReturnType<typeof upload>>
|
||||
|
||||
const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', fileItem.file)
|
||||
const onProgress = (e: ProgressEvent) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.floor(e.loaded / e.total * 100)
|
||||
updateFile(fileItem, percent, fileListRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
return upload({
|
||||
xhr: new XMLHttpRequest(),
|
||||
data: formData,
|
||||
onprogress: onProgress,
|
||||
}, false, undefined, '?source=datasets')
|
||||
.then((res: UploadResult) => {
|
||||
const updatedFile = Object.assign({}, fileItem.file, {
|
||||
id: res.id,
|
||||
...(res as Partial<File>),
|
||||
}) as File
|
||||
const completeFile: FileItem = {
|
||||
fileID: fileItem.fileID,
|
||||
file: updatedFile,
|
||||
progress: -1,
|
||||
}
|
||||
const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID)
|
||||
fileListRef.current[index] = completeFile
|
||||
updateFile(completeFile, 100, fileListRef.current)
|
||||
return Promise.resolve({ ...completeFile })
|
||||
})
|
||||
.catch((e) => {
|
||||
const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
updateFile(fileItem, -2, fileListRef.current)
|
||||
return Promise.resolve({ ...fileItem })
|
||||
})
|
||||
.finally()
|
||||
}, [fileListRef, notify, updateFile, t])
|
||||
|
||||
const uploadBatchFiles = useCallback((bFiles: FileItem[]) => {
|
||||
bFiles.forEach(bf => (bf.progress = 0))
|
||||
return Promise.all(bFiles.map(fileUpload))
|
||||
}, [fileUpload])
|
||||
|
||||
const uploadMultipleFiles = useCallback(async (files: FileItem[]) => {
|
||||
const batchCountLimit = fileUploadConfig.batch_count_limit
|
||||
const length = files.length
|
||||
let start = 0
|
||||
let end = 0
|
||||
|
||||
while (start < length) {
|
||||
if (start + batchCountLimit > length)
|
||||
end = length
|
||||
else
|
||||
end = start + batchCountLimit
|
||||
const bFiles = files.slice(start, end)
|
||||
await uploadBatchFiles(bFiles)
|
||||
start = end
|
||||
}
|
||||
}, [fileUploadConfig, uploadBatchFiles])
|
||||
|
||||
const initialUpload = useCallback((files: File[]) => {
|
||||
if (!files.length)
|
||||
return false
|
||||
|
||||
if (files.length + localFileList.length > FILES_NUMBER_LIMIT && !IS_CE_EDITION) {
|
||||
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.filesNumber', { filesNumber: FILES_NUMBER_LIMIT }) })
|
||||
return false
|
||||
}
|
||||
|
||||
const preparedFiles = files.map((file, index) => ({
|
||||
fileID: `file${index}-${Date.now()}`,
|
||||
file,
|
||||
progress: -1,
|
||||
}))
|
||||
const newFiles = [...fileListRef.current, ...preparedFiles]
|
||||
updateFileList(newFiles)
|
||||
fileListRef.current = newFiles
|
||||
uploadMultipleFiles(preparedFiles)
|
||||
}, [updateFileList, uploadMultipleFiles, notify, t, localFileList])
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.target !== dragRef.current)
|
||||
setDragging(true)
|
||||
}
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.target === dragRef.current)
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragging(false)
|
||||
if (!e.dataTransfer)
|
||||
return
|
||||
|
||||
let files = [...e.dataTransfer.files] as File[]
|
||||
if (notSupportBatchUpload)
|
||||
files = files.slice(0, 1)
|
||||
|
||||
const validFiles = files.filter(isValid)
|
||||
initialUpload(validFiles)
|
||||
}, [initialUpload, isValid, notSupportBatchUpload])
|
||||
|
||||
const selectHandle = useCallback(() => {
|
||||
if (fileUploader.current)
|
||||
fileUploader.current.click()
|
||||
}, [])
|
||||
|
||||
const removeFile = (fileID: string) => {
|
||||
if (fileUploader.current)
|
||||
fileUploader.current.value = ''
|
||||
|
||||
fileListRef.current = fileListRef.current.filter(item => item.fileID !== fileID)
|
||||
updateFileList([...fileListRef.current])
|
||||
}
|
||||
const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = [...(e.target.files ?? [])] as File[]
|
||||
initialUpload(files.filter(isValid))
|
||||
}, [isValid, initialUpload])
|
||||
|
||||
const { theme } = useTheme()
|
||||
const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
|
||||
|
||||
useEffect(() => {
|
||||
const dropElement = dropRef.current
|
||||
dropElement?.addEventListener('dragenter', handleDragEnter)
|
||||
dropElement?.addEventListener('dragover', handleDragOver)
|
||||
dropElement?.addEventListener('dragleave', handleDragLeave)
|
||||
dropElement?.addEventListener('drop', handleDrop)
|
||||
return () => {
|
||||
dropElement?.removeEventListener('dragenter', handleDragEnter)
|
||||
dropElement?.removeEventListener('dragover', handleDragOver)
|
||||
dropElement?.removeEventListener('dragleave', handleDragLeave)
|
||||
dropElement?.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDrop])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
{!hideUpload && (
|
||||
<input
|
||||
ref={fileUploader}
|
||||
id='fileUploader'
|
||||
className='hidden'
|
||||
type='file'
|
||||
multiple={!notSupportBatchUpload}
|
||||
accept={ACCEPTS.join(',')}
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
)}
|
||||
{!hideUpload && (
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={cn(
|
||||
'relative box-border flex min-h-20 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg px-4 py-3 text-xs leading-4 text-text-tertiary',
|
||||
dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
|
||||
)}>
|
||||
<div className='flex min-h-5 items-center justify-center text-sm leading-4 text-text-secondary'>
|
||||
<RiUploadCloud2Line className='mr-2 size-5' />
|
||||
|
||||
<span>
|
||||
{notSupportBatchUpload ? t('datasetCreation.stepOne.uploader.buttonSingleFile') : t('datasetCreation.stepOne.uploader.button')}
|
||||
{allowedExtensions.length > 0 && (
|
||||
<label className='ml-1 cursor-pointer text-text-accent' onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.browse')}</label>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div>{t('datasetCreation.stepOne.uploader.tip', {
|
||||
size: fileUploadConfig.file_size_limit,
|
||||
supportTypes: supportTypesShowNames,
|
||||
batchCount: notSupportBatchUpload ? 1 : fileUploadConfig.batch_count_limit,
|
||||
})}</div>
|
||||
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
|
||||
</div>
|
||||
)}
|
||||
{localFileList.length > 0 && (
|
||||
<div className='mt-1 flex flex-col gap-y-1'>
|
||||
{localFileList.map((fileItem, index) => {
|
||||
const isUploading = fileItem.progress >= 0 && fileItem.progress < 100
|
||||
const isError = fileItem.progress === -2
|
||||
return (
|
||||
<div
|
||||
key={`${fileItem.fileID}-${index}`}
|
||||
onClick={handlePreview.bind(null, fileItem.file)}
|
||||
className={cn(
|
||||
'flex h-12 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs shadow-shadow-shadow-4',
|
||||
isError && 'border-state-destructive-border bg-state-destructive-hover',
|
||||
)}
|
||||
>
|
||||
<div className='flex w-12 shrink-0 items-center justify-center'>
|
||||
<DocumentFileIcon
|
||||
size='lg'
|
||||
className='shrink-0'
|
||||
name={fileItem.file.name}
|
||||
extension={getFileType(fileItem.file)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex shrink grow flex-col gap-0.5'>
|
||||
<div className='flex w-full'>
|
||||
<div className='w-0 grow truncate text-xs text-text-secondary'>{fileItem.file.name}</div>
|
||||
</div>
|
||||
<div className='w-full truncate text-2xs leading-3 text-text-tertiary'>
|
||||
<span className='uppercase'>{getFileType(fileItem.file)}</span>
|
||||
<span className='px-1 text-text-quaternary'>·</span>
|
||||
<span>{getFileSize(fileItem.file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-16 shrink-0 items-center justify-end gap-1 pr-3'>
|
||||
{isUploading && (
|
||||
<SimplePieChart percentage={fileItem.progress} stroke={chartColor} fill={chartColor} animationDuration={0} />
|
||||
)}
|
||||
{
|
||||
isError && (
|
||||
<RiErrorWarningFill className='size-4 text-text-destructive' />
|
||||
)
|
||||
}
|
||||
<span className='flex h-6 w-6 cursor-pointer items-center justify-center' onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeFile(fileItem.fileID)
|
||||
}}>
|
||||
<RiDeleteBinLine className='size-4 text-text-tertiary' />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LocalFile
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import SearchInput from '@/app/components/base/notion-page-selector/search-input'
|
||||
import PageSelector from './page-selector'
|
||||
import type { DataSourceNotionPageMap, DataSourceNotionWorkspace } from '@/models/common'
|
||||
import Header from '../base/header'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { ssePost } from '@/service/base'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import Title from './title'
|
||||
import { useGetDataSourceAuth } from '@/service/use-datasource'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
|
||||
type OnlineDocumentsProps = {
|
||||
isInPipeline?: boolean
|
||||
nodeId: string
|
||||
nodeData: DataSourceNodeType
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const OnlineDocuments = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
isInPipeline = false,
|
||||
onCredentialChange,
|
||||
}: OnlineDocumentsProps) => {
|
||||
const docLink = useDocLink()
|
||||
const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
|
||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||
const {
|
||||
documentsData,
|
||||
searchValue,
|
||||
selectedPagesId,
|
||||
currentCredentialId,
|
||||
} = useDataSourceStoreWithSelector(useShallow(state => ({
|
||||
documentsData: state.documentsData,
|
||||
searchValue: state.searchValue,
|
||||
selectedPagesId: state.selectedPagesId,
|
||||
currentCredentialId: state.currentCredentialId,
|
||||
})))
|
||||
|
||||
const { data: dataSourceAuth } = useGetDataSourceAuth({
|
||||
pluginId: nodeData.plugin_id,
|
||||
provider: nodeData.provider_name,
|
||||
})
|
||||
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
const PagesMapAndSelectedPagesId: DataSourceNotionPageMap = useMemo(() => {
|
||||
const pagesMap = (documentsData || []).reduce((prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => {
|
||||
next.pages.forEach((page) => {
|
||||
prev[page.page_id] = {
|
||||
...page,
|
||||
workspace_id: next.workspace_id,
|
||||
}
|
||||
})
|
||||
|
||||
return prev
|
||||
}, {})
|
||||
return pagesMap
|
||||
}, [documentsData])
|
||||
|
||||
const datasourceNodeRunURL = !isInPipeline
|
||||
? `/rag/pipelines/${pipelineId}/workflows/published/datasource/nodes/${nodeId}/run`
|
||||
: `/rag/pipelines/${pipelineId}/workflows/draft/datasource/nodes/${nodeId}/run`
|
||||
|
||||
const getOnlineDocuments = useCallback(async () => {
|
||||
const { currentCredentialId } = dataSourceStore.getState()
|
||||
ssePost(
|
||||
datasourceNodeRunURL,
|
||||
{
|
||||
body: {
|
||||
inputs: {},
|
||||
credential_id: currentCredentialId,
|
||||
datasource_type: DatasourceType.onlineDocument,
|
||||
},
|
||||
},
|
||||
{
|
||||
onDataSourceNodeCompleted: (documentsData: DataSourceNodeCompletedResponse) => {
|
||||
const { setDocumentsData } = dataSourceStore.getState()
|
||||
setDocumentsData(documentsData.data as DataSourceNotionWorkspace[])
|
||||
},
|
||||
onDataSourceNodeError: (error: DataSourceNodeErrorResponse) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error.error,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [dataSourceStore, datasourceNodeRunURL])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentCredentialId) return
|
||||
getOnlineDocuments()
|
||||
}, [currentCredentialId])
|
||||
|
||||
const handleSearchValueChange = useCallback((value: string) => {
|
||||
const { setSearchValue } = dataSourceStore.getState()
|
||||
setSearchValue(value)
|
||||
}, [dataSourceStore])
|
||||
|
||||
const handleSelectPages = useCallback((newSelectedPagesId: Set<string>) => {
|
||||
const { setSelectedPagesId, setOnlineDocuments } = dataSourceStore.getState()
|
||||
const selectedPages = Array.from(newSelectedPagesId).map(pageId => PagesMapAndSelectedPagesId[pageId])
|
||||
setSelectedPagesId(new Set(Array.from(newSelectedPagesId)))
|
||||
setOnlineDocuments(selectedPages)
|
||||
}, [dataSourceStore, PagesMapAndSelectedPagesId])
|
||||
|
||||
const handlePreviewPage = useCallback((previewPageId: string) => {
|
||||
const { setCurrentDocument } = dataSourceStore.getState()
|
||||
setCurrentDocument(PagesMapAndSelectedPagesId[previewPageId])
|
||||
}, [PagesMapAndSelectedPagesId, dataSourceStore])
|
||||
|
||||
const handleSetting = useCallback(() => {
|
||||
setShowAccountSettingModal({
|
||||
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
|
||||
})
|
||||
}, [setShowAccountSettingModal])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-y-2'>
|
||||
<Header
|
||||
docTitle='Docs'
|
||||
docLink={docLink('/guides/knowledge-base/knowledge-pipeline/authorize-data-source')}
|
||||
onClickConfiguration={handleSetting}
|
||||
pluginName={nodeData.datasource_label}
|
||||
currentCredentialId={currentCredentialId}
|
||||
onCredentialChange={onCredentialChange}
|
||||
credentials={dataSourceAuth?.result || []}
|
||||
/>
|
||||
<div className='rounded-xl border border-components-panel-border bg-background-default-subtle'>
|
||||
<div className='flex items-center gap-x-2 rounded-t-xl border-b border-b-divider-regular bg-components-panel-bg p-1 pl-3'>
|
||||
<div className='flex grow items-center'>
|
||||
<Title name={nodeData.datasource_label} />
|
||||
</div>
|
||||
<SearchInput
|
||||
value={searchValue}
|
||||
onChange={handleSearchValueChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='overflow-hidden rounded-b-xl'>
|
||||
{documentsData?.length ? (
|
||||
<PageSelector
|
||||
checkedIds={selectedPagesId}
|
||||
disabledValue={new Set()}
|
||||
searchValue={searchValue}
|
||||
list={documentsData[0].pages || []}
|
||||
pagesMap={PagesMapAndSelectedPagesId}
|
||||
onSelect={handleSelectPages}
|
||||
canPreview={!isInPipeline}
|
||||
onPreview={handlePreviewPage}
|
||||
isMultipleChoice={!isInPipeline}
|
||||
currentCredentialId={currentCredentialId}
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[296px] items-center justify-center'>
|
||||
<Loading type='app' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnlineDocuments
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FixedSizeList as List } from 'react-window'
|
||||
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
|
||||
import Item from './item'
|
||||
import { recursivePushInParentDescendants } from './utils'
|
||||
|
||||
type PageSelectorProps = {
|
||||
checkedIds: Set<string>
|
||||
disabledValue: Set<string>
|
||||
searchValue: string
|
||||
pagesMap: DataSourceNotionPageMap
|
||||
list: DataSourceNotionPage[]
|
||||
onSelect: (selectedPagesId: Set<string>) => void
|
||||
canPreview?: boolean
|
||||
onPreview?: (selectedPageId: string) => void
|
||||
isMultipleChoice?: boolean
|
||||
currentCredentialId: string
|
||||
}
|
||||
|
||||
export type NotionPageTreeItem = {
|
||||
children: Set<string>
|
||||
descendants: Set<string>
|
||||
depth: number
|
||||
ancestors: string[]
|
||||
} & DataSourceNotionPage
|
||||
|
||||
export type NotionPageTreeMap = Record<string, NotionPageTreeItem>
|
||||
|
||||
type NotionPageItem = {
|
||||
expand: boolean
|
||||
depth: number
|
||||
} & DataSourceNotionPage
|
||||
|
||||
const PageSelector = ({
|
||||
checkedIds,
|
||||
disabledValue,
|
||||
searchValue,
|
||||
pagesMap,
|
||||
list,
|
||||
onSelect,
|
||||
canPreview = true,
|
||||
onPreview,
|
||||
isMultipleChoice = true,
|
||||
currentCredentialId,
|
||||
}: PageSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [dataList, setDataList] = useState<NotionPageItem[]>([])
|
||||
const [currentPreviewPageId, setCurrentPreviewPageId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => {
|
||||
return {
|
||||
...item,
|
||||
expand: false,
|
||||
depth: 0,
|
||||
}
|
||||
}))
|
||||
}, [currentCredentialId])
|
||||
|
||||
const searchDataList = list.filter((item) => {
|
||||
return item.page_name.includes(searchValue)
|
||||
}).map((item) => {
|
||||
return {
|
||||
...item,
|
||||
expand: false,
|
||||
depth: 0,
|
||||
}
|
||||
})
|
||||
const currentDataList = searchValue ? searchDataList : dataList
|
||||
|
||||
const listMapWithChildrenAndDescendants = useMemo(() => {
|
||||
return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => {
|
||||
const pageId = next.page_id
|
||||
if (!prev[pageId])
|
||||
prev[pageId] = { ...next, children: new Set(), descendants: new Set(), depth: 0, ancestors: [] }
|
||||
|
||||
recursivePushInParentDescendants(pagesMap, prev, prev[pageId], prev[pageId])
|
||||
return prev
|
||||
}, {})
|
||||
}, [list, pagesMap])
|
||||
|
||||
const handleToggle = useCallback((index: number) => {
|
||||
const current = dataList[index]
|
||||
const pageId = current.page_id
|
||||
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
|
||||
const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants)
|
||||
const childrenIds = Array.from(currentWithChildrenAndDescendants.children)
|
||||
let newDataList = []
|
||||
|
||||
if (current.expand) {
|
||||
current.expand = false
|
||||
|
||||
newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id))
|
||||
}
|
||||
else {
|
||||
current.expand = true
|
||||
|
||||
newDataList = [
|
||||
...dataList.slice(0, index + 1),
|
||||
...childrenIds.map(item => ({
|
||||
...pagesMap[item],
|
||||
expand: false,
|
||||
depth: listMapWithChildrenAndDescendants[item].depth,
|
||||
})),
|
||||
...dataList.slice(index + 1),
|
||||
]
|
||||
}
|
||||
setDataList(newDataList)
|
||||
}, [dataList, listMapWithChildrenAndDescendants, pagesMap])
|
||||
|
||||
const handleCheck = useCallback((index: number) => {
|
||||
const copyValue = new Set(checkedIds)
|
||||
const current = currentDataList[index]
|
||||
const pageId = current.page_id
|
||||
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
|
||||
|
||||
if (copyValue.has(pageId)) {
|
||||
if (!searchValue && isMultipleChoice) {
|
||||
for (const item of currentWithChildrenAndDescendants.descendants)
|
||||
copyValue.delete(item)
|
||||
}
|
||||
|
||||
copyValue.delete(pageId)
|
||||
}
|
||||
else {
|
||||
if (!searchValue && isMultipleChoice) {
|
||||
for (const item of currentWithChildrenAndDescendants.descendants)
|
||||
copyValue.add(item)
|
||||
}
|
||||
// Single choice mode, clear previous selection
|
||||
if (!isMultipleChoice && copyValue.size > 0) {
|
||||
copyValue.clear()
|
||||
copyValue.add(pageId)
|
||||
}
|
||||
else {
|
||||
copyValue.add(pageId)
|
||||
}
|
||||
}
|
||||
|
||||
onSelect(new Set(copyValue))
|
||||
}, [currentDataList, isMultipleChoice, listMapWithChildrenAndDescendants, onSelect, searchValue, checkedIds])
|
||||
|
||||
const handlePreview = useCallback((index: number) => {
|
||||
const current = currentDataList[index]
|
||||
const pageId = current.page_id
|
||||
|
||||
setCurrentPreviewPageId(pageId)
|
||||
|
||||
if (onPreview)
|
||||
onPreview(pageId)
|
||||
}, [currentDataList, onPreview])
|
||||
|
||||
if (!currentDataList.length) {
|
||||
return (
|
||||
<div className='flex h-[296px] items-center justify-center text-[13px] text-text-tertiary'>
|
||||
{t('common.dataSource.notion.selector.noSearchResult')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
className='py-2'
|
||||
height={296}
|
||||
itemCount={currentDataList.length}
|
||||
itemSize={28}
|
||||
width='100%'
|
||||
itemKey={(index, data) => data.dataList[index].page_id}
|
||||
itemData={{
|
||||
dataList: currentDataList,
|
||||
handleToggle,
|
||||
checkedIds,
|
||||
disabledCheckedIds: disabledValue,
|
||||
handleCheck,
|
||||
canPreview,
|
||||
handlePreview,
|
||||
listMapWithChildrenAndDescendants,
|
||||
searchValue,
|
||||
previewPageId: currentPreviewPageId,
|
||||
pagesMap,
|
||||
isMultipleChoice,
|
||||
}}
|
||||
>
|
||||
{Item}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageSelector
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { areEqual } from 'react-window'
|
||||
import type { ListChildComponentProps } from 'react-window'
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
|
||||
type NotionPageTreeItem = {
|
||||
children: Set<string>
|
||||
descendants: Set<string>
|
||||
depth: number
|
||||
ancestors: string[]
|
||||
} & DataSourceNotionPage
|
||||
|
||||
type NotionPageTreeMap = Record<string, NotionPageTreeItem>
|
||||
|
||||
type NotionPageItem = {
|
||||
expand: boolean
|
||||
depth: number
|
||||
} & DataSourceNotionPage
|
||||
|
||||
const Item = ({ index, style, data }: ListChildComponentProps<{
|
||||
dataList: NotionPageItem[]
|
||||
handleToggle: (index: number) => void
|
||||
checkedIds: Set<string>
|
||||
disabledCheckedIds: Set<string>
|
||||
handleCheck: (index: number) => void
|
||||
canPreview?: boolean
|
||||
handlePreview: (index: number) => void
|
||||
listMapWithChildrenAndDescendants: NotionPageTreeMap
|
||||
searchValue: string
|
||||
previewPageId: string
|
||||
pagesMap: DataSourceNotionPageMap
|
||||
isMultipleChoice?: boolean
|
||||
}>) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
dataList,
|
||||
handleToggle,
|
||||
checkedIds,
|
||||
disabledCheckedIds,
|
||||
handleCheck,
|
||||
canPreview,
|
||||
handlePreview,
|
||||
listMapWithChildrenAndDescendants,
|
||||
searchValue,
|
||||
previewPageId,
|
||||
pagesMap,
|
||||
isMultipleChoice,
|
||||
} = data
|
||||
const current = dataList[index]
|
||||
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
|
||||
const hasChild = currentWithChildrenAndDescendants.descendants.size > 0
|
||||
const ancestors = currentWithChildrenAndDescendants.ancestors
|
||||
const breadCrumbs = ancestors.length ? [...ancestors, current.page_name] : [current.page_name]
|
||||
const disabled = disabledCheckedIds.has(current.page_id)
|
||||
|
||||
const renderArrow = () => {
|
||||
if (hasChild) {
|
||||
return (
|
||||
<div
|
||||
className='mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover'
|
||||
style={{ marginLeft: current.depth * 8 }}
|
||||
onClick={() => handleToggle(index)}
|
||||
>
|
||||
{
|
||||
current.expand
|
||||
? <RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
|
||||
: <RiArrowRightSLine className='h-4 w-4 text-text-tertiary' />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (current.parent_id === 'root' || !pagesMap[current.parent_id]) {
|
||||
return (
|
||||
<div></div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className='mr-1 h-5 w-5 shrink-0' style={{ marginLeft: current.depth * 8 }} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group flex cursor-pointer items-center rounded-md pl-2 pr-[2px] hover:bg-state-base-hover',
|
||||
previewPageId === current.page_id && 'bg-state-base-hover')}
|
||||
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
|
||||
>
|
||||
{isMultipleChoice ? (
|
||||
<Checkbox
|
||||
className='mr-2 shrink-0'
|
||||
checked={checkedIds.has(current.page_id)}
|
||||
disabled={disabled}
|
||||
onCheck={() => {
|
||||
handleCheck(index)
|
||||
}}
|
||||
/>) : (
|
||||
<Radio
|
||||
className='mr-2 shrink-0'
|
||||
isChecked={checkedIds.has(current.page_id)}
|
||||
disabled={disabled}
|
||||
onCheck={() => {
|
||||
handleCheck(index)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!searchValue && renderArrow()}
|
||||
<NotionIcon
|
||||
className='mr-1 shrink-0'
|
||||
type='page'
|
||||
src={current.page_icon}
|
||||
/>
|
||||
<div
|
||||
className='grow truncate text-[13px] font-medium leading-4 text-text-secondary'
|
||||
title={current.page_name}
|
||||
>
|
||||
{current.page_name}
|
||||
</div>
|
||||
{
|
||||
canPreview && (
|
||||
<div
|
||||
className='ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs
|
||||
font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
|
||||
hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex'
|
||||
onClick={() => handlePreview(index)}>
|
||||
{t('common.dataSource.notion.selector.preview')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
searchValue && (
|
||||
<div
|
||||
className='ml-1 max-w-[120px] shrink-0 truncate text-xs text-text-quaternary'
|
||||
title={breadCrumbs.join(' / ')}
|
||||
>
|
||||
{breadCrumbs.join(' / ')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Item, areEqual)
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { DataSourceNotionPageMap } from '@/models/common'
|
||||
import type { NotionPageTreeItem, NotionPageTreeMap } from './index'
|
||||
|
||||
export const recursivePushInParentDescendants = (
|
||||
pagesMap: DataSourceNotionPageMap,
|
||||
listTreeMap: NotionPageTreeMap,
|
||||
current: NotionPageTreeItem,
|
||||
leafItem: NotionPageTreeItem,
|
||||
) => {
|
||||
const parentId = current.parent_id
|
||||
const pageId = current.page_id
|
||||
|
||||
if (!parentId || !pageId)
|
||||
return
|
||||
|
||||
if (parentId !== 'root' && pagesMap[parentId]) {
|
||||
if (!listTreeMap[parentId]) {
|
||||
const children = new Set([pageId])
|
||||
const descendants = new Set([pageId, leafItem.page_id])
|
||||
listTreeMap[parentId] = {
|
||||
...pagesMap[parentId],
|
||||
children,
|
||||
descendants,
|
||||
depth: 0,
|
||||
ancestors: [],
|
||||
}
|
||||
}
|
||||
else {
|
||||
listTreeMap[parentId].children.add(pageId)
|
||||
listTreeMap[parentId].descendants.add(pageId)
|
||||
listTreeMap[parentId].descendants.add(leafItem.page_id)
|
||||
}
|
||||
leafItem.depth++
|
||||
leafItem.ancestors.unshift(listTreeMap[parentId].page_name)
|
||||
|
||||
if (listTreeMap[parentId].parent_id !== 'root')
|
||||
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap[parentId], leafItem)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type TitleProps = {
|
||||
name: string
|
||||
}
|
||||
|
||||
const Title = ({
|
||||
name,
|
||||
}: TitleProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='system-sm-medium px-[5px] py-1 text-text-secondary'>
|
||||
{t('datasetPipeline.onlineDocument.pageSelectorTitle', { name })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Title)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { useToolIcon } from '@/app/components/workflow/hooks'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
type ConnectProps = {
|
||||
nodeData: DataSourceNodeType
|
||||
onSetting: () => void
|
||||
}
|
||||
|
||||
const Connect = ({
|
||||
nodeData,
|
||||
onSetting,
|
||||
}: ConnectProps) => {
|
||||
const { t } = useTranslation()
|
||||
const toolIcon = useToolIcon(nodeData)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-start gap-y-2 rounded-xl bg-workflow-process-bg p-6'>
|
||||
<div className='flex size-12 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg shadow-shadow-shadow-5'>
|
||||
<BlockIcon
|
||||
type={BlockEnum.DataSource}
|
||||
toolIcon={toolIcon}
|
||||
size='md'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-1'>
|
||||
<div className='flex flex-col gap-y-1 pb-3 pt-1'>
|
||||
<div className='system-md-semibold text-text-secondary'>
|
||||
<span className='relative'>
|
||||
{t('datasetPipeline.onlineDrive.notConnected', { name: nodeData.title })}
|
||||
<Icon3Dots className='absolute -right-2.5 -top-1.5 size-4 text-text-secondary' />
|
||||
</span>
|
||||
</div>
|
||||
<div className='system-sm-regular text-text-tertiary'>
|
||||
{t('datasetPipeline.onlineDrive.notConnectedTip', { name: nodeData.title })}
|
||||
</div>
|
||||
</div>
|
||||
<Button className='w-fit' variant='primary' onClick={onSetting}>
|
||||
{t('datasetCreation.stepOne.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Connect
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { BucketsGray } from '@/app/components/base/icons/src/public/knowledge/online-drive'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type BucketProps = {
|
||||
bucketName: string
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
showSeparator?: boolean
|
||||
handleBackToBucketList: () => void
|
||||
handleClickBucketName: () => void
|
||||
}
|
||||
|
||||
const Bucket = ({
|
||||
bucketName,
|
||||
handleBackToBucketList,
|
||||
handleClickBucketName,
|
||||
disabled = false,
|
||||
isActive = false,
|
||||
showSeparator = true,
|
||||
}: BucketProps) => {
|
||||
const { t } = useTranslation()
|
||||
const handleClickItem = useCallback(() => {
|
||||
if (!disabled)
|
||||
handleClickBucketName()
|
||||
}, [disabled, handleClickBucketName])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
popupContent={t('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover'
|
||||
onClick={handleBackToBucketList}
|
||||
>
|
||||
<BucketsGray />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span className='system-xs-regular text-divider-deep'>/</span>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'max-w-full shrink truncate rounded-md px-[5px] py-1',
|
||||
isActive ? 'system-sm-medium text-text-secondary' : 'system-sm-regular text-text-tertiary',
|
||||
!disabled && 'hover:bg-state-base-hover',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClickItem}
|
||||
title={bucketName}
|
||||
>
|
||||
{bucketName}
|
||||
</button>
|
||||
{showSeparator && <span className='system-xs-regular shrink-0 text-divider-deep'>/</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Bucket)
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type DriveProps = {
|
||||
breadcrumbs: string[]
|
||||
handleBackToRoot: () => void
|
||||
}
|
||||
|
||||
const Drive = ({
|
||||
breadcrumbs,
|
||||
handleBackToRoot,
|
||||
}: DriveProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'max-w-full shrink truncate rounded-md px-[5px] py-1',
|
||||
breadcrumbs.length > 0 && 'system-sm-regular text-text-tertiary hover:bg-state-base-hover',
|
||||
breadcrumbs.length === 0 && 'system-sm-medium text-text-secondary',
|
||||
)}
|
||||
onClick={handleBackToRoot}
|
||||
disabled={breadcrumbs.length === 0}
|
||||
>
|
||||
{t('datasetPipeline.onlineDrive.breadcrumbs.allFiles')}
|
||||
</button>
|
||||
{breadcrumbs.length > 0 && <span className='system-xs-regular text-divider-deep'>/</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Drive)
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Menu from './menu'
|
||||
|
||||
type DropdownProps = {
|
||||
startIndex: number
|
||||
breadcrumbs: string[]
|
||||
onBreadcrumbClick: (index: number) => void
|
||||
}
|
||||
|
||||
const Dropdown = ({
|
||||
startIndex,
|
||||
breadcrumbs,
|
||||
onBreadcrumbClick,
|
||||
}: DropdownProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const handleBreadCrumbClick = useCallback((index: number) => {
|
||||
onBreadcrumbClick(index)
|
||||
setOpen(false)
|
||||
}, [onBreadcrumbClick])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -13,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex size-6 items-center justify-center rounded-md',
|
||||
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<RiMoreFill className='size-4 text-text-tertiary' />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[11]'>
|
||||
<Menu
|
||||
breadcrumbs={breadcrumbs}
|
||||
startIndex={startIndex}
|
||||
onBreadcrumbClick={handleBreadCrumbClick}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
<span className='system-xs-regular text-divider-deep'>/</span>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Dropdown)
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
type ItemProps = {
|
||||
name: string
|
||||
index: number
|
||||
onBreadcrumbClick: (index: number) => void
|
||||
}
|
||||
|
||||
const Item = ({
|
||||
name,
|
||||
index,
|
||||
onBreadcrumbClick,
|
||||
}: ItemProps) => {
|
||||
const handleClick = useCallback(() => {
|
||||
onBreadcrumbClick(index)
|
||||
}, [index, onBreadcrumbClick])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='system-md-regular rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'
|
||||
onClick={handleClick}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Item)
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import Item from './item'
|
||||
|
||||
type MenuProps = {
|
||||
breadcrumbs: string[]
|
||||
startIndex: number
|
||||
onBreadcrumbClick: (index: number) => void
|
||||
}
|
||||
|
||||
const Menu = ({
|
||||
breadcrumbs,
|
||||
startIndex,
|
||||
onBreadcrumbClick,
|
||||
}: MenuProps) => {
|
||||
return (
|
||||
<div className='flex w-[136px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
|
||||
{breadcrumbs.map((breadcrumb, index) => {
|
||||
return (
|
||||
<Item
|
||||
key={`${breadcrumb}-${index}`}
|
||||
name={breadcrumb}
|
||||
index={startIndex + index}
|
||||
onBreadcrumbClick={onBreadcrumbClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Menu)
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../../../../store'
|
||||
import Bucket from './bucket'
|
||||
import BreadcrumbItem from './item'
|
||||
import Dropdown from './dropdown'
|
||||
import Drive from './drive'
|
||||
|
||||
type BreadcrumbsProps = {
|
||||
breadcrumbs: string[]
|
||||
keywords: string
|
||||
bucket: string
|
||||
searchResultsLength: number
|
||||
isInPipeline: boolean
|
||||
}
|
||||
|
||||
const Breadcrumbs = ({
|
||||
breadcrumbs,
|
||||
keywords,
|
||||
bucket,
|
||||
searchResultsLength,
|
||||
isInPipeline,
|
||||
}: BreadcrumbsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
const hasBucket = useDataSourceStoreWithSelector(s => s.hasBucket)
|
||||
const showSearchResult = !!keywords && searchResultsLength > 0
|
||||
const showBucketListTitle = breadcrumbs.length === 0 && hasBucket && bucket === ''
|
||||
|
||||
const displayBreadcrumbNum = useMemo(() => {
|
||||
const num = isInPipeline ? 2 : 3
|
||||
return bucket ? num - 1 : num
|
||||
}, [isInPipeline, bucket])
|
||||
|
||||
const breadcrumbsConfig = useMemo(() => {
|
||||
const prefixToDisplay = breadcrumbs.slice(0, displayBreadcrumbNum - 1)
|
||||
const collapsedBreadcrumbs = breadcrumbs.slice(displayBreadcrumbNum - 1, breadcrumbs.length - 1)
|
||||
return {
|
||||
original: breadcrumbs,
|
||||
needCollapsed: breadcrumbs.length > displayBreadcrumbNum,
|
||||
prefixBreadcrumbs: prefixToDisplay,
|
||||
collapsedBreadcrumbs,
|
||||
lastBreadcrumb: breadcrumbs[breadcrumbs.length - 1],
|
||||
}
|
||||
}, [displayBreadcrumbNum, breadcrumbs])
|
||||
|
||||
const handleBackToBucketList = useCallback(() => {
|
||||
const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix, setBucket } = dataSourceStore.getState()
|
||||
setOnlineDriveFileList([])
|
||||
setSelectedFileIds([])
|
||||
setBucket('')
|
||||
setBreadcrumbs([])
|
||||
setPrefix([])
|
||||
}, [dataSourceStore])
|
||||
|
||||
const handleClickBucketName = useCallback(() => {
|
||||
const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = dataSourceStore.getState()
|
||||
setOnlineDriveFileList([])
|
||||
setSelectedFileIds([])
|
||||
setBreadcrumbs([])
|
||||
setPrefix([])
|
||||
}, [dataSourceStore])
|
||||
|
||||
const handleBackToRoot = useCallback(() => {
|
||||
const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = dataSourceStore.getState()
|
||||
setOnlineDriveFileList([])
|
||||
setSelectedFileIds([])
|
||||
setBreadcrumbs([])
|
||||
setPrefix([])
|
||||
}, [dataSourceStore])
|
||||
|
||||
const handleClickBreadcrumb = useCallback((index: number) => {
|
||||
const { breadcrumbs, prefix, setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = dataSourceStore.getState()
|
||||
const newBreadcrumbs = breadcrumbs.slice(0, index + 1)
|
||||
const newPrefix = prefix.slice(0, index + 1)
|
||||
setOnlineDriveFileList([])
|
||||
setSelectedFileIds([])
|
||||
setBreadcrumbs(newBreadcrumbs)
|
||||
setPrefix(newPrefix)
|
||||
}, [dataSourceStore])
|
||||
|
||||
return (
|
||||
<div className='flex grow items-center overflow-hidden'>
|
||||
{showSearchResult && (
|
||||
<div className='system-sm-medium text-test-secondary px-[5px]'>
|
||||
{t('datasetPipeline.onlineDrive.breadcrumbs.searchResult', {
|
||||
searchResultsLength,
|
||||
folderName: breadcrumbs.length > 0 ? breadcrumbs[breadcrumbs.length - 1] : bucket,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!showSearchResult && showBucketListTitle && (
|
||||
<div className='system-sm-medium text-test-secondary px-[5px]'>
|
||||
{t('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')}
|
||||
</div>
|
||||
)}
|
||||
{!showSearchResult && !showBucketListTitle && (
|
||||
<div className='flex w-full items-center gap-x-0.5 overflow-hidden'>
|
||||
{hasBucket && bucket && (
|
||||
<Bucket
|
||||
bucketName={bucket}
|
||||
handleBackToBucketList={handleBackToBucketList}
|
||||
handleClickBucketName={handleClickBucketName}
|
||||
isActive={breadcrumbs.length === 0}
|
||||
disabled={breadcrumbs.length === 0}
|
||||
showSeparator={breadcrumbs.length > 0}
|
||||
/>
|
||||
)}
|
||||
{!hasBucket && (
|
||||
<Drive
|
||||
breadcrumbs={breadcrumbs}
|
||||
handleBackToRoot={handleBackToRoot}
|
||||
/>
|
||||
)}
|
||||
{!breadcrumbsConfig.needCollapsed && (
|
||||
<>
|
||||
{breadcrumbsConfig.original.map((breadcrumb, index) => {
|
||||
const isLast = index === breadcrumbsConfig.original.length - 1
|
||||
return (
|
||||
<BreadcrumbItem
|
||||
key={`${breadcrumb}-${index}`}
|
||||
index={index}
|
||||
handleClick={handleClickBreadcrumb}
|
||||
name={breadcrumb}
|
||||
isActive={isLast}
|
||||
showSeparator={!isLast}
|
||||
disabled={isLast}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{breadcrumbsConfig.needCollapsed && (
|
||||
<>
|
||||
{breadcrumbsConfig.prefixBreadcrumbs.map((breadcrumb, index) => {
|
||||
return (
|
||||
<BreadcrumbItem
|
||||
key={`${breadcrumb}-${index}`}
|
||||
index={index}
|
||||
handleClick={handleClickBreadcrumb}
|
||||
name={breadcrumb}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Dropdown
|
||||
startIndex={breadcrumbsConfig.prefixBreadcrumbs.length}
|
||||
breadcrumbs={breadcrumbsConfig.collapsedBreadcrumbs}
|
||||
onBreadcrumbClick={handleClickBreadcrumb}
|
||||
/>
|
||||
<BreadcrumbItem
|
||||
index={breadcrumbs.length - 1}
|
||||
handleClick={handleClickBreadcrumb}
|
||||
name={breadcrumbsConfig.lastBreadcrumb}
|
||||
isActive={true}
|
||||
disabled={true}
|
||||
showSeparator={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Breadcrumbs)
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type BreadcrumbItemProps = {
|
||||
name: string
|
||||
index: number
|
||||
handleClick: (index: number) => void
|
||||
disabled?: boolean
|
||||
isActive?: boolean
|
||||
showSeparator?: boolean
|
||||
}
|
||||
|
||||
const BreadcrumbItem = ({
|
||||
name,
|
||||
index,
|
||||
handleClick,
|
||||
disabled = false,
|
||||
isActive = false,
|
||||
showSeparator = true,
|
||||
}: BreadcrumbItemProps) => {
|
||||
const handleClickItem = useCallback(() => {
|
||||
if (!disabled)
|
||||
handleClick(index)
|
||||
}, [disabled, handleClick, index])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'max-w-full shrink truncate rounded-md px-[5px] py-1',
|
||||
isActive ? 'system-sm-medium text-text-secondary' : 'system-sm-regular text-text-tertiary',
|
||||
!disabled && 'hover:bg-state-base-hover',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClickItem}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
{showSeparator && <span className='system-xs-regular shrink-0 text-divider-deep'>/</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
BreadcrumbItem.displayName = 'BreadcrumbItem'
|
||||
|
||||
export default React.memo(BreadcrumbItem)
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
import Breadcrumbs from './breadcrumbs'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type HeaderProps = {
|
||||
breadcrumbs: string[]
|
||||
inputValue: string
|
||||
keywords: string
|
||||
bucket: string
|
||||
searchResultsLength: number
|
||||
handleInputChange: React.ChangeEventHandler<HTMLInputElement>
|
||||
handleResetKeywords: () => void
|
||||
isInPipeline: boolean
|
||||
}
|
||||
|
||||
const Header = ({
|
||||
breadcrumbs,
|
||||
inputValue,
|
||||
keywords,
|
||||
bucket,
|
||||
isInPipeline,
|
||||
searchResultsLength,
|
||||
handleInputChange,
|
||||
handleResetKeywords,
|
||||
}: HeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-2 bg-components-panel-bg p-1 pl-3'>
|
||||
<Breadcrumbs
|
||||
breadcrumbs={breadcrumbs}
|
||||
keywords={keywords}
|
||||
bucket={bucket}
|
||||
searchResultsLength={searchResultsLength}
|
||||
isInPipeline={isInPipeline}
|
||||
/>
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onClear={handleResetKeywords}
|
||||
placeholder={t('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')}
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName='w-[200px] h-8 shrink-0'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import Header from './header'
|
||||
import List from './list'
|
||||
import { useState } from 'react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
|
||||
type FileListProps = {
|
||||
fileList: OnlineDriveFile[]
|
||||
selectedFileIds: string[]
|
||||
breadcrumbs: string[]
|
||||
keywords: string
|
||||
bucket: string
|
||||
isInPipeline: boolean
|
||||
resetKeywords: () => void
|
||||
updateKeywords: (keywords: string) => void
|
||||
searchResultsLength: number
|
||||
handleSelectFile: (file: OnlineDriveFile) => void
|
||||
handleOpenFolder: (file: OnlineDriveFile) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const FileList = ({
|
||||
fileList,
|
||||
selectedFileIds,
|
||||
breadcrumbs,
|
||||
keywords,
|
||||
bucket,
|
||||
resetKeywords,
|
||||
updateKeywords,
|
||||
searchResultsLength,
|
||||
handleSelectFile,
|
||||
handleOpenFolder,
|
||||
isInPipeline,
|
||||
isLoading,
|
||||
}: FileListProps) => {
|
||||
const [inputValue, setInputValue] = useState(keywords)
|
||||
|
||||
const { run: updateKeywordsWithDebounce } = useDebounceFn(
|
||||
(keywords: string) => {
|
||||
updateKeywords(keywords)
|
||||
},
|
||||
{ wait: 500 },
|
||||
)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const keywords = e.target.value
|
||||
setInputValue(keywords)
|
||||
updateKeywordsWithDebounce(keywords)
|
||||
}
|
||||
|
||||
const handleResetKeywords = () => {
|
||||
setInputValue('')
|
||||
resetKeywords()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-[400px] flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3'>
|
||||
<Header
|
||||
breadcrumbs={breadcrumbs}
|
||||
inputValue={inputValue}
|
||||
keywords={keywords}
|
||||
bucket={bucket}
|
||||
isInPipeline={isInPipeline}
|
||||
handleInputChange={handleInputChange}
|
||||
searchResultsLength={searchResultsLength}
|
||||
handleResetKeywords={handleResetKeywords}
|
||||
/>
|
||||
<List
|
||||
fileList={fileList}
|
||||
selectedFileIds={selectedFileIds}
|
||||
keywords={keywords}
|
||||
handleResetKeywords={handleResetKeywords}
|
||||
handleOpenFolder={handleOpenFolder}
|
||||
handleSelectFile={handleSelectFile}
|
||||
isInPipeline={isInPipeline}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileList
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const EmptyFolder = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex size-full items-center justify-center rounded-[10px] bg-background-section px-1 py-1.5'>
|
||||
<span className='system-xs-regular text-text-tertiary'>{t('datasetPipeline.onlineDrive.emptyFolder')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(EmptyFolder)
|
||||
@@ -0,0 +1,35 @@
|
||||
import Button from '@/app/components/base/button'
|
||||
import { SearchMenu } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type EmptySearchResultProps = {
|
||||
onResetKeywords: () => void
|
||||
}
|
||||
|
||||
const EmptySearchResult = ({
|
||||
onResetKeywords,
|
||||
}: EmptySearchResultProps & {
|
||||
className?: string
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex size-full flex-col items-center justify-center gap-y-2 rounded-[10px] bg-background-section p-6'>
|
||||
<SearchMenu className='size-8 text-text-tertiary' />
|
||||
<div className='system-sm-regular text-text-secondary'>
|
||||
{t('datasetPipeline.onlineDrive.emptySearchResult')}
|
||||
</div>
|
||||
<Button
|
||||
variant='secondary-accent'
|
||||
size='small'
|
||||
onClick={onResetKeywords}
|
||||
className='px-1.5'
|
||||
>
|
||||
<span className='px-[3px]'>{t('datasetPipeline.onlineDrive.resetKeywords')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(EmptySearchResult)
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { OnlineDriveFileType } from '@/models/pipeline'
|
||||
import { BucketsBlue, Folder } from '@/app/components/base/icons/src/public/knowledge/online-drive'
|
||||
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
|
||||
import { getFileType } from './utils'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type FileIconProps = {
|
||||
type: OnlineDriveFileType
|
||||
fileName: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const FileIcon = ({
|
||||
type,
|
||||
fileName,
|
||||
size = 'md',
|
||||
className,
|
||||
}: FileIconProps) => {
|
||||
const fileType = useMemo(() => {
|
||||
if (type === OnlineDriveFileType.bucket || type === OnlineDriveFileType.folder)
|
||||
return 'custom'
|
||||
|
||||
return getFileType(fileName)
|
||||
}, [type, fileName])
|
||||
|
||||
if (type === OnlineDriveFileType.bucket) {
|
||||
return (
|
||||
<BucketsBlue className={cn('size-[18px]', className)} />
|
||||
)
|
||||
}
|
||||
|
||||
if (type === OnlineDriveFileType.folder) {
|
||||
return (
|
||||
<Folder className={cn('size-[18px]', className)} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FileTypeIcon
|
||||
size={size}
|
||||
type={fileType}
|
||||
className={cn('size-[18px]', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileIcon)
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import Item from './item'
|
||||
import EmptyFolder from './empty-folder'
|
||||
import EmptySearchResult from './empty-search-result'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import { useDataSourceStore } from '../../../store'
|
||||
|
||||
type FileListProps = {
|
||||
fileList: OnlineDriveFile[]
|
||||
selectedFileIds: string[]
|
||||
keywords: string
|
||||
isInPipeline: boolean
|
||||
isLoading: boolean
|
||||
handleResetKeywords: () => void
|
||||
handleSelectFile: (file: OnlineDriveFile) => void
|
||||
handleOpenFolder: (file: OnlineDriveFile) => void
|
||||
}
|
||||
|
||||
const List = ({
|
||||
fileList,
|
||||
selectedFileIds,
|
||||
keywords,
|
||||
handleResetKeywords,
|
||||
handleSelectFile,
|
||||
handleOpenFolder,
|
||||
isInPipeline,
|
||||
isLoading,
|
||||
}: FileListProps) => {
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const observerRef = useRef<IntersectionObserver>(null)
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (anchorRef.current) {
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
const { setNextPageParameters, currentNextPageParametersRef, isTruncated } = dataSourceStore.getState()
|
||||
if (entries[0].isIntersecting && isTruncated.current && !isLoading)
|
||||
setNextPageParameters(currentNextPageParametersRef.current)
|
||||
}, {
|
||||
rootMargin: '100px',
|
||||
})
|
||||
observerRef.current.observe(anchorRef.current)
|
||||
}
|
||||
return () => observerRef.current?.disconnect()
|
||||
}, [anchorRef, isLoading, dataSourceStore])
|
||||
|
||||
const isAllLoading = isLoading && fileList.length === 0 && keywords.length === 0
|
||||
const isPartialLoading = isLoading && fileList.length > 0
|
||||
const isEmptyFolder = !isLoading && fileList.length === 0 && keywords.length === 0
|
||||
const isSearchResultEmpty = !isLoading && fileList.length === 0 && keywords.length > 0
|
||||
|
||||
return (
|
||||
<div className='grow overflow-hidden p-1 pt-0'>
|
||||
{
|
||||
isAllLoading && (
|
||||
<Loading type='app' />
|
||||
)
|
||||
}
|
||||
{
|
||||
isEmptyFolder && (
|
||||
<EmptyFolder />
|
||||
)
|
||||
}
|
||||
{
|
||||
isSearchResultEmpty && (
|
||||
<EmptySearchResult onResetKeywords={handleResetKeywords} />
|
||||
)
|
||||
}
|
||||
{fileList.length > 0 && (
|
||||
<div className='flex h-full flex-col gap-y-px overflow-y-auto rounded-[10px] bg-background-section px-1 py-1.5'>
|
||||
{
|
||||
fileList.map((file) => {
|
||||
const isSelected = selectedFileIds.includes(file.id)
|
||||
return (
|
||||
<Item
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelectFile}
|
||||
onOpen={handleOpenFolder}
|
||||
isMultipleChoice={!isInPipeline}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
{
|
||||
isPartialLoading && (
|
||||
<div className='flex items-center justify-center py-2'>
|
||||
<RiLoader2Line className='animation-spin size-4 text-text-tertiary' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div ref={anchorRef} className='h-0' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(List)
|
||||
@@ -0,0 +1,103 @@
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import React, { useCallback } from 'react'
|
||||
import FileIcon from './file-icon'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Placement } from '@floating-ui/react'
|
||||
|
||||
type ItemProps = {
|
||||
file: OnlineDriveFile
|
||||
isSelected: boolean
|
||||
disabled?: boolean
|
||||
isMultipleChoice?: boolean
|
||||
onSelect: (file: OnlineDriveFile) => void
|
||||
onOpen: (file: OnlineDriveFile) => void
|
||||
}
|
||||
|
||||
const Item = ({
|
||||
file,
|
||||
isSelected,
|
||||
disabled = false,
|
||||
isMultipleChoice = true,
|
||||
onSelect,
|
||||
onOpen,
|
||||
}: ItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { id, name, type, size } = file
|
||||
|
||||
const isBucket = type === 'bucket'
|
||||
const isFolder = type === 'folder'
|
||||
|
||||
const Wrapper = disabled ? Tooltip : React.Fragment
|
||||
const wrapperProps = disabled ? {
|
||||
popupContent: t('datasetPipeline.onlineDrive.notSupportedFileType'),
|
||||
position: 'top-end' as Placement,
|
||||
offset: { mainAxis: 4, crossAxis: -104 },
|
||||
} : {}
|
||||
|
||||
const handleSelect = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
onSelect(file)
|
||||
}, [file, onSelect])
|
||||
|
||||
const handleClickItem = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
if (isBucket || isFolder) {
|
||||
onOpen(file)
|
||||
return
|
||||
}
|
||||
onSelect(file)
|
||||
}, [disabled, file, isBucket, isFolder, onOpen, onSelect])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[3px] hover:bg-state-base-hover'
|
||||
onClick={handleClickItem}
|
||||
>
|
||||
{!isBucket && isMultipleChoice && (
|
||||
<Checkbox
|
||||
className='shrink-0'
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
checked={isSelected}
|
||||
onCheck={handleSelect}
|
||||
/>
|
||||
)}
|
||||
{!isBucket && !isMultipleChoice && (
|
||||
<Radio
|
||||
className='shrink-0'
|
||||
disabled={disabled}
|
||||
isChecked={isSelected}
|
||||
onCheck={handleSelect}
|
||||
/>
|
||||
)}
|
||||
<Wrapper
|
||||
{...wrapperProps}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex grow items-center gap-x-1 overflow-hidden py-0.5',
|
||||
disabled && 'opacity-30',
|
||||
)}>
|
||||
<FileIcon type={type} fileName={name} className='shrink-0 transform-gpu' />
|
||||
<span
|
||||
className='system-sm-medium grow truncate text-text-secondary'
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{!isFolder && typeof size === 'number' && (
|
||||
<span className='system-xs-regular shrink-0 text-text-tertiary'>{formatFileSize(size)}</span>
|
||||
)}
|
||||
</div>
|
||||
</Wrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Item)
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
|
||||
export const getFileExtension = (fileName: string): string => {
|
||||
if (!fileName)
|
||||
return ''
|
||||
const parts = fileName.split('.')
|
||||
if (parts.length <= 1 || (parts[0] === '' && parts.length === 2))
|
||||
return ''
|
||||
|
||||
return parts[parts.length - 1].toLowerCase()
|
||||
}
|
||||
|
||||
export const getFileType = (fileName: string) => {
|
||||
const extension = getFileExtension(fileName)
|
||||
|
||||
if (extension === 'gif')
|
||||
return FileAppearanceTypeEnum.gif
|
||||
|
||||
if (FILE_EXTS.image.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.image
|
||||
|
||||
if (FILE_EXTS.video.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.video
|
||||
|
||||
if (FILE_EXTS.audio.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.audio
|
||||
|
||||
if (extension === 'html' || extension === 'htm' || extension === 'xml' || extension === 'json')
|
||||
return FileAppearanceTypeEnum.code
|
||||
|
||||
if (extension === 'pdf')
|
||||
return FileAppearanceTypeEnum.pdf
|
||||
|
||||
if (extension === 'md' || extension === 'markdown' || extension === 'mdx')
|
||||
return FileAppearanceTypeEnum.markdown
|
||||
|
||||
if (extension === 'xlsx' || extension === 'xls' || extension === 'csv')
|
||||
return FileAppearanceTypeEnum.excel
|
||||
|
||||
if (extension === 'docx' || extension === 'doc')
|
||||
return FileAppearanceTypeEnum.word
|
||||
|
||||
if (extension === 'pptx' || extension === 'ppt')
|
||||
return FileAppearanceTypeEnum.ppt
|
||||
|
||||
if (FILE_EXTS.document.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.document
|
||||
|
||||
return FileAppearanceTypeEnum.custom
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react'
|
||||
|
||||
type HeaderProps = {
|
||||
onClickConfiguration?: () => void
|
||||
docTitle: string
|
||||
docLink: string
|
||||
}
|
||||
|
||||
const Header = ({
|
||||
onClickConfiguration,
|
||||
docTitle,
|
||||
docLink,
|
||||
}: HeaderProps) => {
|
||||
return (
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<div className='flex shrink-0 grow items-center gap-x-1'>
|
||||
<div className='w-20 bg-black'>
|
||||
{/* placeholder */}
|
||||
</div>
|
||||
<Divider type='vertical' className='mx-1 h-3.5' />
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='small'
|
||||
className='px-1'
|
||||
>
|
||||
<RiEqualizer2Line
|
||||
className='size-4'
|
||||
onClick={onClickConfiguration}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<a
|
||||
className='system-xs-medium flex items-center gap-x-1 overflow-hidden text-text-accent'
|
||||
href={docLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<RiBookOpenLine className='size-3.5 shrink-0' />
|
||||
<span className='grow truncate' title={docTitle}>{docTitle}</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import Header from '../base/header'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import FileList from './file-list'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { ssePost } from '@/service/base'
|
||||
import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store'
|
||||
import { convertOnlineDriveData } from './utils'
|
||||
import { produce } from 'immer'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useGetDataSourceAuth } from '@/service/use-datasource'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
|
||||
type OnlineDriveProps = {
|
||||
nodeId: string
|
||||
nodeData: DataSourceNodeType
|
||||
isInPipeline?: boolean
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const OnlineDrive = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
isInPipeline = false,
|
||||
onCredentialChange,
|
||||
}: OnlineDriveProps) => {
|
||||
const docLink = useDocLink()
|
||||
const [isInitialMount, setIsInitialMount] = useState(true)
|
||||
const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
|
||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||
const {
|
||||
nextPageParameters,
|
||||
breadcrumbs,
|
||||
prefix,
|
||||
keywords,
|
||||
bucket,
|
||||
selectedFileIds,
|
||||
onlineDriveFileList,
|
||||
currentCredentialId,
|
||||
} = useDataSourceStoreWithSelector(useShallow(state => ({
|
||||
nextPageParameters: state.nextPageParameters,
|
||||
breadcrumbs: state.breadcrumbs,
|
||||
prefix: state.prefix,
|
||||
keywords: state.keywords,
|
||||
bucket: state.bucket,
|
||||
selectedFileIds: state.selectedFileIds,
|
||||
onlineDriveFileList: state.onlineDriveFileList,
|
||||
currentCredentialId: state.currentCredentialId,
|
||||
})))
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const isLoadingRef = useRef(false)
|
||||
|
||||
const { data: dataSourceAuth } = useGetDataSourceAuth({
|
||||
pluginId: nodeData.plugin_id,
|
||||
provider: nodeData.provider_name,
|
||||
})
|
||||
|
||||
const datasourceNodeRunURL = !isInPipeline
|
||||
? `/rag/pipelines/${pipelineId}/workflows/published/datasource/nodes/${nodeId}/run`
|
||||
: `/rag/pipelines/${pipelineId}/workflows/draft/datasource/nodes/${nodeId}/run`
|
||||
|
||||
const getOnlineDriveFiles = useCallback(async () => {
|
||||
if (isLoadingRef.current) return
|
||||
const { nextPageParameters, prefix, bucket, onlineDriveFileList, currentCredentialId } = dataSourceStore.getState()
|
||||
setIsLoading(true)
|
||||
isLoadingRef.current = true
|
||||
ssePost(
|
||||
datasourceNodeRunURL,
|
||||
{
|
||||
body: {
|
||||
inputs: {
|
||||
prefix: prefix[prefix.length - 1],
|
||||
bucket,
|
||||
next_page_parameters: nextPageParameters,
|
||||
max_keys: 30,
|
||||
},
|
||||
datasource_type: DatasourceType.onlineDrive,
|
||||
credential_id: currentCredentialId,
|
||||
},
|
||||
},
|
||||
{
|
||||
onDataSourceNodeCompleted: (documentsData: DataSourceNodeCompletedResponse) => {
|
||||
const { setOnlineDriveFileList, isTruncated, currentNextPageParametersRef, setHasBucket } = dataSourceStore.getState()
|
||||
const {
|
||||
fileList: newFileList,
|
||||
isTruncated: newIsTruncated,
|
||||
nextPageParameters: newNextPageParameters,
|
||||
hasBucket: newHasBucket,
|
||||
} = convertOnlineDriveData(documentsData.data, breadcrumbs, bucket)
|
||||
setOnlineDriveFileList([...onlineDriveFileList, ...newFileList])
|
||||
isTruncated.current = newIsTruncated
|
||||
currentNextPageParametersRef.current = newNextPageParameters
|
||||
setHasBucket(newHasBucket)
|
||||
setIsLoading(false)
|
||||
isLoadingRef.current = false
|
||||
},
|
||||
onDataSourceNodeError: (error: DataSourceNodeErrorResponse) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error.error,
|
||||
})
|
||||
setIsLoading(false)
|
||||
isLoadingRef.current = false
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [datasourceNodeRunURL, dataSourceStore])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentCredentialId) return
|
||||
if (isInitialMount) {
|
||||
// Only fetch files on initial mount if fileList is empty
|
||||
if (onlineDriveFileList.length === 0)
|
||||
getOnlineDriveFiles()
|
||||
setIsInitialMount(false)
|
||||
}
|
||||
else {
|
||||
getOnlineDriveFiles()
|
||||
}
|
||||
}, [nextPageParameters, prefix, bucket, currentCredentialId])
|
||||
|
||||
const filteredOnlineDriveFileList = useMemo(() => {
|
||||
if (keywords)
|
||||
return onlineDriveFileList.filter(file => file.name.toLowerCase().includes(keywords.toLowerCase()))
|
||||
return onlineDriveFileList
|
||||
}, [onlineDriveFileList, keywords])
|
||||
|
||||
const updateKeywords = useCallback((keywords: string) => {
|
||||
const { setKeywords } = dataSourceStore.getState()
|
||||
setKeywords(keywords)
|
||||
}, [dataSourceStore])
|
||||
|
||||
const resetKeywords = useCallback(() => {
|
||||
const { setKeywords } = dataSourceStore.getState()
|
||||
|
||||
setKeywords('')
|
||||
}, [dataSourceStore])
|
||||
|
||||
const handleSelectFile = useCallback((file: OnlineDriveFile) => {
|
||||
const { selectedFileIds, setSelectedFileIds } = dataSourceStore.getState()
|
||||
if (file.type === OnlineDriveFileType.bucket) return
|
||||
const newSelectedFileList = produce(selectedFileIds, (draft) => {
|
||||
if (draft.includes(file.id)) {
|
||||
const index = draft.indexOf(file.id)
|
||||
draft.splice(index, 1)
|
||||
}
|
||||
else {
|
||||
if (isInPipeline && draft.length >= 1) return
|
||||
draft.push(file.id)
|
||||
}
|
||||
})
|
||||
setSelectedFileIds(newSelectedFileList)
|
||||
}, [dataSourceStore, isInPipeline])
|
||||
|
||||
const handleOpenFolder = useCallback((file: OnlineDriveFile) => {
|
||||
const { breadcrumbs, prefix, setBreadcrumbs, setPrefix, setBucket, setOnlineDriveFileList, setSelectedFileIds } = dataSourceStore.getState()
|
||||
if (file.type === OnlineDriveFileType.file) return
|
||||
setOnlineDriveFileList([])
|
||||
if (file.type === OnlineDriveFileType.bucket) {
|
||||
setBucket(file.name)
|
||||
}
|
||||
else {
|
||||
setSelectedFileIds([])
|
||||
const newBreadcrumbs = produce(breadcrumbs, (draft) => {
|
||||
draft.push(file.name)
|
||||
})
|
||||
const newPrefix = produce(prefix, (draft) => {
|
||||
draft.push(file.id)
|
||||
})
|
||||
setBreadcrumbs(newBreadcrumbs)
|
||||
setPrefix(newPrefix)
|
||||
}
|
||||
}, [dataSourceStore, getOnlineDriveFiles])
|
||||
|
||||
const handleSetting = useCallback(() => {
|
||||
setShowAccountSettingModal({
|
||||
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
|
||||
})
|
||||
}, [setShowAccountSettingModal])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-y-2'>
|
||||
<Header
|
||||
docTitle='Docs'
|
||||
docLink={docLink('/guides/knowledge-base/knowledge-pipeline/authorize-data-source')}
|
||||
onClickConfiguration={handleSetting}
|
||||
pluginName={nodeData.datasource_label}
|
||||
currentCredentialId={currentCredentialId}
|
||||
onCredentialChange={onCredentialChange}
|
||||
credentials={dataSourceAuth?.result || []}
|
||||
/>
|
||||
<FileList
|
||||
fileList={filteredOnlineDriveFileList}
|
||||
selectedFileIds={selectedFileIds}
|
||||
breadcrumbs={breadcrumbs}
|
||||
keywords={keywords}
|
||||
bucket={bucket}
|
||||
resetKeywords={resetKeywords}
|
||||
updateKeywords={updateKeywords}
|
||||
searchResultsLength={filteredOnlineDriveFileList.length}
|
||||
handleSelectFile={handleSelectFile}
|
||||
handleOpenFolder={handleOpenFolder}
|
||||
isInPipeline={isInPipeline}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnlineDrive
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type OnlineDriveFile, OnlineDriveFileType } from '@/models/pipeline'
|
||||
import type { OnlineDriveData } from '@/types/pipeline'
|
||||
|
||||
export const isFile = (type: 'file' | 'folder'): boolean => {
|
||||
return type === 'file'
|
||||
}
|
||||
|
||||
export const isBucketListInitiation = (data: OnlineDriveData[], prefix: string[], bucket: string): boolean => {
|
||||
if (bucket || prefix.length > 0) return false
|
||||
const hasBucket = data.every(item => !!item.bucket)
|
||||
return hasBucket && (data.length > 1 || (data.length === 1 && !!data[0].bucket && data[0].files.length === 0))
|
||||
}
|
||||
|
||||
export const convertOnlineDriveData = (data: OnlineDriveData[], prefix: string[], bucket: string): {
|
||||
fileList: OnlineDriveFile[],
|
||||
isTruncated: boolean,
|
||||
nextPageParameters: Record<string, any>
|
||||
hasBucket: boolean
|
||||
} => {
|
||||
const fileList: OnlineDriveFile[] = []
|
||||
let isTruncated = false
|
||||
let nextPageParameters: Record<string, any> = {}
|
||||
let hasBucket = false
|
||||
|
||||
if (data.length === 0)
|
||||
return { fileList, isTruncated, nextPageParameters, hasBucket }
|
||||
|
||||
if (isBucketListInitiation(data, prefix, bucket)) {
|
||||
data.forEach((item) => {
|
||||
fileList.push({
|
||||
id: item.bucket,
|
||||
name: item.bucket,
|
||||
type: OnlineDriveFileType.bucket,
|
||||
})
|
||||
})
|
||||
hasBucket = true
|
||||
}
|
||||
else {
|
||||
data[0].files.forEach((file) => {
|
||||
const { id, name, size, type } = file
|
||||
const isFileType = isFile(type)
|
||||
fileList.push({
|
||||
id,
|
||||
name,
|
||||
size: isFileType ? size : undefined,
|
||||
type: isFileType ? OnlineDriveFileType.file : OnlineDriveFileType.folder,
|
||||
})
|
||||
})
|
||||
isTruncated = data[0].is_truncated ?? false
|
||||
nextPageParameters = data[0].next_page_parameters ?? {}
|
||||
hasBucket = !!data[0].bucket
|
||||
}
|
||||
return { fileList, isTruncated, nextPageParameters, hasBucket }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useContext } from 'react'
|
||||
import { createStore, useStore } from 'zustand'
|
||||
import { DataSourceContext } from './provider'
|
||||
import type { CommonShape } from './slices/common'
|
||||
import { createCommonSlice } from './slices/common'
|
||||
import type { LocalFileSliceShape } from './slices/local-file'
|
||||
import { createLocalFileSlice } from './slices/local-file'
|
||||
import type { OnlineDocumentSliceShape } from './slices/online-document'
|
||||
import { createOnlineDocumentSlice } from './slices/online-document'
|
||||
import type { WebsiteCrawlSliceShape } from './slices/website-crawl'
|
||||
import { createWebsiteCrawlSlice } from './slices/website-crawl'
|
||||
import type { OnlineDriveSliceShape } from './slices/online-drive'
|
||||
import { createOnlineDriveSlice } from './slices/online-drive'
|
||||
|
||||
export type DataSourceShape = CommonShape
|
||||
& LocalFileSliceShape
|
||||
& OnlineDocumentSliceShape
|
||||
& WebsiteCrawlSliceShape
|
||||
& OnlineDriveSliceShape
|
||||
|
||||
export const createDataSourceStore = () => {
|
||||
return createStore<DataSourceShape>((...args) => ({
|
||||
...createCommonSlice(...args),
|
||||
...createLocalFileSlice(...args),
|
||||
...createOnlineDocumentSlice(...args),
|
||||
...createWebsiteCrawlSlice(...args),
|
||||
...createOnlineDriveSlice(...args),
|
||||
}))
|
||||
}
|
||||
|
||||
export const useDataSourceStoreWithSelector = <T>(selector: (state: DataSourceShape) => T): T => {
|
||||
const store = useContext(DataSourceContext)
|
||||
if (!store)
|
||||
throw new Error('Missing DataSourceContext.Provider in the tree')
|
||||
|
||||
return useStore(store, selector)
|
||||
}
|
||||
|
||||
export const useDataSourceStore = () => {
|
||||
const store = useContext(DataSourceContext)
|
||||
if (!store)
|
||||
throw new Error('Missing DataSourceContext.Provider in the tree')
|
||||
|
||||
return store
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createContext, useRef } from 'react'
|
||||
import { createDataSourceStore } from './'
|
||||
|
||||
type DataSourceStoreApi = ReturnType<typeof createDataSourceStore>
|
||||
|
||||
type DataSourceContextType = DataSourceStoreApi | null
|
||||
|
||||
export const DataSourceContext = createContext<DataSourceContextType>(null)
|
||||
|
||||
type DataSourceProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const DataSourceProvider = ({
|
||||
children,
|
||||
}: DataSourceProviderProps) => {
|
||||
const storeRef = useRef<DataSourceStoreApi>(null)
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createDataSourceStore()
|
||||
|
||||
return (
|
||||
<DataSourceContext.Provider value={storeRef.current!}>
|
||||
{children}
|
||||
</DataSourceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataSourceProvider
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
|
||||
export type CommonShape = {
|
||||
currentNodeIdRef: React.RefObject<string>
|
||||
currentCredentialId: string
|
||||
setCurrentCredentialId: (credentialId: string) => void
|
||||
currentCredentialIdRef: React.RefObject<string>
|
||||
}
|
||||
|
||||
export const createCommonSlice: StateCreator<CommonShape> = (set) => {
|
||||
return ({
|
||||
currentNodeIdRef: { current: '' },
|
||||
currentCredentialId: '',
|
||||
setCurrentCredentialId: (credentialId: string) => {
|
||||
set({ currentCredentialId: credentialId })
|
||||
},
|
||||
currentCredentialIdRef: { current: '' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { DocumentItem, CustomFile as File, FileItem } from '@/models/datasets'
|
||||
|
||||
export type LocalFileSliceShape = {
|
||||
localFileList: FileItem[]
|
||||
setLocalFileList: (fileList: FileItem[]) => void
|
||||
currentLocalFile: File | undefined
|
||||
setCurrentLocalFile: (file: File | undefined) => void
|
||||
previewLocalFileRef: React.RefObject<DocumentItem | undefined>
|
||||
}
|
||||
|
||||
export const createLocalFileSlice: StateCreator<LocalFileSliceShape> = (set, get) => {
|
||||
return ({
|
||||
localFileList: [],
|
||||
setLocalFileList: (fileList: FileItem[]) => {
|
||||
set(() => ({
|
||||
localFileList: fileList,
|
||||
}))
|
||||
const { previewLocalFileRef } = get()
|
||||
previewLocalFileRef.current = fileList[0]?.file as DocumentItem
|
||||
},
|
||||
currentLocalFile: undefined,
|
||||
setCurrentLocalFile: (file: File | undefined) => set(() => ({
|
||||
currentLocalFile: file,
|
||||
})),
|
||||
previewLocalFileRef: { current: undefined },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common'
|
||||
|
||||
export type OnlineDocumentSliceShape = {
|
||||
documentsData: DataSourceNotionWorkspace[]
|
||||
setDocumentsData: (documentData: DataSourceNotionWorkspace[]) => void
|
||||
searchValue: string
|
||||
setSearchValue: (searchValue: string) => void
|
||||
onlineDocuments: NotionPage[]
|
||||
setOnlineDocuments: (documents: NotionPage[]) => void
|
||||
currentDocument: NotionPage | undefined
|
||||
setCurrentDocument: (document: NotionPage | undefined) => void
|
||||
selectedPagesId: Set<string>
|
||||
setSelectedPagesId: (selectedPagesId: Set<string>) => void
|
||||
previewOnlineDocumentRef: React.RefObject<NotionPage | undefined>
|
||||
}
|
||||
|
||||
export const createOnlineDocumentSlice: StateCreator<OnlineDocumentSliceShape> = (set, get) => {
|
||||
return ({
|
||||
documentsData: [],
|
||||
setDocumentsData: (documentsData: DataSourceNotionWorkspace[]) => set(() => ({
|
||||
documentsData,
|
||||
})),
|
||||
searchValue: '',
|
||||
setSearchValue: (searchValue: string) => set(() => ({
|
||||
searchValue,
|
||||
})),
|
||||
onlineDocuments: [],
|
||||
setOnlineDocuments: (documents: NotionPage[]) => {
|
||||
set(() => ({
|
||||
onlineDocuments: documents,
|
||||
}))
|
||||
const { previewOnlineDocumentRef } = get()
|
||||
previewOnlineDocumentRef.current = documents[0]
|
||||
},
|
||||
currentDocument: undefined,
|
||||
setCurrentDocument: (document: NotionPage | undefined) => set(() => ({
|
||||
currentDocument: document,
|
||||
})),
|
||||
selectedPagesId: new Set(),
|
||||
setSelectedPagesId: (selectedPagesId: Set<string>) => set(() => ({
|
||||
selectedPagesId,
|
||||
})),
|
||||
previewOnlineDocumentRef: { current: undefined },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
|
||||
export type OnlineDriveSliceShape = {
|
||||
breadcrumbs: string[]
|
||||
setBreadcrumbs: (breadcrumbs: string[]) => void
|
||||
prefix: string[]
|
||||
setPrefix: (prefix: string[]) => void
|
||||
keywords: string
|
||||
setKeywords: (keywords: string) => void
|
||||
selectedFileIds: string[]
|
||||
setSelectedFileIds: (selectedFileIds: string[]) => void
|
||||
onlineDriveFileList: OnlineDriveFile[]
|
||||
setOnlineDriveFileList: (onlineDriveFileList: OnlineDriveFile[]) => void
|
||||
bucket: string
|
||||
setBucket: (bucket: string) => void
|
||||
nextPageParameters: Record<string, any>
|
||||
currentNextPageParametersRef: React.RefObject<Record<string, any>>
|
||||
setNextPageParameters: (nextPageParameters: Record<string, any>) => void
|
||||
isTruncated: React.RefObject<boolean>
|
||||
previewOnlineDriveFileRef: React.RefObject<OnlineDriveFile | undefined>
|
||||
hasBucket: boolean
|
||||
setHasBucket: (hasBucket: boolean) => void
|
||||
}
|
||||
|
||||
export const createOnlineDriveSlice: StateCreator<OnlineDriveSliceShape> = (set, get) => {
|
||||
return ({
|
||||
breadcrumbs: [],
|
||||
setBreadcrumbs: (breadcrumbs: string[]) => set(() => ({
|
||||
breadcrumbs,
|
||||
})),
|
||||
prefix: [],
|
||||
setPrefix: (prefix: string[]) => set(() => ({
|
||||
prefix,
|
||||
})),
|
||||
keywords: '',
|
||||
setKeywords: (keywords: string) => set(() => ({
|
||||
keywords,
|
||||
})),
|
||||
selectedFileIds: [],
|
||||
setSelectedFileIds: (selectedFileIds: string[]) => {
|
||||
set(() => ({
|
||||
selectedFileIds,
|
||||
}))
|
||||
const id = selectedFileIds[0]
|
||||
const { onlineDriveFileList, previewOnlineDriveFileRef } = get()
|
||||
previewOnlineDriveFileRef.current = onlineDriveFileList.find(file => file.id === id)
|
||||
},
|
||||
onlineDriveFileList: [],
|
||||
setOnlineDriveFileList: (onlineDriveFileList: OnlineDriveFile[]) => set(() => ({
|
||||
onlineDriveFileList,
|
||||
})),
|
||||
bucket: '',
|
||||
setBucket: (bucket: string) => set(() => ({
|
||||
bucket,
|
||||
})),
|
||||
nextPageParameters: {},
|
||||
currentNextPageParametersRef: { current: {} },
|
||||
setNextPageParameters: (nextPageParameters: Record<string, any>) => set(() => ({
|
||||
nextPageParameters,
|
||||
})),
|
||||
isTruncated: { current: false },
|
||||
previewOnlineDriveFileRef: { current: undefined },
|
||||
hasBucket: false,
|
||||
setHasBucket: (hasBucket: boolean) => set(() => ({
|
||||
hasBucket,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { CrawlResult, CrawlResultItem } from '@/models/datasets'
|
||||
import { CrawlStep } from '@/models/datasets'
|
||||
|
||||
export type WebsiteCrawlSliceShape = {
|
||||
websitePages: CrawlResultItem[]
|
||||
setWebsitePages: (pages: CrawlResultItem[]) => void
|
||||
currentWebsite: CrawlResultItem | undefined
|
||||
setCurrentWebsite: (website: CrawlResultItem | undefined) => void
|
||||
crawlResult: CrawlResult | undefined
|
||||
setCrawlResult: (result: CrawlResult | undefined) => void
|
||||
step: CrawlStep
|
||||
setStep: (step: CrawlStep) => void
|
||||
previewIndex: number
|
||||
setPreviewIndex: (index: number) => void
|
||||
previewWebsitePageRef: React.RefObject<CrawlResultItem | undefined>
|
||||
}
|
||||
|
||||
export const createWebsiteCrawlSlice: StateCreator<WebsiteCrawlSliceShape> = (set, get) => {
|
||||
return ({
|
||||
websitePages: [],
|
||||
setWebsitePages: (pages: CrawlResultItem[]) => {
|
||||
set(() => ({
|
||||
websitePages: pages,
|
||||
}))
|
||||
const { previewWebsitePageRef } = get()
|
||||
previewWebsitePageRef.current = pages[0]
|
||||
},
|
||||
currentWebsite: undefined,
|
||||
setCurrentWebsite: (website: CrawlResultItem | undefined) => set(() => ({
|
||||
currentWebsite: website,
|
||||
})),
|
||||
crawlResult: undefined,
|
||||
setCrawlResult: (result: CrawlResult | undefined) => set(() => ({
|
||||
crawlResult: result,
|
||||
})),
|
||||
step: CrawlStep.init,
|
||||
setStep: (step: CrawlStep) => set(() => ({
|
||||
step,
|
||||
})),
|
||||
previewIndex: -1,
|
||||
setPreviewIndex: (index: number) => set(() => ({
|
||||
previewIndex: index,
|
||||
})),
|
||||
previewWebsitePageRef: { current: undefined },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type CheckboxWithLabelProps = {
|
||||
className?: string
|
||||
isChecked: boolean
|
||||
onChange: (isChecked: boolean) => void
|
||||
label: string
|
||||
labelClassName?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
const CheckboxWithLabel = ({
|
||||
className = '',
|
||||
isChecked,
|
||||
onChange,
|
||||
label,
|
||||
labelClassName,
|
||||
tooltip,
|
||||
}: CheckboxWithLabelProps) => {
|
||||
return (
|
||||
<label className={cn('flex items-center space-x-2', className)}>
|
||||
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} />
|
||||
<div className={cn('system-sm-medium text-text-secondary', labelClassName)}>{label}</div>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[200px]'>{tooltip}</div>
|
||||
}
|
||||
triggerClassName='ml-0.5 w-4 h-4'
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
export default React.memo(CheckboxWithLabel)
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
import React, { useCallback } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
|
||||
type CrawledResultItemProps = {
|
||||
payload: CrawlResultItemType
|
||||
isChecked: boolean
|
||||
onCheckChange: (checked: boolean) => void
|
||||
isPreview: boolean
|
||||
showPreview: boolean
|
||||
onPreview: () => void
|
||||
isMultipleChoice?: boolean
|
||||
}
|
||||
|
||||
const CrawledResultItem = ({
|
||||
payload,
|
||||
isChecked,
|
||||
onCheckChange,
|
||||
isPreview,
|
||||
onPreview,
|
||||
showPreview,
|
||||
isMultipleChoice = true,
|
||||
}: CrawledResultItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
onCheckChange(!isChecked)
|
||||
}, [isChecked, onCheckChange])
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative flex cursor-pointer gap-x-2 rounded-lg p-2',
|
||||
isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover',
|
||||
)}>
|
||||
{
|
||||
isMultipleChoice ? (
|
||||
<Checkbox
|
||||
className='shrink-0'
|
||||
checked={isChecked}
|
||||
onCheck={handleCheckChange}
|
||||
/>
|
||||
) : (
|
||||
<Radio
|
||||
isChecked={isChecked}
|
||||
onCheck={handleCheckChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className='flex min-w-0 grow flex-col gap-y-0.5'>
|
||||
<div
|
||||
className='system-sm-medium truncate text-text-secondary'
|
||||
title={payload.title}
|
||||
>
|
||||
{payload.title}
|
||||
</div>
|
||||
<div
|
||||
className='system-xs-regular truncate text-text-tertiary'
|
||||
title={payload.source_url}
|
||||
>
|
||||
{payload.source_url}
|
||||
</div>
|
||||
</div>
|
||||
{showPreview && (
|
||||
<Button
|
||||
size='small'
|
||||
onClick={onPreview}
|
||||
className='system-xs-medium-uppercase right-2 top-2 hidden px-1.5 group-hover:absolute group-hover:block'
|
||||
>
|
||||
{t('datasetCreation.stepOne.website.preview')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(CrawledResultItem)
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
import CheckboxWithLabel from './checkbox-with-label'
|
||||
import CrawledResultItem from './crawled-result-item'
|
||||
|
||||
const I18N_PREFIX = 'datasetCreation.stepOne.website'
|
||||
|
||||
type CrawledResultProps = {
|
||||
className?: string
|
||||
previewIndex?: number
|
||||
list: CrawlResultItem[]
|
||||
checkedList: CrawlResultItem[]
|
||||
onSelectedChange: (selected: CrawlResultItem[]) => void
|
||||
onPreview?: (payload: CrawlResultItem, index: number) => void
|
||||
showPreview?: boolean
|
||||
usedTime: number
|
||||
isMultipleChoice?: boolean
|
||||
}
|
||||
|
||||
const CrawledResult = ({
|
||||
className = '',
|
||||
previewIndex,
|
||||
list,
|
||||
checkedList,
|
||||
onSelectedChange,
|
||||
usedTime,
|
||||
onPreview,
|
||||
showPreview = false,
|
||||
isMultipleChoice = true,
|
||||
}: CrawledResultProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isCheckAll = checkedList.length === list.length
|
||||
|
||||
const handleCheckedAll = useCallback(() => {
|
||||
if (!isCheckAll)
|
||||
onSelectedChange(list)
|
||||
|
||||
else
|
||||
onSelectedChange([])
|
||||
}, [isCheckAll, list, onSelectedChange])
|
||||
|
||||
const handleItemCheckChange = useCallback((item: CrawlResultItem) => {
|
||||
return (checked: boolean) => {
|
||||
if (checked) {
|
||||
if (isMultipleChoice)
|
||||
onSelectedChange([...checkedList, item])
|
||||
else
|
||||
onSelectedChange([item])
|
||||
}
|
||||
else { onSelectedChange(checkedList.filter(checkedItem => checkedItem.source_url !== item.source_url)) }
|
||||
}
|
||||
}, [checkedList, onSelectedChange, isMultipleChoice])
|
||||
|
||||
const handlePreview = useCallback((index: number) => {
|
||||
if (!onPreview) return
|
||||
onPreview(list[index], index)
|
||||
}, [list, onPreview])
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)}>
|
||||
<div className='system-sm-medium pt-2 text-text-primary'>
|
||||
{t(`${I18N_PREFIX}.scrapTimeInfo`, {
|
||||
total: list.length,
|
||||
time: usedTime.toFixed(1),
|
||||
})}
|
||||
</div>
|
||||
<div className='overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg'>
|
||||
{isMultipleChoice && (
|
||||
<div className='flex items-center px-4 py-2'>
|
||||
<CheckboxWithLabel
|
||||
isChecked={isCheckAll}
|
||||
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2'>
|
||||
{list.map((item, index) => (
|
||||
<CrawledResultItem
|
||||
key={item.source_url}
|
||||
payload={item}
|
||||
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
|
||||
onCheckChange={handleItemCheckChange(item)}
|
||||
isPreview={index === previewIndex}
|
||||
onPreview={handlePreview.bind(null, index)}
|
||||
showPreview={showPreview}
|
||||
isMultipleChoice={isMultipleChoice}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(CrawledResult)
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type CrawlingProps = {
|
||||
className?: string
|
||||
crawledNum: number
|
||||
totalNum: number
|
||||
}
|
||||
|
||||
type BlockProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type ItemProps = {
|
||||
firstLineWidth: string
|
||||
secondLineWidth: string
|
||||
}
|
||||
|
||||
const Block = React.memo(({
|
||||
className,
|
||||
}: BlockProps) => {
|
||||
return <div className={cn('bg-text-quaternary opacity-20', className)} />
|
||||
})
|
||||
|
||||
const Item = React.memo(({
|
||||
firstLineWidth,
|
||||
secondLineWidth,
|
||||
}: ItemProps) => {
|
||||
return (
|
||||
<div className='flex gap-x-2 px-2 py-[5px]'>
|
||||
<div className='py-0.5'>
|
||||
<Block className='size-4 rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex grow flex-col'>
|
||||
<div className='flex h-5 w-full items-center'>
|
||||
<Block className={cn('h-2.5 rounded-sm', firstLineWidth)} />
|
||||
</div>
|
||||
<div className='flex h-[18px] w-full items-center'>
|
||||
<Block className={cn('h-1.5 rounded-sm', secondLineWidth)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const Crawling = ({
|
||||
className = '',
|
||||
crawledNum,
|
||||
totalNum,
|
||||
}: CrawlingProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const itemsConfig = [{
|
||||
firstLineWidth: 'w-[35%]',
|
||||
secondLineWidth: 'w-[50%]',
|
||||
}, {
|
||||
firstLineWidth: 'w-[40%]',
|
||||
secondLineWidth: 'w-[45%]',
|
||||
}, {
|
||||
firstLineWidth: 'w-[30%]',
|
||||
secondLineWidth: 'w-[36%]',
|
||||
}]
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2 flex flex-col gap-y-2 pt-2', className)}>
|
||||
<div className='system-sm-medium text-text-primary'>
|
||||
{t('datasetCreation.stepOne.website.totalPageScraped')} {crawledNum}/{totalNum}
|
||||
</div>
|
||||
<div className='overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg'>
|
||||
<div className='flex items-center gap-x-2 px-4 py-2'>
|
||||
<Block className='size-4 rounded-[4px]' />
|
||||
<Block className='h-2.5 w-14 rounded-sm' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-px border-t border-divider-subtle bg-background-default-subtle p-2'>
|
||||
{itemsConfig.map((item, index) => (
|
||||
<Item
|
||||
key={index}
|
||||
firstLineWidth={item.firstLineWidth}
|
||||
secondLineWidth={item.secondLineWidth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Crawling)
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiErrorWarningFill } from '@remixicon/react'
|
||||
|
||||
type ErrorMessageProps = {
|
||||
className?: string
|
||||
title: string
|
||||
errorMsg?: string
|
||||
}
|
||||
|
||||
const ErrorMessage = ({
|
||||
className,
|
||||
title,
|
||||
errorMsg,
|
||||
}: ErrorMessageProps) => {
|
||||
return (
|
||||
// eslint-disable-next-line tailwindcss/migration-from-tailwind-2
|
||||
<div className={cn(
|
||||
'flex gap-x-0.5 rounded-xl border-[0.5px] border-components-panel-border bg-opacity-40 bg-toast-error-bg p-2 shadow-xs shadow-shadow-shadow-3',
|
||||
className,
|
||||
)}>
|
||||
<div className='flex size-6 items-center justify-center'>
|
||||
<RiErrorWarningFill className='h-4 w-4 text-text-destructive' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-0.5 py-1'>
|
||||
<div className='system-xs-medium text-text-primary'>{title}</div>
|
||||
{errorMsg && (
|
||||
<div className='system-xs-regular text-text-secondary'>{errorMsg}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ErrorMessage)
|
||||
@@ -0,0 +1,123 @@
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useAppForm } from '@/app/components/base/form'
|
||||
import BaseField from '@/app/components/base/form/form-scenarios/base/field'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiPlayLargeLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import type { RAGPipelineVariables } from '@/models/pipeline'
|
||||
import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields'
|
||||
import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils'
|
||||
import { CrawlStep } from '@/models/datasets'
|
||||
|
||||
const I18N_PREFIX = 'datasetCreation.stepOne.website'
|
||||
|
||||
type OptionsProps = {
|
||||
variables: RAGPipelineVariables
|
||||
step: CrawlStep
|
||||
runDisabled?: boolean
|
||||
onSubmit: (data: Record<string, any>) => void
|
||||
}
|
||||
|
||||
const Options = ({
|
||||
variables,
|
||||
step,
|
||||
runDisabled,
|
||||
onSubmit,
|
||||
}: OptionsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const initialData = useInitialData(variables)
|
||||
const configurations = useConfigurations(variables)
|
||||
const schema = useMemo(() => {
|
||||
return generateZodSchema(configurations)
|
||||
}, [configurations])
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: initialData,
|
||||
validators: {
|
||||
onSubmit: ({ value }) => {
|
||||
const result = schema.safeParse(value)
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
const firstIssue = issues[0]
|
||||
const errorMessage = `"${firstIssue.path.join('.')}" ${firstIssue.message}`
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
return errorMessage
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
onSubmit(value)
|
||||
},
|
||||
})
|
||||
|
||||
const [fold, {
|
||||
toggle: foldToggle,
|
||||
setTrue: foldHide,
|
||||
setFalse: foldShow,
|
||||
}] = useBoolean(false)
|
||||
|
||||
useEffect(() => {
|
||||
// When the step change
|
||||
if (step !== CrawlStep.init)
|
||||
foldHide()
|
||||
else
|
||||
foldShow()
|
||||
}, [step])
|
||||
|
||||
const isRunning = useMemo(() => step === CrawlStep.running, [step])
|
||||
|
||||
return (
|
||||
<form
|
||||
className='w-full'
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
form.handleSubmit()
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-x-1 px-4 py-2'>
|
||||
<div
|
||||
className='flex grow cursor-pointer select-none items-center gap-x-0.5'
|
||||
onClick={foldToggle}
|
||||
>
|
||||
<span className='system-sm-semibold-uppercase text-text-secondary'>
|
||||
{t(`${I18N_PREFIX}.options`)}
|
||||
</span>
|
||||
<ArrowDownRoundFill className={cn('h-4 w-4 shrink-0 text-text-quaternary', fold && '-rotate-90')} />
|
||||
</div>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={form.handleSubmit}
|
||||
disabled={runDisabled || isRunning}
|
||||
loading={isRunning}
|
||||
className='shrink-0 gap-x-0.5'
|
||||
spinnerClassName='!ml-0'
|
||||
>
|
||||
<RiPlayLargeLine className='size-4' />
|
||||
<span className='px-0.5'>{!isRunning ? t(`${I18N_PREFIX}.run`) : t(`${I18N_PREFIX}.running`)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{!fold && (
|
||||
<div className='flex flex-col gap-3 border-t border-divider-subtle px-4 py-3'>
|
||||
{configurations.map((config, index) => {
|
||||
const FieldComponent = BaseField({
|
||||
initialData,
|
||||
config,
|
||||
})
|
||||
return <FieldComponent key={index} form={form} />
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default Options
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
import { CrawlStep } from '@/models/datasets'
|
||||
import Header from '../base/header'
|
||||
import Options from './base/options'
|
||||
import Crawling from './base/crawling'
|
||||
import ErrorMessage from './base/error-message'
|
||||
import CrawledResult from './base/crawled-result'
|
||||
import {
|
||||
useDraftPipelinePreProcessingParams,
|
||||
usePublishedPipelinePreProcessingParams,
|
||||
} from '@/service/use-pipeline'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { ssePost } from '@/service/base'
|
||||
import type {
|
||||
DataSourceNodeCompletedResponse,
|
||||
DataSourceNodeErrorResponse,
|
||||
DataSourceNodeProcessingResponse,
|
||||
} from '@/types/pipeline'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useGetDataSourceAuth } from '@/service/use-datasource'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
|
||||
const I18N_PREFIX = 'datasetCreation.stepOne.website'
|
||||
|
||||
export type WebsiteCrawlProps = {
|
||||
nodeId: string
|
||||
nodeData: DataSourceNodeType
|
||||
isInPipeline?: boolean
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const WebsiteCrawl = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
isInPipeline = false,
|
||||
onCredentialChange,
|
||||
}: WebsiteCrawlProps) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const [totalNum, setTotalNum] = useState(0)
|
||||
const [crawledNum, setCrawledNum] = useState(0)
|
||||
const [crawlErrorMessage, setCrawlErrorMessage] = useState('')
|
||||
const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
|
||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||
const {
|
||||
crawlResult,
|
||||
step,
|
||||
checkedCrawlResult,
|
||||
previewIndex,
|
||||
currentCredentialId,
|
||||
} = useDataSourceStoreWithSelector(useShallow(state => ({
|
||||
crawlResult: state.crawlResult,
|
||||
step: state.step,
|
||||
checkedCrawlResult: state.websitePages,
|
||||
previewIndex: state.previewIndex,
|
||||
currentCredentialId: state.currentCredentialId,
|
||||
})))
|
||||
|
||||
const { data: dataSourceAuth } = useGetDataSourceAuth({
|
||||
pluginId: nodeData.plugin_id,
|
||||
provider: nodeData.provider_name,
|
||||
})
|
||||
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
const usePreProcessingParams = useRef(!isInPipeline ? usePublishedPipelinePreProcessingParams : useDraftPipelinePreProcessingParams)
|
||||
const { data: paramsConfig, isFetching: isFetchingParams } = usePreProcessingParams.current({
|
||||
pipeline_id: pipelineId!,
|
||||
node_id: nodeId,
|
||||
}, !!pipelineId && !!nodeId)
|
||||
|
||||
const isInit = step === CrawlStep.init
|
||||
const isCrawlFinished = step === CrawlStep.finished
|
||||
const isRunning = step === CrawlStep.running
|
||||
const showError = isCrawlFinished && crawlErrorMessage
|
||||
const datasourceNodeRunURL = !isInPipeline
|
||||
? `/rag/pipelines/${pipelineId}/workflows/published/datasource/nodes/${nodeId}/run`
|
||||
: `/rag/pipelines/${pipelineId}/workflows/draft/datasource/nodes/${nodeId}/run`
|
||||
|
||||
const handleCheckedCrawlResultChange = useCallback((checkedCrawlResult: CrawlResultItem[]) => {
|
||||
const { setWebsitePages } = dataSourceStore.getState()
|
||||
setWebsitePages(checkedCrawlResult)
|
||||
}, [dataSourceStore])
|
||||
|
||||
const handlePreview = useCallback((website: CrawlResultItem, index: number) => {
|
||||
const { setCurrentWebsite, setPreviewIndex } = dataSourceStore.getState()
|
||||
setCurrentWebsite(website)
|
||||
setPreviewIndex(index)
|
||||
}, [dataSourceStore])
|
||||
|
||||
const handleRun = useCallback(async (value: Record<string, any>) => {
|
||||
const { setStep, setCrawlResult, currentCredentialId } = dataSourceStore.getState()
|
||||
|
||||
setStep(CrawlStep.running)
|
||||
ssePost(
|
||||
datasourceNodeRunURL,
|
||||
{
|
||||
body: {
|
||||
inputs: value,
|
||||
datasource_type: DatasourceType.websiteCrawl,
|
||||
credential_id: currentCredentialId,
|
||||
response_mode: 'streaming',
|
||||
},
|
||||
},
|
||||
{
|
||||
onDataSourceNodeProcessing: (data: DataSourceNodeProcessingResponse) => {
|
||||
setTotalNum(data.total ?? 0)
|
||||
setCrawledNum(data.completed ?? 0)
|
||||
},
|
||||
onDataSourceNodeCompleted: (data: DataSourceNodeCompletedResponse) => {
|
||||
const { data: crawlData, time_consuming } = data
|
||||
const crawlResultData = {
|
||||
data: crawlData as CrawlResultItem[],
|
||||
time_consuming: time_consuming ?? 0,
|
||||
}
|
||||
setCrawlResult(crawlResultData)
|
||||
handleCheckedCrawlResultChange(isInPipeline ? [crawlData[0]] : crawlData) // default select the crawl result
|
||||
setCrawlErrorMessage('')
|
||||
setStep(CrawlStep.finished)
|
||||
},
|
||||
onDataSourceNodeError: (error: DataSourceNodeErrorResponse) => {
|
||||
setCrawlErrorMessage(error.error || t(`${I18N_PREFIX}.unknownError`))
|
||||
setStep(CrawlStep.finished)
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [dataSourceStore, datasourceNodeRunURL, handleCheckedCrawlResultChange, isInPipeline, t])
|
||||
|
||||
const handleSubmit = useCallback((value: Record<string, any>) => {
|
||||
handleRun(value)
|
||||
}, [handleRun])
|
||||
|
||||
const handleSetting = useCallback(() => {
|
||||
setShowAccountSettingModal({
|
||||
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
|
||||
})
|
||||
}, [setShowAccountSettingModal])
|
||||
|
||||
const handleCredentialChange = useCallback((credentialId: string) => {
|
||||
setCrawledNum(0)
|
||||
setTotalNum(0)
|
||||
setCrawlErrorMessage('')
|
||||
onCredentialChange(credentialId)
|
||||
}, [dataSourceStore, onCredentialChange])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<Header
|
||||
docTitle='Docs'
|
||||
docLink={docLink('/guides/knowledge-base/knowledge-pipeline/authorize-data-source')}
|
||||
onClickConfiguration={handleSetting}
|
||||
pluginName={nodeData.datasource_label}
|
||||
currentCredentialId={currentCredentialId}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
credentials={dataSourceAuth?.result || []}
|
||||
/>
|
||||
<div className='mt-2 rounded-xl border border-components-panel-border bg-background-default-subtle'>
|
||||
<Options
|
||||
variables={paramsConfig?.variables || []}
|
||||
step={step}
|
||||
runDisabled={!currentCredentialId || isFetchingParams}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
{!isInit && (
|
||||
<div className='relative flex flex-col'>
|
||||
{isRunning && (
|
||||
<Crawling
|
||||
crawledNum={crawledNum}
|
||||
totalNum={totalNum}
|
||||
/>
|
||||
)}
|
||||
{showError && (
|
||||
<ErrorMessage
|
||||
className='mt-2'
|
||||
title={t(`${I18N_PREFIX}.exceptionErrorTitle`)}
|
||||
errorMsg={crawlErrorMessage}
|
||||
/>
|
||||
)}
|
||||
{isCrawlFinished && !showError && (
|
||||
<CrawledResult
|
||||
className='mt-2'
|
||||
list={crawlResult?.data || []}
|
||||
checkedList={checkedCrawlResult}
|
||||
onSelectedChange={handleCheckedCrawlResultChange}
|
||||
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
|
||||
previewIndex={previewIndex}
|
||||
onPreview={handlePreview}
|
||||
showPreview={!isInPipeline}
|
||||
isMultipleChoice={!isInPipeline} // only support single choice in test run
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(WebsiteCrawl)
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AddDocumentsStep } from './types'
|
||||
import type { DataSourceOption } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { BlockEnum, type Node } from '@/app/components/workflow/types'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { useDataSourceStore, useDataSourceStoreWithSelector } from './data-source/store'
|
||||
import type { DataSourceNotionPageMap, DataSourceNotionWorkspace } from '@/models/common'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { CrawlStep } from '@/models/datasets'
|
||||
|
||||
export const useAddDocumentsSteps = () => {
|
||||
const { t } = useTranslation()
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
setCurrentStep(preStep => preStep + 1)
|
||||
}, [])
|
||||
|
||||
const handleBackStep = useCallback(() => {
|
||||
setCurrentStep(preStep => preStep - 1)
|
||||
}, [])
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: t('datasetPipeline.addDocuments.steps.chooseDatasource'),
|
||||
value: AddDocumentsStep.dataSource,
|
||||
},
|
||||
{
|
||||
label: t('datasetPipeline.addDocuments.steps.processDocuments'),
|
||||
value: AddDocumentsStep.processDocuments,
|
||||
},
|
||||
{
|
||||
label: t('datasetPipeline.addDocuments.steps.processingDocuments'),
|
||||
value: AddDocumentsStep.processingDocuments,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
steps,
|
||||
currentStep,
|
||||
handleNextStep,
|
||||
handleBackStep,
|
||||
}
|
||||
}
|
||||
|
||||
export const useDatasourceOptions = (pipelineNodes: Node<DataSourceNodeType>[]) => {
|
||||
const datasourceNodes = pipelineNodes.filter(node => node.data.type === BlockEnum.DataSource)
|
||||
|
||||
const options = useMemo(() => {
|
||||
const options: DataSourceOption[] = []
|
||||
datasourceNodes.forEach((node) => {
|
||||
const label = node.data.title
|
||||
options.push({
|
||||
label,
|
||||
value: node.id,
|
||||
data: node.data,
|
||||
})
|
||||
})
|
||||
return options
|
||||
}, [datasourceNodes])
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
export const useLocalFile = () => {
|
||||
const {
|
||||
localFileList,
|
||||
currentLocalFile,
|
||||
} = useDataSourceStoreWithSelector(useShallow(state => ({
|
||||
localFileList: state.localFileList,
|
||||
currentLocalFile: state.currentLocalFile,
|
||||
})))
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
const allFileLoaded = useMemo(() => (localFileList.length > 0 && localFileList.every(file => file.file.id)), [localFileList])
|
||||
|
||||
const hidePreviewLocalFile = useCallback(() => {
|
||||
const { setCurrentLocalFile } = dataSourceStore.getState()
|
||||
setCurrentLocalFile(undefined)
|
||||
}, [dataSourceStore])
|
||||
|
||||
return {
|
||||
localFileList,
|
||||
allFileLoaded,
|
||||
currentLocalFile,
|
||||
hidePreviewLocalFile,
|
||||
}
|
||||
}
|
||||
|
||||
export const useOnlineDocument = () => {
|
||||
const {
|
||||
documentsData,
|
||||
onlineDocuments,
|
||||
currentDocument,
|
||||
} = useDataSourceStoreWithSelector(useShallow(state => ({
|
||||
documentsData: state.documentsData,
|
||||
onlineDocuments: state.onlineDocuments,
|
||||
currentDocument: state.currentDocument,
|
||||
})))
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
const currentWorkspace = documentsData[0]
|
||||
|
||||
const PagesMapAndSelectedPagesId: DataSourceNotionPageMap = useMemo(() => {
|
||||
const pagesMap = (documentsData || []).reduce((prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => {
|
||||
next.pages.forEach((page) => {
|
||||
prev[page.page_id] = {
|
||||
...page,
|
||||
workspace_id: next.workspace_id,
|
||||
}
|
||||
})
|
||||
|
||||
return prev
|
||||
}, {})
|
||||
return pagesMap
|
||||
}, [documentsData])
|
||||
|
||||
const hidePreviewOnlineDocument = useCallback(() => {
|
||||
const { setCurrentDocument } = dataSourceStore.getState()
|
||||
setCurrentDocument(undefined)
|
||||
}, [dataSourceStore])
|
||||
|
||||
const clearOnlineDocumentData = useCallback(() => {
|
||||
const {
|
||||
setDocumentsData,
|
||||
setSearchValue,
|
||||
setSelectedPagesId,
|
||||
setOnlineDocuments,
|
||||
setCurrentDocument,
|
||||
} = dataSourceStore.getState()
|
||||
setDocumentsData([])
|
||||
setSearchValue('')
|
||||
setSelectedPagesId(new Set())
|
||||
setOnlineDocuments([])
|
||||
setCurrentDocument(undefined)
|
||||
}, [dataSourceStore])
|
||||
|
||||
return {
|
||||
currentWorkspace,
|
||||
onlineDocuments,
|
||||
currentDocument,
|
||||
PagesMapAndSelectedPagesId,
|
||||
hidePreviewOnlineDocument,
|
||||
clearOnlineDocumentData,
|
||||
}
|
||||
}
|
||||
|
||||
export const useWebsiteCrawl = () => {
|
||||
const {
|
||||
websitePages,
|
||||
currentWebsite,
|
||||
} = useDataSourceStoreWithSelector(useShallow(state => ({
|
||||
websitePages: state.websitePages,
|
||||
currentWebsite: state.currentWebsite,
|
||||
})))
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
const hideWebsitePreview = useCallback(() => {
|
||||
const { setCurrentWebsite, setPreviewIndex } = dataSourceStore.getState()
|
||||
setCurrentWebsite(undefined)
|
||||
setPreviewIndex(-1)
|
||||
}, [dataSourceStore])
|
||||
|
||||
const clearWebsiteCrawlData = useCallback(() => {
|
||||
const {
|
||||
setStep,
|
||||
setCrawlResult,
|
||||
setWebsitePages,
|
||||
setPreviewIndex,
|
||||
setCurrentWebsite,
|
||||
} = dataSourceStore.getState()
|
||||
setStep(CrawlStep.init)
|
||||
setCrawlResult(undefined)
|
||||
setCurrentWebsite(undefined)
|
||||
setWebsitePages([])
|
||||
setPreviewIndex(-1)
|
||||
}, [dataSourceStore])
|
||||
|
||||
return {
|
||||
websitePages,
|
||||
currentWebsite,
|
||||
hideWebsitePreview,
|
||||
clearWebsiteCrawlData,
|
||||
}
|
||||
}
|
||||
|
||||
export const useOnlineDrive = () => {
|
||||
const {
|
||||
onlineDriveFileList,
|
||||
selectedFileIds,
|
||||
} = useDataSourceStoreWithSelector(useShallow(state => ({
|
||||
onlineDriveFileList: state.onlineDriveFileList,
|
||||
selectedFileIds: state.selectedFileIds,
|
||||
})))
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
const selectedOnlineDriveFileList = useMemo(() => {
|
||||
return selectedFileIds.map(id => onlineDriveFileList.find(item => item.id === id)!)
|
||||
}, [onlineDriveFileList, selectedFileIds])
|
||||
|
||||
const clearOnlineDriveData = useCallback(() => {
|
||||
const {
|
||||
setOnlineDriveFileList,
|
||||
setBucket,
|
||||
setPrefix,
|
||||
setKeywords,
|
||||
setSelectedFileIds,
|
||||
} = dataSourceStore.getState()
|
||||
setOnlineDriveFileList([])
|
||||
setBucket('')
|
||||
setPrefix([])
|
||||
setKeywords('')
|
||||
setSelectedFileIds([])
|
||||
}, [dataSourceStore])
|
||||
|
||||
return {
|
||||
onlineDriveFileList,
|
||||
selectedFileIds,
|
||||
selectedOnlineDriveFileList,
|
||||
clearOnlineDriveData,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
'use client'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import DataSourceOptions from './data-source-options'
|
||||
import type { CrawlResultItem, DocumentItem, CustomFile as File, FileIndexingEstimateResponse } from '@/models/datasets'
|
||||
import LocalFile from '@/app/components/datasets/documents/create-from-pipeline/data-source/local-file'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import OnlineDocuments from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents'
|
||||
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
|
||||
import WebsiteCrawl from '@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl'
|
||||
import OnlineDrive from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive'
|
||||
import Actions from './actions'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import LeftHeader from './left-header'
|
||||
import { usePublishedPipelineInfo, useRunPublishedPipeline } from '@/service/use-pipeline'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import FilePreview from './preview/file-preview'
|
||||
import OnlineDocumentPreview from './preview/online-document-preview'
|
||||
import WebsitePreview from './preview/web-preview'
|
||||
import ProcessDocuments from './process-documents'
|
||||
import ChunkPreview from './preview/chunk-preview'
|
||||
import Processing from './processing'
|
||||
import type {
|
||||
InitialDocumentDetail,
|
||||
OnlineDriveFile,
|
||||
PublishedPipelineRunPreviewResponse,
|
||||
PublishedPipelineRunResponse,
|
||||
} from '@/models/pipeline'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useAddDocumentsSteps, useLocalFile, useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from './hooks'
|
||||
import DataSourceProvider from './data-source/store/provider'
|
||||
import { useDataSourceStore } from './data-source/store'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
|
||||
const CreateFormPipeline = () => {
|
||||
const { t } = useTranslation()
|
||||
const plan = useProviderContextSelector(state => state.plan)
|
||||
const enableBilling = useProviderContextSelector(state => state.enableBilling)
|
||||
const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
|
||||
const [datasource, setDatasource] = useState<Datasource>()
|
||||
const [estimateData, setEstimateData] = useState<FileIndexingEstimateResponse | undefined>(undefined)
|
||||
const [batchId, setBatchId] = useState('')
|
||||
const [documents, setDocuments] = useState<InitialDocumentDetail[]>([])
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
const isPreview = useRef(false)
|
||||
const formRef = useRef<any>(null)
|
||||
|
||||
const { data: pipelineInfo, isFetching: isFetchingPipelineInfo } = usePublishedPipelineInfo(pipelineId || '')
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
|
||||
const {
|
||||
steps,
|
||||
currentStep,
|
||||
handleNextStep,
|
||||
handleBackStep,
|
||||
} = useAddDocumentsSteps()
|
||||
const {
|
||||
localFileList,
|
||||
allFileLoaded,
|
||||
currentLocalFile,
|
||||
hidePreviewLocalFile,
|
||||
} = useLocalFile()
|
||||
const {
|
||||
currentWorkspace,
|
||||
onlineDocuments,
|
||||
currentDocument,
|
||||
PagesMapAndSelectedPagesId,
|
||||
hidePreviewOnlineDocument,
|
||||
clearOnlineDocumentData,
|
||||
} = useOnlineDocument()
|
||||
const {
|
||||
websitePages,
|
||||
currentWebsite,
|
||||
hideWebsitePreview,
|
||||
clearWebsiteCrawlData,
|
||||
} = useWebsiteCrawl()
|
||||
const {
|
||||
onlineDriveFileList,
|
||||
selectedFileIds,
|
||||
selectedOnlineDriveFileList,
|
||||
clearOnlineDriveData,
|
||||
} = useOnlineDrive()
|
||||
|
||||
const datasourceType = useMemo(() => datasource?.nodeData.provider_type, [datasource])
|
||||
const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace
|
||||
const isShowVectorSpaceFull = useMemo(() => {
|
||||
if (!datasource)
|
||||
return false
|
||||
if (datasourceType === DatasourceType.localFile)
|
||||
return allFileLoaded && isVectorSpaceFull && enableBilling
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return onlineDocuments.length > 0 && isVectorSpaceFull && enableBilling
|
||||
if (datasourceType === DatasourceType.websiteCrawl)
|
||||
return websitePages.length > 0 && isVectorSpaceFull && enableBilling
|
||||
if (datasourceType === DatasourceType.onlineDrive)
|
||||
return onlineDriveFileList.length > 0 && isVectorSpaceFull && enableBilling
|
||||
return false
|
||||
}, [allFileLoaded, datasource, datasourceType, enableBilling, isVectorSpaceFull, onlineDocuments.length, onlineDriveFileList.length, websitePages.length])
|
||||
const notSupportBatchUpload = enableBilling && plan.type === 'sandbox'
|
||||
|
||||
const nextBtnDisabled = useMemo(() => {
|
||||
if (!datasource) return true
|
||||
if (datasourceType === DatasourceType.localFile)
|
||||
return isShowVectorSpaceFull || !localFileList.length || !allFileLoaded
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return isShowVectorSpaceFull || !onlineDocuments.length
|
||||
if (datasourceType === DatasourceType.websiteCrawl)
|
||||
return isShowVectorSpaceFull || !websitePages.length
|
||||
if (datasourceType === DatasourceType.onlineDrive)
|
||||
return isShowVectorSpaceFull || !selectedFileIds.length
|
||||
return false
|
||||
}, [datasource, datasourceType, isShowVectorSpaceFull, localFileList.length, allFileLoaded, onlineDocuments.length, websitePages.length, selectedFileIds.length])
|
||||
|
||||
const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
|
||||
file_size_limit: 15,
|
||||
batch_count_limit: 5,
|
||||
}, [fileUploadConfigResponse])
|
||||
|
||||
const showSelect = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
const pagesCount = currentWorkspace?.pages.length ?? 0
|
||||
return pagesCount > 0
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const isBucketList = onlineDriveFileList.some(file => file.type === 'bucket')
|
||||
return !isBucketList && onlineDriveFileList.filter((item) => {
|
||||
return item.type !== 'bucket'
|
||||
}).length > 0
|
||||
}
|
||||
}, [currentWorkspace?.pages.length, datasourceType, onlineDriveFileList])
|
||||
|
||||
const totalOptions = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return currentWorkspace?.pages.length
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
return onlineDriveFileList.filter((item) => {
|
||||
return item.type !== 'bucket'
|
||||
}).length
|
||||
}
|
||||
}, [currentWorkspace?.pages.length, datasourceType, onlineDriveFileList])
|
||||
|
||||
const selectedOptions = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return onlineDocuments.length
|
||||
if (datasourceType === DatasourceType.onlineDrive)
|
||||
return selectedFileIds.length
|
||||
}, [datasourceType, onlineDocuments.length, selectedFileIds.length])
|
||||
|
||||
const tip = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return t('datasetPipeline.addDocuments.selectOnlineDocumentTip', { count: 50 })
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
return t('datasetPipeline.addDocuments.selectOnlineDriveTip', {
|
||||
count: fileUploadConfig.batch_count_limit,
|
||||
fileSize: fileUploadConfig.file_size_limit,
|
||||
})
|
||||
}
|
||||
return ''
|
||||
}, [datasourceType, fileUploadConfig.batch_count_limit, fileUploadConfig.file_size_limit, t])
|
||||
|
||||
const { mutateAsync: runPublishedPipeline, isIdle, isPending } = useRunPublishedPipeline()
|
||||
|
||||
const handlePreviewChunks = useCallback(async (data: Record<string, any>) => {
|
||||
if (!datasource)
|
||||
return
|
||||
const {
|
||||
previewLocalFileRef,
|
||||
previewOnlineDocumentRef,
|
||||
previewWebsitePageRef,
|
||||
previewOnlineDriveFileRef,
|
||||
currentCredentialId,
|
||||
} = dataSourceStore.getState()
|
||||
const datasourceInfoList: Record<string, any>[] = []
|
||||
if (datasourceType === DatasourceType.localFile) {
|
||||
const { id, name, type, size, extension, mime_type } = previewLocalFileRef.current as File
|
||||
const documentInfo = {
|
||||
related_id: id,
|
||||
name,
|
||||
type,
|
||||
size,
|
||||
extension,
|
||||
mime_type,
|
||||
url: '',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
credential_id: currentCredentialId,
|
||||
}
|
||||
datasourceInfoList.push(documentInfo)
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
const { workspace_id, ...rest } = previewOnlineDocumentRef.current!
|
||||
const documentInfo = {
|
||||
workspace_id,
|
||||
page: rest,
|
||||
credential_id: currentCredentialId,
|
||||
}
|
||||
datasourceInfoList.push(documentInfo)
|
||||
}
|
||||
if (datasourceType === DatasourceType.websiteCrawl) {
|
||||
datasourceInfoList.push({
|
||||
...previewWebsitePageRef.current!,
|
||||
credential_id: currentCredentialId,
|
||||
})
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const { bucket } = dataSourceStore.getState()
|
||||
const { id, type, name } = previewOnlineDriveFileRef.current!
|
||||
datasourceInfoList.push({
|
||||
bucket,
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
credential_id: currentCredentialId,
|
||||
})
|
||||
}
|
||||
await runPublishedPipeline({
|
||||
pipeline_id: pipelineId!,
|
||||
inputs: data,
|
||||
start_node_id: datasource.nodeId,
|
||||
datasource_type: datasourceType as DatasourceType,
|
||||
datasource_info_list: datasourceInfoList,
|
||||
is_preview: true,
|
||||
}, {
|
||||
onSuccess: (res) => {
|
||||
setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs)
|
||||
},
|
||||
})
|
||||
}, [datasource, datasourceType, runPublishedPipeline, pipelineId, dataSourceStore])
|
||||
|
||||
const handleProcess = useCallback(async (data: Record<string, any>) => {
|
||||
if (!datasource)
|
||||
return
|
||||
const { currentCredentialId } = dataSourceStore.getState()
|
||||
const datasourceInfoList: Record<string, any>[] = []
|
||||
if (datasourceType === DatasourceType.localFile) {
|
||||
const {
|
||||
localFileList,
|
||||
} = dataSourceStore.getState()
|
||||
localFileList.forEach((file) => {
|
||||
const { id, name, type, size, extension, mime_type } = file.file
|
||||
const documentInfo = {
|
||||
related_id: id,
|
||||
name,
|
||||
type,
|
||||
size,
|
||||
extension,
|
||||
mime_type,
|
||||
url: '',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
credential_id: currentCredentialId,
|
||||
}
|
||||
datasourceInfoList.push(documentInfo)
|
||||
})
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
const {
|
||||
onlineDocuments,
|
||||
} = dataSourceStore.getState()
|
||||
onlineDocuments.forEach((page) => {
|
||||
const { workspace_id, ...rest } = page
|
||||
const documentInfo = {
|
||||
workspace_id,
|
||||
page: rest,
|
||||
credential_id: currentCredentialId,
|
||||
}
|
||||
datasourceInfoList.push(documentInfo)
|
||||
})
|
||||
}
|
||||
if (datasourceType === DatasourceType.websiteCrawl) {
|
||||
const {
|
||||
websitePages,
|
||||
} = dataSourceStore.getState()
|
||||
websitePages.forEach((websitePage) => {
|
||||
datasourceInfoList.push({
|
||||
...websitePage,
|
||||
credential_id: currentCredentialId,
|
||||
})
|
||||
})
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const {
|
||||
bucket,
|
||||
selectedFileIds,
|
||||
onlineDriveFileList,
|
||||
} = dataSourceStore.getState()
|
||||
selectedFileIds.forEach((id) => {
|
||||
const file = onlineDriveFileList.find(file => file.id === id)
|
||||
datasourceInfoList.push({
|
||||
bucket,
|
||||
id: file?.id,
|
||||
name: file?.name,
|
||||
type: file?.type,
|
||||
credential_id: currentCredentialId,
|
||||
})
|
||||
})
|
||||
}
|
||||
await runPublishedPipeline({
|
||||
pipeline_id: pipelineId!,
|
||||
inputs: data,
|
||||
start_node_id: datasource.nodeId,
|
||||
datasource_type: datasourceType as DatasourceType,
|
||||
datasource_info_list: datasourceInfoList,
|
||||
is_preview: false,
|
||||
}, {
|
||||
onSuccess: (res) => {
|
||||
setBatchId((res as PublishedPipelineRunResponse).batch || '')
|
||||
setDocuments((res as PublishedPipelineRunResponse).documents || [])
|
||||
handleNextStep()
|
||||
},
|
||||
})
|
||||
}, [dataSourceStore, datasource, datasourceType, handleNextStep, pipelineId, runPublishedPipeline])
|
||||
|
||||
const onClickProcess = useCallback(() => {
|
||||
isPreview.current = false
|
||||
formRef.current?.submit()
|
||||
}, [])
|
||||
|
||||
const onClickPreview = useCallback(() => {
|
||||
isPreview.current = true
|
||||
formRef.current?.submit()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback((data: Record<string, any>) => {
|
||||
if (isPreview.current)
|
||||
handlePreviewChunks(data)
|
||||
else
|
||||
handleProcess(data)
|
||||
}, [handlePreviewChunks, handleProcess])
|
||||
|
||||
const handlePreviewFileChange = useCallback((file: DocumentItem) => {
|
||||
const { previewLocalFileRef } = dataSourceStore.getState()
|
||||
previewLocalFileRef.current = file
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handlePreviewOnlineDocumentChange = useCallback((page: NotionPage) => {
|
||||
const { previewOnlineDocumentRef } = dataSourceStore.getState()
|
||||
previewOnlineDocumentRef.current = page
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handlePreviewWebsiteChange = useCallback((website: CrawlResultItem) => {
|
||||
const { previewWebsitePageRef } = dataSourceStore.getState()
|
||||
previewWebsitePageRef.current = website
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handlePreviewOnlineDriveFileChange = useCallback((file: OnlineDriveFile) => {
|
||||
const { previewOnlineDriveFileRef } = dataSourceStore.getState()
|
||||
previewOnlineDriveFileRef.current = file
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const {
|
||||
onlineDocuments,
|
||||
onlineDriveFileList,
|
||||
selectedFileIds,
|
||||
setOnlineDocuments,
|
||||
setSelectedFileIds,
|
||||
setSelectedPagesId,
|
||||
} = dataSourceStore.getState()
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
const allIds = currentWorkspace?.pages.map(page => page.page_id) || []
|
||||
if (onlineDocuments.length < allIds.length) {
|
||||
const selectedPages = Array.from(allIds).map(pageId => PagesMapAndSelectedPagesId[pageId])
|
||||
setOnlineDocuments(selectedPages)
|
||||
setSelectedPagesId(new Set(allIds))
|
||||
}
|
||||
else {
|
||||
setOnlineDocuments([])
|
||||
setSelectedPagesId(new Set())
|
||||
}
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const allKeys = onlineDriveFileList.filter((item) => {
|
||||
return item.type !== 'bucket'
|
||||
}).map(file => file.id)
|
||||
if (selectedFileIds.length < allKeys.length)
|
||||
setSelectedFileIds(allKeys)
|
||||
else
|
||||
setSelectedFileIds([])
|
||||
}
|
||||
}, [PagesMapAndSelectedPagesId, currentWorkspace?.pages, dataSourceStore, datasourceType])
|
||||
|
||||
const clearDataSourceData = useCallback((dataSource: Datasource) => {
|
||||
if (dataSource.nodeData.provider_type === DatasourceType.onlineDocument)
|
||||
clearOnlineDocumentData()
|
||||
else if (dataSource.nodeData.provider_type === DatasourceType.websiteCrawl)
|
||||
clearWebsiteCrawlData()
|
||||
else if (dataSource.nodeData.provider_type === DatasourceType.onlineDrive)
|
||||
clearOnlineDriveData()
|
||||
}, [])
|
||||
|
||||
const handleSwitchDataSource = useCallback((dataSource: Datasource) => {
|
||||
const {
|
||||
setCurrentCredentialId,
|
||||
currentNodeIdRef,
|
||||
} = dataSourceStore.getState()
|
||||
clearDataSourceData(dataSource)
|
||||
setCurrentCredentialId('')
|
||||
currentNodeIdRef.current = dataSource.nodeId
|
||||
setDatasource(dataSource)
|
||||
}, [dataSourceStore])
|
||||
|
||||
const handleCredentialChange = useCallback((credentialId: string) => {
|
||||
const { setCurrentCredentialId } = dataSourceStore.getState()
|
||||
clearDataSourceData(datasource!)
|
||||
setCurrentCredentialId(credentialId)
|
||||
}, [dataSourceStore, datasource])
|
||||
|
||||
if (isFetchingPipelineInfo) {
|
||||
return (
|
||||
<Loading type='app' />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative flex h-[calc(100vh-56px)] w-full min-w-[1024px] overflow-x-auto rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle'
|
||||
>
|
||||
<div className='h-full min-w-0 flex-1'>
|
||||
<div className='flex h-full flex-col px-14'>
|
||||
<LeftHeader
|
||||
steps={steps}
|
||||
title={t('datasetPipeline.addDocuments.title')}
|
||||
currentStep={currentStep}
|
||||
/>
|
||||
<div className='grow overflow-y-auto'>
|
||||
{
|
||||
currentStep === 1 && (
|
||||
<div className='flex flex-col gap-y-5 pt-4'>
|
||||
<DataSourceOptions
|
||||
datasourceNodeId={datasource?.nodeId || ''}
|
||||
onSelect={handleSwitchDataSource}
|
||||
pipelineNodes={(pipelineInfo?.graph.nodes || []) as Node<DataSourceNodeType>[]}
|
||||
/>
|
||||
{datasourceType === DatasourceType.localFile && (
|
||||
<LocalFile
|
||||
allowedExtensions={datasource!.nodeData.fileExtensions || []}
|
||||
notSupportBatchUpload={notSupportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDocument && (
|
||||
<OnlineDocuments
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.websiteCrawl && (
|
||||
<WebsiteCrawl
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDrive && (
|
||||
<OnlineDrive
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
)}
|
||||
{isShowVectorSpaceFull && (
|
||||
<VectorSpaceFull />
|
||||
)}
|
||||
<Actions
|
||||
showSelect={showSelect}
|
||||
totalOptions={totalOptions}
|
||||
selectedOptions={selectedOptions}
|
||||
onSelectAll={handleSelectAll}
|
||||
disabled={nextBtnDisabled}
|
||||
handleNextStep={handleNextStep}
|
||||
tip={tip}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentStep === 2 && (
|
||||
<ProcessDocuments
|
||||
ref={formRef}
|
||||
dataSourceNodeId={datasource!.nodeId}
|
||||
isRunning={isPending}
|
||||
onProcess={onClickProcess}
|
||||
onPreview={onClickPreview}
|
||||
onSubmit={handleSubmit}
|
||||
onBack={handleBackStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentStep === 3 && (
|
||||
<Processing
|
||||
batchId={batchId}
|
||||
documents={documents}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview */}
|
||||
{
|
||||
currentStep === 1 && (
|
||||
<div className='h-full min-w-0 flex-1'>
|
||||
<div className='flex h-full flex-col pl-2 pt-2'>
|
||||
{currentLocalFile && (
|
||||
<FilePreview
|
||||
file={currentLocalFile}
|
||||
hidePreview={hidePreviewLocalFile}
|
||||
/>
|
||||
)}
|
||||
{currentDocument && (
|
||||
<OnlineDocumentPreview
|
||||
datasourceNodeId={datasource!.nodeId}
|
||||
currentPage={currentDocument}
|
||||
hidePreview={hidePreviewOnlineDocument}
|
||||
/>
|
||||
)}
|
||||
{currentWebsite && (
|
||||
<WebsitePreview
|
||||
currentWebsite={currentWebsite}
|
||||
hidePreview={hideWebsitePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentStep === 2 && (
|
||||
<div className='h-full min-w-0 flex-1'>
|
||||
<div className='flex h-full flex-col pl-2 pt-2'>
|
||||
<ChunkPreview
|
||||
dataSourceType={datasourceType as DatasourceType}
|
||||
localFiles={localFileList.map(file => file.file)}
|
||||
onlineDocuments={onlineDocuments}
|
||||
websitePages={websitePages}
|
||||
onlineDriveFiles={selectedOnlineDriveFileList}
|
||||
isIdle={isIdle}
|
||||
isPending={isPending && isPreview.current}
|
||||
estimateData={estimateData}
|
||||
onPreview={onClickPreview}
|
||||
handlePreviewFileChange={handlePreviewFileChange}
|
||||
handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange}
|
||||
handlePreviewWebsitePageChange={handlePreviewWebsiteChange}
|
||||
handlePreviewOnlineDriveFileChange={handlePreviewOnlineDriveFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CreateFormPipelineWrapper = () => {
|
||||
return (
|
||||
<DataSourceProvider>
|
||||
<CreateFormPipeline />
|
||||
</DataSourceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateFormPipelineWrapper
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { RiArrowLeftLine } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Effect from '@/app/components/base/effect'
|
||||
import type { Step } from './step-indicator'
|
||||
import StepIndicator from './step-indicator'
|
||||
import Link from 'next/link'
|
||||
|
||||
type LeftHeaderProps = {
|
||||
steps: Array<Step>
|
||||
title: string
|
||||
currentStep: number
|
||||
}
|
||||
|
||||
const LeftHeader = ({
|
||||
steps,
|
||||
title,
|
||||
currentStep,
|
||||
}: LeftHeaderProps) => {
|
||||
const { datasetId } = useParams()
|
||||
|
||||
return (
|
||||
<div className='relative flex flex-col gap-y-0.5 pb-2 pt-4'>
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<span className='system-2xs-semibold-uppercase bg-pipeline-add-documents-title-bg bg-clip-text text-transparent'>
|
||||
{title}
|
||||
</span>
|
||||
<span className='system-2xs-regular text-divider-regular'>/</span>
|
||||
<StepIndicator steps={steps} currentStep={currentStep} />
|
||||
</div>
|
||||
<div className='system-md-semibold text-text-primary'>
|
||||
{steps[currentStep - 1]?.label}
|
||||
</div>
|
||||
{currentStep !== steps.length && (
|
||||
<Link
|
||||
href={`/datasets/${datasetId}/documents`}
|
||||
replace
|
||||
>
|
||||
<Button
|
||||
variant='secondary-accent'
|
||||
className='absolute -left-11 top-3.5 size-9 rounded-full p-0'
|
||||
>
|
||||
<RiArrowLeftLine className='size-5 ' />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Effect className='left-8 top-[-34px] opacity-20' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(LeftHeader)
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PreviewContainer from '../../../preview/container'
|
||||
import { PreviewHeader } from '../../../preview/header'
|
||||
import type { CrawlResultItem, CustomFile, DocumentItem, FileIndexingEstimateResponse } from '@/models/datasets'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import PreviewDocumentPicker from '../../../common/document-picker/preview-document-picker'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { ChunkContainer, QAPreview } from '../../../chunk'
|
||||
import { FormattedText } from '../../../formatted-text/formatted'
|
||||
import { PreviewSlice } from '../../../formatted-text/flavours/preview-slice'
|
||||
import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { RiSearchEyeLine } from '@remixicon/react'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { getFileExtension } from '../data-source/online-drive/file-list/list/utils'
|
||||
|
||||
type ChunkPreviewProps = {
|
||||
dataSourceType: DatasourceType
|
||||
localFiles: CustomFile[]
|
||||
onlineDocuments: NotionPage[]
|
||||
websitePages: CrawlResultItem[]
|
||||
onlineDriveFiles: OnlineDriveFile[]
|
||||
isIdle: boolean
|
||||
isPending: boolean
|
||||
estimateData: FileIndexingEstimateResponse | undefined
|
||||
onPreview: () => void
|
||||
handlePreviewFileChange: (file: DocumentItem) => void
|
||||
handlePreviewOnlineDocumentChange: (page: NotionPage) => void
|
||||
handlePreviewWebsitePageChange: (page: CrawlResultItem) => void
|
||||
handlePreviewOnlineDriveFileChange: (file: OnlineDriveFile) => void
|
||||
}
|
||||
|
||||
const ChunkPreview = ({
|
||||
dataSourceType,
|
||||
localFiles,
|
||||
onlineDocuments,
|
||||
websitePages,
|
||||
onlineDriveFiles,
|
||||
isIdle,
|
||||
isPending,
|
||||
estimateData,
|
||||
onPreview,
|
||||
handlePreviewFileChange,
|
||||
handlePreviewOnlineDocumentChange,
|
||||
handlePreviewWebsitePageChange,
|
||||
handlePreviewOnlineDriveFileChange,
|
||||
}: ChunkPreviewProps) => {
|
||||
const { t } = useTranslation()
|
||||
const currentDocForm = useDatasetDetailContextWithSelector(s => s.dataset?.doc_form)
|
||||
|
||||
const [previewFile, setPreviewFile] = useState<DocumentItem>(localFiles[0] as DocumentItem)
|
||||
const [previewOnlineDocument, setPreviewOnlineDocument] = useState<NotionPage>(onlineDocuments[0])
|
||||
const [previewWebsitePage, setPreviewWebsitePage] = useState<CrawlResultItem>(websitePages[0])
|
||||
const [previewOnlineDriveFile, setPreviewOnlineDriveFile] = useState<OnlineDriveFile>(onlineDriveFiles[0])
|
||||
|
||||
return (
|
||||
<PreviewContainer
|
||||
header={<PreviewHeader
|
||||
title={t('datasetCreation.stepTwo.preview')}
|
||||
>
|
||||
<div className='flex items-center gap-1'>
|
||||
{dataSourceType === DatasourceType.localFile
|
||||
&& <PreviewDocumentPicker
|
||||
files={localFiles as Array<Required<CustomFile>>}
|
||||
onChange={(selected) => {
|
||||
setPreviewFile(selected)
|
||||
handlePreviewFileChange(selected)
|
||||
}}
|
||||
value={previewFile}
|
||||
/>
|
||||
}
|
||||
{dataSourceType === DatasourceType.onlineDocument
|
||||
&& <PreviewDocumentPicker
|
||||
files={
|
||||
onlineDocuments.map(page => ({
|
||||
id: page.page_id,
|
||||
name: page.page_name,
|
||||
extension: 'md',
|
||||
}))
|
||||
}
|
||||
onChange={(selected) => {
|
||||
const selectedPage = onlineDocuments.find(page => page.page_id === selected.id)
|
||||
setPreviewOnlineDocument(selectedPage!)
|
||||
handlePreviewOnlineDocumentChange(selectedPage!)
|
||||
}}
|
||||
value={{
|
||||
id: previewOnlineDocument?.page_id || '',
|
||||
name: previewOnlineDocument?.page_name || '',
|
||||
extension: 'md',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{dataSourceType === DatasourceType.websiteCrawl
|
||||
&& <PreviewDocumentPicker
|
||||
files={
|
||||
websitePages.map(page => ({
|
||||
id: page.source_url,
|
||||
name: page.title,
|
||||
extension: 'md',
|
||||
}))
|
||||
}
|
||||
onChange={(selected) => {
|
||||
const selectedPage = websitePages.find(page => page.source_url === selected.id)
|
||||
setPreviewWebsitePage(selectedPage!)
|
||||
handlePreviewWebsitePageChange(selectedPage!)
|
||||
}}
|
||||
value={
|
||||
{
|
||||
id: previewWebsitePage?.source_url || '',
|
||||
name: previewWebsitePage?.title || '',
|
||||
extension: 'md',
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
{dataSourceType === DatasourceType.onlineDrive
|
||||
&& <PreviewDocumentPicker
|
||||
files={
|
||||
onlineDriveFiles.map(file => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
extension: getFileExtension(previewOnlineDriveFile?.name),
|
||||
}))
|
||||
}
|
||||
onChange={(selected) => {
|
||||
const selectedFile = onlineDriveFiles.find(file => file.id === selected.id)
|
||||
setPreviewOnlineDriveFile(selectedFile!)
|
||||
handlePreviewOnlineDriveFileChange(selectedFile!)
|
||||
}}
|
||||
value={
|
||||
{
|
||||
id: previewOnlineDriveFile?.id || '',
|
||||
name: previewOnlineDriveFile?.name || '',
|
||||
extension: getFileExtension(previewOnlineDriveFile?.name),
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
{
|
||||
currentDocForm !== ChunkingMode.qa
|
||||
&& <Badge text={t('datasetCreation.stepTwo.previewChunkCount', {
|
||||
count: estimateData?.total_segments || 0,
|
||||
}) as string}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</PreviewHeader>}
|
||||
className='relative flex h-full w-full shrink-0'
|
||||
mainClassName='space-y-6'
|
||||
>
|
||||
{!isPending && currentDocForm === ChunkingMode.qa && estimateData?.qa_preview && (
|
||||
estimateData?.qa_preview.map((item, index) => (
|
||||
<ChunkContainer
|
||||
key={`${item.question}-${index}`}
|
||||
label={`Chunk-${index + 1}`}
|
||||
characterCount={item.question.length + item.answer.length}
|
||||
>
|
||||
<QAPreview qa={item} />
|
||||
</ChunkContainer>
|
||||
))
|
||||
)}
|
||||
{!isPending && currentDocForm === ChunkingMode.text && estimateData?.preview && (
|
||||
estimateData?.preview.map((item, index) => (
|
||||
<ChunkContainer
|
||||
key={`${item.content}-${index}`}
|
||||
label={`Chunk-${index + 1}`}
|
||||
characterCount={item.content.length}
|
||||
>
|
||||
{item.content}
|
||||
</ChunkContainer>
|
||||
))
|
||||
)}
|
||||
{!isPending && currentDocForm === ChunkingMode.parentChild && estimateData?.preview && (
|
||||
estimateData?.preview?.map((item, index) => {
|
||||
const indexForLabel = index + 1
|
||||
return (
|
||||
<ChunkContainer
|
||||
key={`${item.content}-${index}`}
|
||||
label={`Chunk-${indexForLabel}`}
|
||||
characterCount={item.content.length}
|
||||
>
|
||||
<FormattedText>
|
||||
{item.child_chunks.map((child, index) => {
|
||||
const indexForLabel = index + 1
|
||||
return (
|
||||
<PreviewSlice
|
||||
key={child}
|
||||
label={`C-${indexForLabel}`}
|
||||
text={child}
|
||||
tooltip={`Child-chunk-${indexForLabel} · ${child.length} Characters`}
|
||||
labelInnerClassName='text-[10px] font-semibold align-bottom leading-7'
|
||||
dividerClassName='leading-7'
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</FormattedText>
|
||||
</ChunkContainer>
|
||||
)
|
||||
})
|
||||
)}
|
||||
{isIdle && (
|
||||
<div className='flex h-full w-full items-center justify-center'>
|
||||
<div className='flex flex-col items-center justify-center gap-3 pb-4'>
|
||||
<RiSearchEyeLine className='size-10 text-text-empty-state-icon' />
|
||||
<p className='text-sm text-text-tertiary'>
|
||||
{t('datasetCreation.stepTwo.previewChunkTip')}
|
||||
</p>
|
||||
<Button onClick={onPreview}>
|
||||
{t('datasetPipeline.addDocuments.stepTwo.previewChunks')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isPending && (
|
||||
<div className='h-full w-full space-y-6 overflow-hidden'>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<SkeletonContainer key={i}>
|
||||
<SkeletonRow>
|
||||
<SkeletonRectangle className='w-20' />
|
||||
<SkeletonPoint />
|
||||
<SkeletonRectangle className='w-24' />
|
||||
</SkeletonRow>
|
||||
<SkeletonRectangle className='w-full' />
|
||||
<SkeletonRectangle className='w-full' />
|
||||
<SkeletonRectangle className='w-[422px]' />
|
||||
</SkeletonContainer>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PreviewContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ChunkPreview)
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from './loading'
|
||||
import type { CustomFile as File } from '@/models/datasets'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useFilePreview } from '@/service/use-common'
|
||||
import DocumentFileIcon from '../../../common/document-file-icon'
|
||||
import { formatFileSize, formatNumberAbbreviated } from '@/utils/format'
|
||||
|
||||
type FilePreviewProps = {
|
||||
file: File
|
||||
hidePreview: () => void
|
||||
}
|
||||
|
||||
const FilePreview = ({
|
||||
file,
|
||||
hidePreview,
|
||||
}: FilePreviewProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: fileData, isFetching } = useFilePreview(file.id || '')
|
||||
|
||||
const fileName = useMemo(() => {
|
||||
if (!file)
|
||||
return ''
|
||||
const arr = file.name.split('.')
|
||||
return arr.slice(0, -1).join()
|
||||
}, [file])
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col rounded-t-xl border-l border-t border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5'>
|
||||
<div className='flex gap-x-2 border-b border-divider-subtle pb-3 pl-6 pr-4 pt-4'>
|
||||
<div className='flex grow flex-col gap-y-1'>
|
||||
<div className='system-2xs-semibold-uppercase text-text-accent'>{t('datasetPipeline.addDocuments.stepOne.preview')}</div>
|
||||
<div className='title-md-semi-bold text-tex-primary'>{`${fileName}.${file.extension || ''}`}</div>
|
||||
<div className='system-xs-medium flex items-center gap-x-1 text-text-tertiary'>
|
||||
<DocumentFileIcon
|
||||
className='size-3.5 shrink-0'
|
||||
name={file.name}
|
||||
extension={file.extension}
|
||||
/>
|
||||
<span className='uppercase'>{file.extension}</span>
|
||||
<span>·</span>
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
{fileData && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{`${formatNumberAbbreviated(fileData.content.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-8 w-8 shrink-0 items-center justify-center'
|
||||
onClick={hidePreview}
|
||||
>
|
||||
<RiCloseLine className='size-[18px]' />
|
||||
</button>
|
||||
</div>
|
||||
{isFetching && (
|
||||
<div className='grow'>
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
{!isFetching && fileData && (
|
||||
<div className='body-md-regular grow overflow-hidden px-6 py-5 text-text-secondary'>
|
||||
{fileData.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePreview
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col gap-y-3 overflow-hidden bg-gradient-to-b from-components-panel-bg-transparent to-components-panel-bg px-6 py-5'>
|
||||
<SkeletonContainer className='w-full gap-0'>
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-3/5' />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className='w-full gap-0'>
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-[70%]' />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className='w-full gap-0'>
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-[56%]' />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className='w-full gap-0'>
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-3/5' />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className='w-full gap-0'>
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-3/5' />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className='w-full gap-0'>
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-full' />
|
||||
<SkeletonRectangle className='my-1.5 w-1/2' />
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Loading)
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { formatNumberAbbreviated } from '@/utils/format'
|
||||
import Loading from './loading'
|
||||
import { Notion } from '@/app/components/base/icons/src/public/common'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { usePreviewOnlineDocument } from '@/service/use-pipeline'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { useDataSourceStore } from '../data-source/store'
|
||||
|
||||
type OnlineDocumentPreviewProps = {
|
||||
currentPage: NotionPage
|
||||
datasourceNodeId: string
|
||||
hidePreview: () => void
|
||||
}
|
||||
|
||||
const OnlineDocumentPreview = ({
|
||||
currentPage,
|
||||
datasourceNodeId,
|
||||
hidePreview,
|
||||
}: OnlineDocumentPreviewProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [content, setContent] = useState('')
|
||||
const pipelineId = useDatasetDetailContextWithSelector(state => state.dataset?.pipeline_id)
|
||||
const { mutateAsync: getOnlineDocumentContent, isPending } = usePreviewOnlineDocument()
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
useEffect(() => {
|
||||
const { currentCredentialId } = dataSourceStore.getState()
|
||||
getOnlineDocumentContent({
|
||||
workspaceID: currentPage.workspace_id,
|
||||
pageID: currentPage.page_id,
|
||||
pageType: currentPage.type,
|
||||
pipelineId: pipelineId || '',
|
||||
datasourceNodeId,
|
||||
credentialId: currentCredentialId,
|
||||
}, {
|
||||
onSuccess(data) {
|
||||
setContent(data.content)
|
||||
},
|
||||
onError(error) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [currentPage.page_id])
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col rounded-t-xl border-l border-t border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5'>
|
||||
<div className='flex gap-x-2 border-b border-divider-subtle pb-3 pl-6 pr-4 pt-4'>
|
||||
<div className='flex grow flex-col gap-y-1'>
|
||||
<div className='system-2xs-semibold-uppercase text-text-accent'>{t('datasetPipeline.addDocuments.stepOne.preview')}</div>
|
||||
<div className='title-md-semi-bold text-tex-primary'>{currentPage?.page_name}</div>
|
||||
<div className='system-xs-medium flex items-center gap-x-1 text-text-tertiary'>
|
||||
<Notion className='size-3.5' />
|
||||
<span>{currentPage.type}</span>
|
||||
<span>·</span>
|
||||
<span>{`${formatNumberAbbreviated(content.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-8 w-8 shrink-0 items-center justify-center'
|
||||
onClick={hidePreview}
|
||||
>
|
||||
<RiCloseLine className='size-[18px]' />
|
||||
</button>
|
||||
</div>
|
||||
{isPending && (
|
||||
<div className='grow'>
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
{!isPending && content && (
|
||||
<div className='body-md-regular grow overflow-hidden px-6 py-5 text-text-secondary'>
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnlineDocumentPreview
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
import { RiCloseLine, RiGlobalLine } from '@remixicon/react'
|
||||
import { formatNumberAbbreviated } from '@/utils/format'
|
||||
|
||||
type WebsitePreviewProps = {
|
||||
currentWebsite: CrawlResultItem
|
||||
hidePreview: () => void
|
||||
}
|
||||
|
||||
const WebsitePreview = ({
|
||||
currentWebsite,
|
||||
hidePreview,
|
||||
}: WebsitePreviewProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col rounded-t-xl border-l border-t border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5'>
|
||||
<div className='flex gap-x-2 border-b border-divider-subtle pb-3 pl-6 pr-4 pt-4'>
|
||||
<div className='flex grow flex-col gap-y-1'>
|
||||
<div className='system-2xs-semibold-uppercase'>{t('datasetPipeline.addDocuments.stepOne.preview')}</div>
|
||||
<div className='title-md-semi-bold text-tex-primary'>{currentWebsite.title}</div>
|
||||
<div className='system-xs-medium flex gap-x-1 text-text-tertiary'>
|
||||
<RiGlobalLine className='size-3.5' />
|
||||
<span className='uppercase' title={currentWebsite.source_url}>{currentWebsite.source_url}</span>
|
||||
<span>·</span>
|
||||
<span>·</span>
|
||||
<span>{`${formatNumberAbbreviated(currentWebsite.content.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-8 w-8 shrink-0 items-center justify-center'
|
||||
onClick={hidePreview}
|
||||
>
|
||||
<RiCloseLine className='size-[18px]' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='body-md-regular grow overflow-hidden px-6 py-5 text-text-secondary'>
|
||||
{currentWebsite.content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebsitePreview
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowLeftLine } from '@remixicon/react'
|
||||
|
||||
type ActionsProps = {
|
||||
onBack: () => void
|
||||
runDisabled?: boolean
|
||||
onProcess: () => void
|
||||
}
|
||||
|
||||
const Actions = ({
|
||||
onBack,
|
||||
runDisabled,
|
||||
onProcess,
|
||||
}: ActionsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={onBack}
|
||||
className='gap-x-0.5'
|
||||
>
|
||||
<RiArrowLeftLine className='size-4' />
|
||||
<span className='px-0.5'>{t('datasetPipeline.operations.dataSource')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
disabled={runDisabled}
|
||||
onClick={onProcess}
|
||||
>
|
||||
{t('datasetPipeline.operations.saveAndProcess')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Actions)
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useAppForm } from '@/app/components/base/form'
|
||||
import BaseField from '@/app/components/base/form/form-scenarios/base/field'
|
||||
import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useCallback, useImperativeHandle } from 'react'
|
||||
import type { ZodSchema } from 'zod'
|
||||
import Header from './header'
|
||||
|
||||
type OptionsProps = {
|
||||
initialData: Record<string, any>
|
||||
configurations: BaseConfiguration[]
|
||||
schema: ZodSchema
|
||||
onSubmit: (data: Record<string, any>) => void
|
||||
onPreview: () => void
|
||||
ref: React.RefObject<any>
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
const Form = ({
|
||||
initialData,
|
||||
configurations,
|
||||
schema,
|
||||
onSubmit,
|
||||
onPreview,
|
||||
ref,
|
||||
isRunning,
|
||||
}: OptionsProps) => {
|
||||
const form = useAppForm({
|
||||
defaultValues: initialData,
|
||||
validators: {
|
||||
onSubmit: ({ value }) => {
|
||||
const result = schema.safeParse(value)
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
const firstIssue = issues[0]
|
||||
const errorMessage = `"${firstIssue.path.join('.')}" ${firstIssue.message}`
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
return errorMessage
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
onSubmit(value)
|
||||
},
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
submit: () => {
|
||||
form.handleSubmit()
|
||||
},
|
||||
}
|
||||
}, [form])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
form.reset()
|
||||
}, [form])
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col rounded-lg border border-components-panel-border bg-components-panel-bg'
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
form.handleSubmit()
|
||||
}}
|
||||
>
|
||||
<form.Subscribe
|
||||
selector={state => state.isDirty}
|
||||
children={isDirty => (
|
||||
<Header
|
||||
onReset={handleReset}
|
||||
resetDisabled={!isDirty}
|
||||
onPreview={onPreview}
|
||||
previewDisabled={isRunning}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className='flex flex-col gap-3 border-t border-divider-subtle px-4 py-3'>
|
||||
{configurations.map((config, index) => {
|
||||
const FieldComponent = BaseField({
|
||||
initialData,
|
||||
config,
|
||||
})
|
||||
return <FieldComponent key={index} form={form} />
|
||||
})}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default Form
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiSearchEyeLine } from '@remixicon/react'
|
||||
|
||||
type HeaderProps = {
|
||||
onReset: () => void
|
||||
resetDisabled: boolean
|
||||
previewDisabled: boolean
|
||||
onPreview?: () => void
|
||||
}
|
||||
|
||||
const Header = ({
|
||||
onReset,
|
||||
resetDisabled,
|
||||
previewDisabled,
|
||||
onPreview,
|
||||
}: HeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-1 px-4 py-2'>
|
||||
<div className='system-sm-semibold-uppercase grow text-text-secondary'>
|
||||
{t('datasetPipeline.addDocuments.stepTwo.chunkSettings')}
|
||||
</div>
|
||||
<Button variant='ghost' disabled={resetDisabled} onClick={onReset}>
|
||||
{t('common.operation.reset')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary-accent'
|
||||
onClick={onPreview}
|
||||
className='gap-x-0.5'
|
||||
disabled={previewDisabled}
|
||||
>
|
||||
<RiSearchEyeLine className='size-4' />
|
||||
<span className='px-0.5'>{t('datasetPipeline.addDocuments.stepTwo.previewChunks')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { usePublishedPipelineProcessingParams } from '@/service/use-pipeline'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
|
||||
export const useInputVariables = (datasourceNodeId: string) => {
|
||||
const pipelineId = useDatasetDetailContextWithSelector(state => state.dataset?.pipeline_id)
|
||||
const { data: paramsConfig, isFetching: isFetchingParams } = usePublishedPipelineProcessingParams({
|
||||
pipeline_id: pipelineId!,
|
||||
node_id: datasourceNodeId,
|
||||
})
|
||||
|
||||
return {
|
||||
paramsConfig,
|
||||
isFetchingParams,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils'
|
||||
import { useInputVariables } from './hooks'
|
||||
import Form from './form'
|
||||
import Actions from './actions'
|
||||
import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields'
|
||||
|
||||
type ProcessDocumentsProps = {
|
||||
dataSourceNodeId: string
|
||||
ref: React.RefObject<any>
|
||||
isRunning: boolean
|
||||
onProcess: () => void
|
||||
onPreview: () => void
|
||||
onSubmit: (data: Record<string, any>) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const ProcessDocuments = ({
|
||||
dataSourceNodeId,
|
||||
isRunning,
|
||||
onProcess,
|
||||
onPreview,
|
||||
onSubmit,
|
||||
onBack,
|
||||
ref,
|
||||
}: ProcessDocumentsProps) => {
|
||||
const { isFetchingParams, paramsConfig } = useInputVariables(dataSourceNodeId)
|
||||
const initialData = useInitialData(paramsConfig?.variables || [])
|
||||
const configurations = useConfigurations(paramsConfig?.variables || [])
|
||||
const schema = generateZodSchema(configurations)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-y-4 pt-4'>
|
||||
<Form
|
||||
ref={ref}
|
||||
initialData={initialData}
|
||||
configurations={configurations}
|
||||
schema={schema}
|
||||
onSubmit={onSubmit}
|
||||
onPreview={onPreview}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
<Actions runDisabled={isFetchingParams || isRunning} onBack={onBack} onProcess={onProcess} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ProcessDocuments)
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiAedFill,
|
||||
RiArrowRightLine,
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiLoader2Fill,
|
||||
RiTerminalBoxLine,
|
||||
} from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { IndexingStatusResponse } from '@/models/datasets'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import PriorityLabel from '@/app/components/billing/priority-label'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useInvalidDocumentList } from '@/service/knowledge/use-document'
|
||||
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
|
||||
import RuleDetail from './rule-detail'
|
||||
import type { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { DatasourceType, type InitialDocumentDetail } from '@/models/pipeline'
|
||||
import { useIndexingStatusBatch, useProcessRule } from '@/service/knowledge/use-dataset'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
|
||||
import Link from 'next/link'
|
||||
|
||||
type EmbeddingProcessProps = {
|
||||
datasetId: string
|
||||
batchId: string
|
||||
documents?: InitialDocumentDetail[]
|
||||
indexingType?: IndexingType
|
||||
retrievalMethod?: RETRIEVE_METHOD
|
||||
}
|
||||
|
||||
const EmbeddingProcess = ({
|
||||
datasetId,
|
||||
batchId,
|
||||
documents = [],
|
||||
indexingType,
|
||||
retrievalMethod,
|
||||
}: EmbeddingProcessProps) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
const [indexingStatusBatchDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse[]>([])
|
||||
const [shouldPoll, setShouldPoll] = useState(true)
|
||||
const { mutateAsync: fetchIndexingStatus } = useIndexingStatusBatch({ datasetId, batchId })
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>
|
||||
|
||||
const fetchData = async () => {
|
||||
await fetchIndexingStatus(undefined, {
|
||||
onSuccess: (res) => {
|
||||
const indexingStatusDetailList = res.data
|
||||
setIndexingStatusDetail(indexingStatusDetailList)
|
||||
const isCompleted = indexingStatusDetailList.every(indexingStatusDetail => ['completed', 'error', 'paused'].includes(indexingStatusDetail.indexing_status))
|
||||
if (isCompleted)
|
||||
setShouldPoll(false)
|
||||
},
|
||||
onSettled: () => {
|
||||
if (shouldPoll)
|
||||
timeoutId = setTimeout(fetchData, 2500)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fetchData()
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [shouldPoll])
|
||||
|
||||
// get rule
|
||||
const firstDocument = documents[0]
|
||||
const { data: ruleDetail } = useProcessRule(firstDocument.id)
|
||||
|
||||
const invalidDocumentList = useInvalidDocumentList()
|
||||
const navToDocumentList = () => {
|
||||
invalidDocumentList()
|
||||
router.push(`/datasets/${datasetId}/documents`)
|
||||
}
|
||||
const apiReferenceUrl = useDatasetApiAccessUrl()
|
||||
|
||||
const isEmbeddingWaiting = useMemo(() => {
|
||||
if (!indexingStatusBatchDetail.length) return false
|
||||
return indexingStatusBatchDetail.every(indexingStatusDetail => ['waiting'].includes(indexingStatusDetail?.indexing_status || ''))
|
||||
}, [indexingStatusBatchDetail])
|
||||
const isEmbedding = useMemo(() => {
|
||||
if (!indexingStatusBatchDetail.length) return false
|
||||
return indexingStatusBatchDetail.some(indexingStatusDetail => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || ''))
|
||||
}, [indexingStatusBatchDetail])
|
||||
const isEmbeddingCompleted = useMemo(() => {
|
||||
if (!indexingStatusBatchDetail.length) return false
|
||||
return indexingStatusBatchDetail.every(indexingStatusDetail => ['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status || ''))
|
||||
}, [indexingStatusBatchDetail])
|
||||
|
||||
const getSourceName = (id: string) => {
|
||||
const doc = documents.find(document => document.id === id)
|
||||
return doc?.name
|
||||
}
|
||||
const getFileType = (name?: string) => name?.split('.').pop() || 'txt'
|
||||
const getSourcePercent = (detail: IndexingStatusResponse) => {
|
||||
const completedCount = detail.completed_segments || 0
|
||||
const totalCount = detail.total_segments || 0
|
||||
if (totalCount === 0)
|
||||
return 0
|
||||
const percent = Math.round(completedCount * 100 / totalCount)
|
||||
return percent > 100 ? 100 : percent
|
||||
}
|
||||
const getSourceType = (id: string) => {
|
||||
const doc = documents.find(document => document.id === id)
|
||||
return doc?.data_source_type
|
||||
}
|
||||
const getIcon = (id: string) => {
|
||||
const doc = documents.find(document => document.id === id)
|
||||
|
||||
return doc?.data_source_info.notion_page_icon
|
||||
}
|
||||
const isSourceEmbedding = (detail: IndexingStatusResponse) =>
|
||||
['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'].includes(detail.indexing_status || '')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col gap-y-3'>
|
||||
<div className='system-md-semibold-uppercase flex items-center gap-x-1 text-text-secondary'>
|
||||
{(isEmbeddingWaiting || isEmbedding) && (
|
||||
<>
|
||||
<RiLoader2Fill className='size-4 animate-spin' />
|
||||
<span>
|
||||
{isEmbeddingWaiting ? t('datasetDocuments.embedding.waiting') : t('datasetDocuments.embedding.processing')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{isEmbeddingCompleted && t('datasetDocuments.embedding.completed')}
|
||||
</div>
|
||||
{
|
||||
enableBilling && plan.type !== Plan.team && (
|
||||
<div className='flex h-[52px] items-center gap-x-2 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2.5 pl-3 shadow-xs shadow-shadow-shadow-3'>
|
||||
<div className='flex shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 shadow-md shadow-shadow-shadow-5'>
|
||||
<RiAedFill className='size-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
<div className='system-md-medium grow text-text-primary'>
|
||||
{t('billing.plansCommon.documentProcessingPriorityUpgrade')}
|
||||
</div>
|
||||
<UpgradeBtn loc='knowledge-speed-up' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='flex flex-col gap-0.5 pb-2'>
|
||||
{indexingStatusBatchDetail.map(indexingStatusDetail => (
|
||||
<div
|
||||
key={indexingStatusDetail.id}
|
||||
className={cn(
|
||||
'relative h-[26px] overflow-hidden rounded-md bg-components-progress-bar-bg',
|
||||
indexingStatusDetail.indexing_status === 'error' && 'bg-state-destructive-hover-alt',
|
||||
)}
|
||||
>
|
||||
{isSourceEmbedding(indexingStatusDetail) && (
|
||||
<div
|
||||
className='absolute left-0 top-0 h-full min-w-0.5 border-r-[2px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress'
|
||||
style={{ width: `${getSourcePercent(indexingStatusDetail)}%` }}
|
||||
/>
|
||||
)}
|
||||
<div className='z-[1] flex h-full items-center gap-1 pl-[6px] pr-2'>
|
||||
{getSourceType(indexingStatusDetail.id) === DatasourceType.localFile && (
|
||||
<DocumentFileIcon
|
||||
size='sm'
|
||||
className='shrink-0'
|
||||
name={getSourceName(indexingStatusDetail.id)}
|
||||
extension={getFileType(getSourceName(indexingStatusDetail.id))}
|
||||
/>
|
||||
)}
|
||||
{getSourceType(indexingStatusDetail.id) === DatasourceType.onlineDocument && (
|
||||
<NotionIcon
|
||||
className='shrink-0'
|
||||
type='page'
|
||||
src={getIcon(indexingStatusDetail.id)}
|
||||
/>
|
||||
)}
|
||||
<div className='flex w-0 grow items-center gap-1' title={getSourceName(indexingStatusDetail.id)}>
|
||||
<div className='system-xs-medium truncate text-text-secondary'>
|
||||
{getSourceName(indexingStatusDetail.id)}
|
||||
</div>
|
||||
{
|
||||
enableBilling && (
|
||||
<PriorityLabel className='ml-0' />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{isSourceEmbedding(indexingStatusDetail) && (
|
||||
<div className='shrink-0 text-xs text-text-secondary'>{`${getSourcePercent(indexingStatusDetail)}%`}</div>
|
||||
)}
|
||||
{indexingStatusDetail.indexing_status === 'error' && (
|
||||
<Tooltip
|
||||
popupClassName='px-4 py-[14px] max-w-60 body-xs-regular text-text-secondary border-[0.5px] border-components-panel-border rounded-xl'
|
||||
offset={4}
|
||||
popupContent={indexingStatusDetail.error}
|
||||
>
|
||||
<span>
|
||||
<RiErrorWarningFill className='size-4 shrink-0 text-text-destructive' />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{indexingStatusDetail.indexing_status === 'completed' && (
|
||||
<RiCheckboxCircleFill className='size-4 shrink-0 text-text-success' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
|
||||
<RuleDetail
|
||||
sourceData={ruleDetail}
|
||||
indexingType={indexingType}
|
||||
retrievalMethod={retrievalMethod}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-6 flex items-center gap-x-2 py-2'>
|
||||
<Link
|
||||
href={apiReferenceUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Button
|
||||
className='w-fit gap-x-0.5 px-3'
|
||||
>
|
||||
<RiTerminalBoxLine className='size-4' />
|
||||
<span className='px-0.5'>Access the API</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
className='w-fit gap-x-0.5 px-3'
|
||||
variant='primary'
|
||||
onClick={navToDocumentList}
|
||||
>
|
||||
<span className='px-0.5'>{t('datasetCreation.stepThree.navTo')}</span>
|
||||
<RiArrowRightLine className='size-4 stroke-current stroke-1' />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmbeddingProcess
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ProcessMode, type ProcessRuleResponse } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata'
|
||||
import Image from 'next/image'
|
||||
import { indexMethodIcon, retrievalIcon } from '@/app/components/datasets/create/icons'
|
||||
|
||||
type RuleDetailProps = {
|
||||
sourceData?: ProcessRuleResponse
|
||||
indexingType?: IndexingType
|
||||
retrievalMethod?: RETRIEVE_METHOD
|
||||
}
|
||||
|
||||
const RuleDetail = ({
|
||||
sourceData,
|
||||
indexingType,
|
||||
retrievalMethod,
|
||||
}: RuleDetailProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getValue = useCallback((field: string) => {
|
||||
let value = '-'
|
||||
switch (field) {
|
||||
case 'mode':
|
||||
value = !sourceData?.mode
|
||||
? value
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
: sourceData.mode === ProcessMode.general
|
||||
? (t('datasetDocuments.embedding.custom') as string)
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
: `${t('datasetDocuments.embedding.hierarchical')} · ${sourceData?.rules?.parent_mode === 'paragraph'
|
||||
? t('dataset.parentMode.paragraph')
|
||||
: t('dataset.parentMode.fullDoc')}`
|
||||
break
|
||||
}
|
||||
return value
|
||||
}, [sourceData, t])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<FieldInfo
|
||||
label={t('datasetDocuments.embedding.mode')}
|
||||
displayedValue={getValue('mode')}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('datasetCreation.stepTwo.indexMode')}
|
||||
displayedValue={t(`datasetCreation.stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`) as string}
|
||||
valueIcon={
|
||||
<Image
|
||||
className='size-4'
|
||||
src={
|
||||
indexingType === IndexingType.ECONOMICAL
|
||||
? indexMethodIcon.economical
|
||||
: indexMethodIcon.high_quality
|
||||
}
|
||||
alt=''
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('datasetSettings.form.retrievalSetting.title')}
|
||||
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod}.title`) as string}
|
||||
valueIcon={
|
||||
<Image
|
||||
className='size-4'
|
||||
src={
|
||||
retrievalMethod === RETRIEVE_METHOD.fullText
|
||||
? retrievalIcon.fullText
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
: retrievalMethod === RETRIEVE_METHOD.hybrid
|
||||
? retrievalIcon.hybrid
|
||||
: retrievalIcon.vector
|
||||
}
|
||||
alt=''
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(RuleDetail)
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiBookOpenLine } from '@remixicon/react'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import EmbeddingProcess from './embedding-process'
|
||||
import type { InitialDocumentDetail } from '@/models/pipeline'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
|
||||
type ProcessingProps = {
|
||||
batchId: string
|
||||
documents: InitialDocumentDetail[]
|
||||
}
|
||||
|
||||
const Processing = ({
|
||||
batchId,
|
||||
documents,
|
||||
}: ProcessingProps) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const datasetId = useDatasetDetailContextWithSelector(s => s.dataset?.id)
|
||||
const indexingType = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique)
|
||||
const retrievalMethod = useDatasetDetailContextWithSelector(s => s.dataset?.retrieval_model_dict.search_method)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full justify-center overflow-hidden'>
|
||||
<div className='h-full w-3/5 overflow-y-auto pb-8 pt-10'>
|
||||
<div className='max-w-[640px]'>
|
||||
<EmbeddingProcess
|
||||
datasetId={datasetId!}
|
||||
batchId={batchId}
|
||||
documents={documents}
|
||||
indexingType={indexingType}
|
||||
retrievalMethod={retrievalMethod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-2/5 pr-8 pt-[88px]'>
|
||||
<div className='flex w-[328px] flex-col gap-3 rounded-xl bg-background-section p-6'>
|
||||
<div className='flex size-10 items-center justify-center rounded-[10px] bg-components-card-bg shadow-lg shadow-shadow-shadow-5'>
|
||||
<RiBookOpenLine className='size-5 text-text-accent' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-2'>
|
||||
<div className='system-xl-semibold text-text-secondary'>{t('datasetCreation.stepThree.sideTipTitle')}</div>
|
||||
<div className='system-sm-regular text-text-tertiary'>{t('datasetCreation.stepThree.sideTipContent')}</div>
|
||||
<a
|
||||
href={docLink('/guides/knowledge-base/integrate-knowledge-within-application')}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='system-sm-regular text-text-accent'
|
||||
>
|
||||
{t('datasetPipeline.addDocuments.stepThree.learnMore')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Processing
|
||||
@@ -0,0 +1,33 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import React from 'react'
|
||||
|
||||
export type Step = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type StepIndicatorProps = {
|
||||
currentStep: number
|
||||
steps: Step[]
|
||||
}
|
||||
|
||||
const StepIndicator = ({
|
||||
currentStep,
|
||||
steps,
|
||||
}: StepIndicatorProps) => {
|
||||
return (
|
||||
<div className='flex gap-x-1'>
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index === currentStep - 1
|
||||
return (
|
||||
<div
|
||||
key={step.value}
|
||||
className={cn('h-1 w-1 rounded-lg bg-divider-solid', isActive && 'w-2 bg-state-accent-solid')}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(StepIndicator)
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum AddDocumentsStep {
|
||||
dataSource = 'dataSource',
|
||||
processDocuments = 'processDocuments',
|
||||
processingDocuments = 'processingDocuments',
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
useCSVDownloader,
|
||||
} from 'react-papaparse'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import I18n from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
|
||||
const CSV_TEMPLATE_QA_EN = [
|
||||
['question', 'answer'],
|
||||
['question1', 'answer1'],
|
||||
['question2', 'answer2'],
|
||||
]
|
||||
const CSV_TEMPLATE_QA_CN = [
|
||||
['问题', '答案'],
|
||||
['问题 1', '答案 1'],
|
||||
['问题 2', '答案 2'],
|
||||
]
|
||||
const CSV_TEMPLATE_EN = [
|
||||
['segment content'],
|
||||
['content1'],
|
||||
['content2'],
|
||||
]
|
||||
const CSV_TEMPLATE_CN = [
|
||||
['分段内容'],
|
||||
['内容 1'],
|
||||
['内容 2'],
|
||||
]
|
||||
|
||||
const CSVDownload: FC<{ docForm: ChunkingMode }> = ({ docForm }) => {
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18n)
|
||||
const { CSVDownloader, Type } = useCSVDownloader()
|
||||
|
||||
const getTemplate = () => {
|
||||
if (locale === LanguagesSupported[1]) {
|
||||
if (docForm === ChunkingMode.qa)
|
||||
return CSV_TEMPLATE_QA_CN
|
||||
return CSV_TEMPLATE_CN
|
||||
}
|
||||
if (docForm === ChunkingMode.qa)
|
||||
return CSV_TEMPLATE_QA_EN
|
||||
return CSV_TEMPLATE_EN
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-6'>
|
||||
<div className='text-sm font-medium text-text-primary'>{t('share.generation.csvStructureTitle')}</div>
|
||||
<div className='mt-2 max-h-[500px] overflow-auto'>
|
||||
{docForm === ChunkingMode.qa && (
|
||||
<table className='w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-subtle text-xs'>
|
||||
<thead className='text-text-secondary'>
|
||||
<tr>
|
||||
<td className='h-9 border-b border-divider-subtle pl-3 pr-2'>{t('datasetDocuments.list.batchModal.question')}</td>
|
||||
<td className='h-9 border-b border-divider-subtle pl-3 pr-2'>{t('datasetDocuments.list.batchModal.answer')}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='text-text-tertiary'>
|
||||
<tr>
|
||||
<td className='h-9 border-b border-divider-subtle pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.question')} 1</td>
|
||||
<td className='h-9 border-b border-divider-subtle pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.answer')} 1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='h-9 pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.question')} 2</td>
|
||||
<td className='h-9 pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.answer')} 2</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{docForm === ChunkingMode.text && (
|
||||
<table className='w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-subtle text-xs'>
|
||||
<thead className='text-text-secondary'>
|
||||
<tr>
|
||||
<td className='h-9 border-b border-divider-subtle pl-3 pr-2'>{t('datasetDocuments.list.batchModal.contentTitle')}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='text-text-tertiary'>
|
||||
<tr>
|
||||
<td className='h-9 border-b border-divider-subtle pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.content')} 1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='h-9 pl-3 pr-2 text-[13px]'>{t('datasetDocuments.list.batchModal.content')} 2</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<CSVDownloader
|
||||
className="mt-2 block cursor-pointer"
|
||||
type={Type.Link}
|
||||
filename={'template'}
|
||||
bom={true}
|
||||
data={getTemplate()}
|
||||
>
|
||||
<div className='flex h-[18px] items-center space-x-1 text-xs font-medium text-text-accent'>
|
||||
<DownloadIcon className='mr-1 h-3 w-3' />
|
||||
{t('datasetDocuments.list.batchModal.template')}
|
||||
</div>
|
||||
</CSVDownloader>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(CSVDownload)
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import cn from '@/utils/classnames'
|
||||
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { FileItem } from '@/models/datasets'
|
||||
import { upload } from '@/service/base'
|
||||
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
|
||||
import useSWR from 'swr'
|
||||
import { fetchFileUploadConfig } from '@/service/common'
|
||||
import SimplePieChart from '@/app/components/base/simple-pie-chart'
|
||||
import { Theme } from '@/types/app'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
|
||||
export type Props = {
|
||||
file: FileItem | undefined
|
||||
updateFile: (file?: FileItem) => void
|
||||
}
|
||||
|
||||
const CSVUploader: FC<Props> = ({
|
||||
file,
|
||||
updateFile,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const dragRef = useRef<HTMLDivElement>(null)
|
||||
const fileUploader = useRef<HTMLInputElement>(null)
|
||||
const { data: fileUploadConfigResponse } = useSWR({ url: '/files/upload' }, fetchFileUploadConfig)
|
||||
const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
|
||||
file_size_limit: 15,
|
||||
}, [fileUploadConfigResponse])
|
||||
|
||||
type UploadResult = Awaited<ReturnType<typeof upload>>
|
||||
|
||||
const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => {
|
||||
fileItem.progress = 0
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', fileItem.file)
|
||||
const onProgress = (e: ProgressEvent) => {
|
||||
if (e.lengthComputable) {
|
||||
const progress = Math.floor(e.loaded / e.total * 100)
|
||||
updateFile({
|
||||
...fileItem,
|
||||
progress,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return upload({
|
||||
xhr: new XMLHttpRequest(),
|
||||
data: formData,
|
||||
onprogress: onProgress,
|
||||
}, false, undefined, '?source=datasets')
|
||||
.then((res: UploadResult) => {
|
||||
const updatedFile = Object.assign({}, fileItem.file, {
|
||||
id: res.id,
|
||||
...(res as Partial<File>),
|
||||
}) as File
|
||||
const completeFile: FileItem = {
|
||||
fileID: fileItem.fileID,
|
||||
file: updatedFile,
|
||||
progress: 100,
|
||||
}
|
||||
updateFile(completeFile)
|
||||
return Promise.resolve({ ...completeFile })
|
||||
})
|
||||
.catch((e) => {
|
||||
const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
const errorFile = {
|
||||
...fileItem,
|
||||
progress: -2,
|
||||
}
|
||||
updateFile(errorFile)
|
||||
return Promise.resolve({ ...errorFile })
|
||||
})
|
||||
.finally()
|
||||
}, [notify, t, updateFile])
|
||||
|
||||
const uploadFile = useCallback(async (fileItem: FileItem) => {
|
||||
await fileUpload(fileItem)
|
||||
}, [fileUpload])
|
||||
|
||||
const initialUpload = useCallback((file?: File) => {
|
||||
if (!file)
|
||||
return false
|
||||
|
||||
const newFile: FileItem = {
|
||||
fileID: `file0-${Date.now()}`,
|
||||
file,
|
||||
progress: -1,
|
||||
}
|
||||
updateFile(newFile)
|
||||
uploadFile(newFile)
|
||||
}, [updateFile, uploadFile])
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.target !== dragRef.current)
|
||||
setDragging(true)
|
||||
}
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.target === dragRef.current)
|
||||
setDragging(false)
|
||||
}
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragging(false)
|
||||
if (!e.dataTransfer)
|
||||
return
|
||||
const files = [...e.dataTransfer.files]
|
||||
if (files.length > 1) {
|
||||
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.count') })
|
||||
return
|
||||
}
|
||||
initialUpload(files[0])
|
||||
}
|
||||
const selectHandle = () => {
|
||||
if (fileUploader.current)
|
||||
fileUploader.current.click()
|
||||
}
|
||||
const removeFile = () => {
|
||||
if (fileUploader.current)
|
||||
fileUploader.current.value = ''
|
||||
updateFile()
|
||||
}
|
||||
|
||||
const getFileType = (currentFile: File) => {
|
||||
if (!currentFile)
|
||||
return ''
|
||||
|
||||
const arr = currentFile.name.split('.')
|
||||
return arr[arr.length - 1]
|
||||
}
|
||||
|
||||
const isValid = useCallback((file?: File) => {
|
||||
if (!file)
|
||||
return false
|
||||
|
||||
const { size } = file
|
||||
const ext = `.${getFileType(file)}`
|
||||
const isValidType = ext.toLowerCase() === '.csv'
|
||||
if (!isValidType)
|
||||
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.typeError') })
|
||||
|
||||
const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024
|
||||
if (!isValidSize)
|
||||
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.size', { size: fileUploadConfig.file_size_limit }) })
|
||||
|
||||
return isValidType && isValidSize
|
||||
}, [fileUploadConfig, notify, t])
|
||||
|
||||
const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const currentFile = e.target.files?.[0]
|
||||
if (!isValid(currentFile))
|
||||
return
|
||||
|
||||
initialUpload(currentFile)
|
||||
}
|
||||
|
||||
const { theme } = useTheme()
|
||||
const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
|
||||
|
||||
useEffect(() => {
|
||||
dropRef.current?.addEventListener('dragenter', handleDragEnter)
|
||||
dropRef.current?.addEventListener('dragover', handleDragOver)
|
||||
dropRef.current?.addEventListener('dragleave', handleDragLeave)
|
||||
dropRef.current?.addEventListener('drop', handleDrop)
|
||||
return () => {
|
||||
dropRef.current?.removeEventListener('dragenter', handleDragEnter)
|
||||
dropRef.current?.removeEventListener('dragover', handleDragOver)
|
||||
dropRef.current?.removeEventListener('dragleave', handleDragLeave)
|
||||
dropRef.current?.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='mt-6'>
|
||||
<input
|
||||
ref={fileUploader}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
id="fileUploader"
|
||||
accept='.csv'
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
<div ref={dropRef}>
|
||||
{!file && (
|
||||
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover')}>
|
||||
<div className='flex w-full items-center justify-center space-x-2'>
|
||||
<CSVIcon className="shrink-0" />
|
||||
<div className='text-text-secondary'>
|
||||
{t('datasetDocuments.list.batchModal.csvUploadTitle')}
|
||||
<span className='cursor-pointer text-text-accent' onClick={selectHandle}>{t('datasetDocuments.list.batchModal.browse')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
|
||||
</div>
|
||||
)}
|
||||
{file && (
|
||||
<div className={cn('group flex h-20 items-center rounded-xl border border-components-panel-border bg-components-panel-bg-blur px-6 text-sm font-normal', 'hover:border-divider-subtle hover:bg-components-panel-on-panel-item-bg-hover')}>
|
||||
<CSVIcon className="shrink-0" />
|
||||
<div className='ml-2 flex w-0 grow'>
|
||||
<span className='max-w-[calc(100%_-_30px)] overflow-hidden text-ellipsis whitespace-nowrap text-text-primary'>{file.file.name.replace(/.csv$/, '')}</span>
|
||||
<span className='shrink-0 text-text-secondary'>.csv</span>
|
||||
</div>
|
||||
<div className='hidden items-center group-hover:flex'>
|
||||
{(file.progress < 100 && file.progress >= 0) && (
|
||||
<>
|
||||
<SimplePieChart percentage={file.progress} stroke={chartColor} fill={chartColor} animationDuration={0}/>
|
||||
<div className='mx-2 h-4 w-px bg-text-secondary'/>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
|
||||
<div className='mx-2 h-4 w-px bg-text-secondary' />
|
||||
<div className='cursor-pointer p-2' onClick={removeFile}>
|
||||
<RiDeleteBinLine className='h-4 w-4 text-text-secondary' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CSVUploader)
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import CSVUploader from './csv-uploader'
|
||||
import CSVDownloader from './csv-downloader'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { ChunkingMode, FileItem } from '@/models/datasets'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type IBatchModalProps = {
|
||||
isShow: boolean
|
||||
docForm: ChunkingMode
|
||||
onCancel: () => void
|
||||
onConfirm: (file: FileItem) => void
|
||||
}
|
||||
|
||||
const BatchModal: FC<IBatchModalProps> = ({
|
||||
isShow,
|
||||
docForm,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [currentCSV, setCurrentCSV] = useState<FileItem>()
|
||||
const handleFile = (file?: FileItem) => setCurrentCSV(file)
|
||||
|
||||
const handleSend = () => {
|
||||
if (!currentCSV)
|
||||
return
|
||||
onCancel()
|
||||
onConfirm(currentCSV)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShow)
|
||||
setCurrentCSV(undefined)
|
||||
}, [isShow])
|
||||
|
||||
return (
|
||||
<Modal isShow={isShow} onClose={noop} className='!max-w-[520px] !rounded-xl px-8 py-6'>
|
||||
<div className='relative pb-1 text-xl font-medium leading-[30px] text-text-primary'>{t('datasetDocuments.list.batchModal.title')}</div>
|
||||
<div className='absolute right-4 top-4 cursor-pointer p-2' onClick={onCancel}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-secondary' />
|
||||
</div>
|
||||
<CSVUploader
|
||||
file={currentCSV}
|
||||
updateFile={handleFile}
|
||||
/>
|
||||
<CSVDownloader
|
||||
docForm={docForm}
|
||||
/>
|
||||
<div className='mt-[28px] flex justify-end pt-6'>
|
||||
<Button className='mr-2' onClick={onCancel}>
|
||||
{t('datasetDocuments.list.batchModal.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSend} disabled={!currentCSV || !currentCSV.file || !currentCSV.file.id}>
|
||||
{t('datasetDocuments.list.batchModal.run')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default React.memo(BatchModal)
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { type FC, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiCollapseDiagonalLine,
|
||||
RiExpandDiagonalLine,
|
||||
} from '@remixicon/react'
|
||||
import ActionButtons from './common/action-buttons'
|
||||
import ChunkContent from './common/chunk-content'
|
||||
import Dot from './common/dot'
|
||||
import { SegmentIndexTag } from './common/segment-index-tag'
|
||||
import { useSegmentListContext } from './index'
|
||||
import type { ChildChunkDetail, ChunkingMode } from '@/models/datasets'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import classNames from '@/utils/classnames'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { formatTime } from '@/utils/time'
|
||||
|
||||
type IChildSegmentDetailProps = {
|
||||
chunkId: string
|
||||
childChunkInfo?: Partial<ChildChunkDetail> & { id: string }
|
||||
onUpdate: (segmentId: string, childChunkId: string, content: string) => void
|
||||
onCancel: () => void
|
||||
docForm: ChunkingMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all the contents of the segment
|
||||
*/
|
||||
const ChildSegmentDetail: FC<IChildSegmentDetailProps> = ({
|
||||
chunkId,
|
||||
childChunkInfo,
|
||||
onUpdate,
|
||||
onCancel,
|
||||
docForm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [content, setContent] = useState(childChunkInfo?.content || '')
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const fullScreen = useSegmentListContext(s => s.fullScreen)
|
||||
const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen)
|
||||
|
||||
eventEmitter?.useSubscription((v) => {
|
||||
if (v === 'update-child-segment')
|
||||
setLoading(true)
|
||||
if (v === 'update-child-segment-done')
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(chunkId, childChunkInfo?.id || '', content)
|
||||
}
|
||||
|
||||
const wordCountText = useMemo(() => {
|
||||
const count = content.length
|
||||
return `${formatNumber(count)} ${t('datasetDocuments.segment.characters', { count })}`
|
||||
}, [content.length])
|
||||
|
||||
const EditTimeText = useMemo(() => {
|
||||
const timeText = formatTime({
|
||||
date: (childChunkInfo?.updated_at ?? 0) * 1000,
|
||||
dateFormat: `${t('datasetDocuments.segment.dateTimeFormat')}`,
|
||||
})
|
||||
return `${t('datasetDocuments.segment.editedAt')} ${timeText}`
|
||||
}, [childChunkInfo?.updated_at])
|
||||
|
||||
return (
|
||||
<div className={'flex h-full flex-col'}>
|
||||
<div className={classNames('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}>
|
||||
<div className='flex flex-col'>
|
||||
<div className='system-xl-semibold text-text-primary'>{t('datasetDocuments.segment.editChildChunk')}</div>
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<SegmentIndexTag positionId={childChunkInfo?.position || ''} labelPrefix={t('datasetDocuments.segment.childChunk') as string} />
|
||||
<Dot />
|
||||
<span className='system-xs-medium text-text-tertiary'>{wordCountText}</span>
|
||||
<Dot />
|
||||
<span className='system-xs-medium text-text-tertiary'>
|
||||
{EditTimeText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
{fullScreen && (
|
||||
<>
|
||||
<ActionButtons
|
||||
handleCancel={handleCancel}
|
||||
handleSave={handleSave}
|
||||
loading={loading}
|
||||
isChildChunk={true}
|
||||
/>
|
||||
<Divider type='vertical' className='ml-4 mr-2 h-3.5 bg-divider-regular' />
|
||||
</>
|
||||
)}
|
||||
<div className='mr-1 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={toggleFullScreen}>
|
||||
{fullScreen ? <RiCollapseDiagonalLine className='h-4 w-4 text-text-tertiary' /> : <RiExpandDiagonalLine className='h-4 w-4 text-text-tertiary' />}
|
||||
</div>
|
||||
<div className='flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={onCancel}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames('flex w-full grow', fullScreen ? 'flex-row justify-center px-6 pt-6' : 'px-4 py-3')}>
|
||||
<div className={classNames('h-full overflow-hidden whitespace-pre-line break-all', fullScreen ? 'w-1/2' : 'w-full')}>
|
||||
<ChunkContent
|
||||
docForm={docForm}
|
||||
question={content}
|
||||
onQuestionChange={content => setContent(content)}
|
||||
isEditMode={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!fullScreen && (
|
||||
<div className='flex items-center justify-end border-t-[1px] border-t-divider-subtle p-4 pt-3'>
|
||||
<ActionButtons
|
||||
handleCancel={handleCancel}
|
||||
handleSave={handleSave}
|
||||
loading={loading}
|
||||
isChildChunk={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ChildSegmentDetail)
|
||||
@@ -0,0 +1,196 @@
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EditSlice } from '../../../formatted-text/flavours/edit-slice'
|
||||
import { useDocumentContext } from '../context'
|
||||
import { FormattedText } from '../../../formatted-text/formatted'
|
||||
import Empty from './common/empty'
|
||||
import FullDocListSkeleton from './skeleton/full-doc-list-skeleton'
|
||||
import { useSegmentListContext } from './index'
|
||||
import type { ChildChunkDetail } from '@/models/datasets'
|
||||
import Input from '@/app/components/base/input'
|
||||
import cn from '@/utils/classnames'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
type IChildSegmentCardProps = {
|
||||
childChunks: ChildChunkDetail[]
|
||||
parentChunkId: string
|
||||
handleInputChange?: (value: string) => void
|
||||
handleAddNewChildChunk?: (parentChunkId: string) => void
|
||||
enabled: boolean
|
||||
onDelete?: (segId: string, childChunkId: string) => Promise<void>
|
||||
onClickSlice?: (childChunk: ChildChunkDetail) => void
|
||||
total?: number
|
||||
inputValue?: string
|
||||
onClearFilter?: () => void
|
||||
isLoading?: boolean
|
||||
focused?: boolean
|
||||
}
|
||||
|
||||
const ChildSegmentList: FC<IChildSegmentCardProps> = ({
|
||||
childChunks,
|
||||
parentChunkId,
|
||||
handleInputChange,
|
||||
handleAddNewChildChunk,
|
||||
enabled,
|
||||
onDelete,
|
||||
onClickSlice,
|
||||
total,
|
||||
inputValue,
|
||||
onClearFilter,
|
||||
isLoading,
|
||||
focused = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const parentMode = useDocumentContext(s => s.parentMode)
|
||||
const currChildChunk = useSegmentListContext(s => s.currChildChunk)
|
||||
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setCollapsed(!collapsed)
|
||||
}
|
||||
|
||||
const isParagraphMode = useMemo(() => {
|
||||
return parentMode === 'paragraph'
|
||||
}, [parentMode])
|
||||
|
||||
const isFullDocMode = useMemo(() => {
|
||||
return parentMode === 'full-doc'
|
||||
}, [parentMode])
|
||||
|
||||
const contentOpacity = useMemo(() => {
|
||||
return (enabled || focused) ? '' : 'opacity-50 group-hover/card:opacity-100'
|
||||
}, [enabled, focused])
|
||||
|
||||
const totalText = useMemo(() => {
|
||||
const isSearch = inputValue !== '' && isFullDocMode
|
||||
if (!isSearch) {
|
||||
const text = isFullDocMode
|
||||
? !total
|
||||
? '--'
|
||||
: formatNumber(total)
|
||||
: formatNumber(childChunks.length)
|
||||
const count = isFullDocMode
|
||||
? text === '--'
|
||||
? 0
|
||||
: total
|
||||
: childChunks.length
|
||||
return `${text} ${t('datasetDocuments.segment.childChunks', { count })}`
|
||||
}
|
||||
else {
|
||||
const text = !total ? '--' : formatNumber(total)
|
||||
const count = text === '--' ? 0 : total
|
||||
return `${count} ${t('datasetDocuments.segment.searchResults', { count })}`
|
||||
}
|
||||
}, [isFullDocMode, total, childChunks.length, inputValue])
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex flex-col',
|
||||
contentOpacity,
|
||||
isParagraphMode ? 'pb-2 pt-1' : 'grow px-3',
|
||||
(isFullDocMode && isLoading) && 'overflow-y-hidden',
|
||||
)}>
|
||||
{isFullDocMode ? <Divider type='horizontal' className='my-1 h-px bg-divider-subtle' /> : null}
|
||||
<div className={cn('flex items-center justify-between', isFullDocMode ? 'sticky -top-2 left-0 bg-background-default pb-3 pt-2' : '')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 items-center rounded-lg pl-1 pr-3',
|
||||
isParagraphMode && 'cursor-pointer',
|
||||
(isParagraphMode && collapsed) && 'bg-dataset-child-chunk-expand-btn-bg',
|
||||
isFullDocMode && 'pl-0',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggleCollapse()
|
||||
}}
|
||||
>
|
||||
{
|
||||
isParagraphMode
|
||||
? collapsed
|
||||
? (
|
||||
<RiArrowRightSLine className='mr-0.5 h-4 w-4 text-text-secondary opacity-50' />
|
||||
)
|
||||
: (<RiArrowDownSLine className='mr-0.5 h-4 w-4 text-text-secondary' />)
|
||||
: null
|
||||
}
|
||||
<span className='system-sm-semibold-uppercase text-text-secondary'>{totalText}</span>
|
||||
<span className={cn('pl-1.5 text-xs font-medium text-text-quaternary', isParagraphMode ? 'hidden group-hover/card:inline-block' : '')}>·</span>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'system-xs-semibold-uppercase px-1.5 py-1 text-components-button-secondary-accent-text',
|
||||
isParagraphMode ? 'hidden group-hover/card:inline-block' : '',
|
||||
(isFullDocMode && isLoading) ? 'text-components-button-secondary-accent-text-disabled' : '',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleAddNewChildChunk?.(parentChunkId)
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('common.operation.add')}
|
||||
</button>
|
||||
</div>
|
||||
{isFullDocMode
|
||||
? <Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName='!w-52'
|
||||
value={inputValue}
|
||||
onChange={e => handleInputChange?.(e.target.value)}
|
||||
onClear={() => handleInputChange?.('')}
|
||||
/>
|
||||
: null}
|
||||
</div>
|
||||
{isLoading ? <FullDocListSkeleton /> : null}
|
||||
{((isFullDocMode && !isLoading) || !collapsed)
|
||||
? <div className={cn('flex gap-x-0.5', isFullDocMode ? 'mb-6 grow' : 'items-center')}>
|
||||
{isParagraphMode && (
|
||||
<div className='self-stretch'>
|
||||
<Divider type='vertical' className='mx-[7px] w-[2px] bg-text-accent-secondary' />
|
||||
</div>
|
||||
)}
|
||||
{childChunks.length > 0
|
||||
? <FormattedText className={cn('flex w-full flex-col !leading-6', isParagraphMode ? 'gap-y-2' : 'gap-y-3')}>
|
||||
{childChunks.map((childChunk) => {
|
||||
const edited = childChunk.updated_at !== childChunk.created_at
|
||||
const focused = currChildChunk?.childChunkInfo?.id === childChunk.id
|
||||
return <EditSlice
|
||||
key={childChunk.id}
|
||||
label={`C-${childChunk.position}${edited ? ` · ${t('datasetDocuments.segment.edited')}` : ''}`}
|
||||
text={childChunk.content}
|
||||
onDelete={() => onDelete?.(childChunk.segment_id, childChunk.id)}
|
||||
className='child-chunk'
|
||||
labelClassName={focused ? 'bg-state-accent-solid text-text-primary-on-surface' : ''}
|
||||
labelInnerClassName={'text-[10px] font-semibold align-bottom leading-6'}
|
||||
contentClassName={cn('!leading-6', focused ? 'bg-state-accent-hover-alt text-text-primary' : 'text-text-secondary')}
|
||||
showDivider={false}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClickSlice?.(childChunk)
|
||||
}}
|
||||
offsetOptions={({ rects }) => {
|
||||
return {
|
||||
mainAxis: isFullDocMode ? -rects.floating.width : 12 - rects.floating.width,
|
||||
crossAxis: (20 - rects.floating.height) / 2,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
})}
|
||||
</FormattedText>
|
||||
: inputValue !== ''
|
||||
? <div className='h-full w-full'>
|
||||
<Empty onClearFilter={onClearFilter!} />
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChildSegmentList
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { type FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useDocumentContext } from '../../context'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
type IActionButtonsProps = {
|
||||
handleCancel: () => void
|
||||
handleSave: () => void
|
||||
loading: boolean
|
||||
actionType?: 'edit' | 'add'
|
||||
handleRegeneration?: () => void
|
||||
isChildChunk?: boolean
|
||||
}
|
||||
|
||||
const ActionButtons: FC<IActionButtonsProps> = ({
|
||||
handleCancel,
|
||||
handleSave,
|
||||
loading,
|
||||
actionType = 'edit',
|
||||
handleRegeneration,
|
||||
isChildChunk = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docForm = useDocumentContext(s => s.docForm)
|
||||
const parentMode = useDocumentContext(s => s.parentMode)
|
||||
|
||||
useKeyPress(['esc'], (e) => {
|
||||
e.preventDefault()
|
||||
handleCancel()
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.s`, (e) => {
|
||||
e.preventDefault()
|
||||
if (loading)
|
||||
return
|
||||
handleSave()
|
||||
},
|
||||
{ exactMatch: true, useCapture: true })
|
||||
|
||||
const isParentChildParagraphMode = useMemo(() => {
|
||||
return docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
|
||||
}, [docForm, parentMode])
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<span className='system-sm-medium text-components-button-secondary-text'>{t('common.operation.cancel')}</span>
|
||||
<span className='system-kbd rounded-[4px] bg-components-kbd-bg-gray px-[1px] text-text-tertiary'>ESC</span>
|
||||
</div>
|
||||
</Button>
|
||||
{(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk)
|
||||
? <Button
|
||||
onClick={handleRegeneration}
|
||||
disabled={loading}
|
||||
>
|
||||
<span className='system-sm-medium text-components-button-secondary-text'>
|
||||
{t('common.operation.saveAndRegenerate')}
|
||||
</span>
|
||||
</Button>
|
||||
: null
|
||||
}
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<span className='text-components-button-primary-text'>{t('common.operation.save')}</span>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
<span className='system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white capitalize text-text-primary-on-surface'>{getKeyboardKeyNameBySystem('ctrl')}</span>
|
||||
<span className='system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface'>S</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ActionButtons.displayName = 'ActionButtons'
|
||||
|
||||
export default React.memo(ActionButtons)
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from '@/utils/classnames'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
|
||||
type AddAnotherProps = {
|
||||
className?: string
|
||||
isChecked: boolean
|
||||
onCheck: () => void
|
||||
}
|
||||
|
||||
const AddAnother: FC<AddAnotherProps> = ({
|
||||
className,
|
||||
isChecked,
|
||||
onCheck,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={classNames('flex items-center gap-x-1 pl-1', className)}>
|
||||
<Checkbox
|
||||
key='add-another-checkbox'
|
||||
className='shrink-0'
|
||||
checked={isChecked}
|
||||
onCheck={onCheck}
|
||||
/>
|
||||
<span className='system-xs-medium text-text-tertiary'>{t('datasetDocuments.segment.addAnother')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AddAnother)
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { type FC } from 'react'
|
||||
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDraftLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import cn from '@/utils/classnames'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
const i18nPrefix = 'dataset.batchAction'
|
||||
type IBatchActionProps = {
|
||||
className?: string
|
||||
selectedIds: string[]
|
||||
onBatchEnable: () => void
|
||||
onBatchDisable: () => void
|
||||
onBatchDelete: () => Promise<void>
|
||||
onArchive?: () => void
|
||||
onEditMetadata?: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const BatchAction: FC<IBatchActionProps> = ({
|
||||
className,
|
||||
selectedIds,
|
||||
onBatchEnable,
|
||||
onBatchDisable,
|
||||
onArchive,
|
||||
onBatchDelete,
|
||||
onEditMetadata,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isShowDeleteConfirm, {
|
||||
setTrue: showDeleteConfirm,
|
||||
setFalse: hideDeleteConfirm,
|
||||
}] = useBoolean(false)
|
||||
const [isDeleting, {
|
||||
setTrue: setIsDeleting,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
setIsDeleting()
|
||||
await onBatchDelete()
|
||||
hideDeleteConfirm()
|
||||
}
|
||||
return (
|
||||
<div className={cn('pointer-events-none flex w-full justify-center gap-x-2', className)}>
|
||||
<div className='pointer-events-auto flex items-center gap-x-1 rounded-[10px] border border-components-actionbar-border-accent bg-components-actionbar-bg-accent p-1 shadow-xl shadow-shadow-shadow-5'>
|
||||
<div className='inline-flex items-center gap-x-2 py-1 pl-2 pr-3'>
|
||||
<span className='system-xs-medium flex h-5 w-5 items-center justify-center rounded-md bg-text-accent text-text-primary-on-surface'>
|
||||
{selectedIds.length}
|
||||
</span>
|
||||
<span className='system-sm-semibold text-text-accent'>{t(`${i18nPrefix}.selected`)}</span>
|
||||
</div>
|
||||
<Divider type='vertical' className='mx-0.5 h-3.5 bg-divider-regular' />
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='gap-x-0.5 px-3'
|
||||
onClick={onBatchEnable}
|
||||
>
|
||||
<RiCheckboxCircleLine className='size-4' />
|
||||
<span className='px-0.5'>{t(`${i18nPrefix}.enable`)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='gap-x-0.5 px-3'
|
||||
onClick={onBatchDisable}
|
||||
>
|
||||
<RiCloseCircleLine className='size-4' />
|
||||
<span className='px-0.5'>{t(`${i18nPrefix}.disable`)}</span>
|
||||
</Button>
|
||||
{onEditMetadata && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='gap-x-0.5 px-3'
|
||||
onClick={onEditMetadata}
|
||||
>
|
||||
<RiDraftLine className='size-4' />
|
||||
<span className='px-0.5'>{t('dataset.metadata.metadata')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onArchive && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='gap-x-0.5 px-3'
|
||||
onClick={onArchive}
|
||||
>
|
||||
<RiArchive2Line className='size-4' />
|
||||
<span className='px-0.5'>{t(`${i18nPrefix}.archive`)}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
destructive
|
||||
className='gap-x-0.5 px-3'
|
||||
onClick={showDeleteConfirm}
|
||||
>
|
||||
<RiDeleteBinLine className='size-4' />
|
||||
<span className='px-0.5'>{t(`${i18nPrefix}.delete`)}</span>
|
||||
</Button>
|
||||
|
||||
<Divider type='vertical' className='mx-0.5 h-3.5 bg-divider-regular' />
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='px-3'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<span className='px-0.5'>{t(`${i18nPrefix}.cancel`)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{
|
||||
isShowDeleteConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t('datasetDocuments.list.delete.title')}
|
||||
content={t('datasetDocuments.list.delete.content')}
|
||||
confirmText={t('common.operation.sure')}
|
||||
onConfirm={handleBatchDelete}
|
||||
onCancel={hideDeleteConfirm}
|
||||
isLoading={isDeleting}
|
||||
isDisabled={isDeleting}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BatchAction)
|
||||
@@ -0,0 +1,203 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import type { ComponentProps, FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
|
||||
type IContentProps = ComponentProps<'textarea'>
|
||||
|
||||
const Textarea: FC<IContentProps> = React.memo(({
|
||||
value,
|
||||
placeholder,
|
||||
className,
|
||||
disabled,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<textarea
|
||||
className={classNames(
|
||||
'inset-0 w-full resize-none appearance-none overflow-y-auto border-none bg-transparent outline-none',
|
||||
className,
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
type IAutoResizeTextAreaProps = ComponentProps<'textarea'> & {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
labelRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
const AutoResizeTextArea: FC<IAutoResizeTextAreaProps> = React.memo(({
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
disabled,
|
||||
containerRef,
|
||||
labelRef,
|
||||
...rest
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const observerRef = useRef<ResizeObserver>(null)
|
||||
const [maxHeight, setMaxHeight] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea)
|
||||
return
|
||||
textarea.style.height = 'auto'
|
||||
const lineHeight = Number.parseInt(getComputedStyle(textarea).lineHeight)
|
||||
const textareaHeight = Math.max(textarea.scrollHeight, lineHeight)
|
||||
textarea.style.height = `${textareaHeight}px`
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const label = labelRef.current
|
||||
if (!container || !label)
|
||||
return
|
||||
const updateMaxHeight = () => {
|
||||
const containerHeight = container.clientHeight
|
||||
const labelHeight = label.clientHeight
|
||||
const padding = 32
|
||||
const space = 12
|
||||
const maxHeight = Math.floor((containerHeight - 2 * labelHeight - padding - space) / 2)
|
||||
setMaxHeight(maxHeight)
|
||||
}
|
||||
updateMaxHeight()
|
||||
observerRef.current = new ResizeObserver(updateMaxHeight)
|
||||
observerRef.current.observe(container)
|
||||
return () => {
|
||||
observerRef.current?.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={classNames(
|
||||
'inset-0 w-full resize-none appearance-none border-none bg-transparent outline-none',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
AutoResizeTextArea.displayName = 'AutoResizeTextArea'
|
||||
|
||||
type IQATextAreaProps = {
|
||||
question: string
|
||||
answer?: string
|
||||
onQuestionChange: (question: string) => void
|
||||
onAnswerChange?: (answer: string) => void
|
||||
isEditMode?: boolean
|
||||
}
|
||||
|
||||
const QATextArea: FC<IQATextAreaProps> = React.memo(({
|
||||
question,
|
||||
answer,
|
||||
onQuestionChange,
|
||||
onAnswerChange,
|
||||
isEditMode = true,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const labelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='h-full overflow-hidden'>
|
||||
<div ref={labelRef} className='mb-1 text-xs font-medium text-text-tertiary'>QUESTION</div>
|
||||
<AutoResizeTextArea
|
||||
className='text-sm tracking-[-0.07px] text-text-secondary caret-[#295EFF]'
|
||||
value={question}
|
||||
placeholder={t('datasetDocuments.segment.questionPlaceholder') || ''}
|
||||
onChange={e => onQuestionChange(e.target.value)}
|
||||
disabled={!isEditMode}
|
||||
containerRef={containerRef}
|
||||
labelRef={labelRef}
|
||||
/>
|
||||
<div className='mb-1 mt-6 text-xs font-medium text-text-tertiary'>ANSWER</div>
|
||||
<AutoResizeTextArea
|
||||
className='text-sm tracking-[-0.07px] text-text-secondary caret-[#295EFF]'
|
||||
value={answer}
|
||||
placeholder={t('datasetDocuments.segment.answerPlaceholder') || ''}
|
||||
onChange={e => onAnswerChange?.(e.target.value)}
|
||||
disabled={!isEditMode}
|
||||
autoFocus
|
||||
containerRef={containerRef}
|
||||
labelRef={labelRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
QATextArea.displayName = 'QATextArea'
|
||||
|
||||
type IChunkContentProps = {
|
||||
question: string
|
||||
answer?: string
|
||||
onQuestionChange: (question: string) => void
|
||||
onAnswerChange?: (answer: string) => void
|
||||
isEditMode?: boolean
|
||||
docForm: ChunkingMode
|
||||
}
|
||||
|
||||
const ChunkContent: FC<IChunkContentProps> = ({
|
||||
question,
|
||||
answer,
|
||||
onQuestionChange,
|
||||
onAnswerChange,
|
||||
isEditMode,
|
||||
docForm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (docForm === ChunkingMode.qa) {
|
||||
return <QATextArea
|
||||
question={question}
|
||||
answer={answer}
|
||||
onQuestionChange={onQuestionChange}
|
||||
onAnswerChange={onAnswerChange}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
}
|
||||
|
||||
if (!isEditMode) {
|
||||
return (
|
||||
<Markdown
|
||||
className='h-full w-full !text-text-secondary'
|
||||
content={question}
|
||||
customDisallowedElements={['input']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className='body-md-regular h-full w-full pb-6 tracking-[-0.07px] text-text-secondary caret-[#295EFF]'
|
||||
value={question}
|
||||
placeholder={t('datasetDocuments.segment.contentPlaceholder') || ''}
|
||||
onChange={e => onQuestionChange(e.target.value)}
|
||||
disabled={!isEditMode}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
ChunkContent.displayName = 'ChunkContent'
|
||||
|
||||
export default React.memo(ChunkContent)
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
const Dot = () => {
|
||||
return (
|
||||
<div className='system-xs-medium text-text-quaternary'>·</div>
|
||||
)
|
||||
}
|
||||
|
||||
Dot.displayName = 'Dot'
|
||||
|
||||
export default React.memo(Dot)
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useSegmentListContext } from '..'
|
||||
|
||||
type DrawerProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
side?: 'right' | 'left' | 'bottom' | 'top'
|
||||
showOverlay?: boolean
|
||||
modal?: boolean // click outside event can pass through if modal is false
|
||||
closeOnOutsideClick?: boolean
|
||||
panelClassName?: string
|
||||
panelContentClassName?: string
|
||||
needCheckChunks?: boolean
|
||||
}
|
||||
|
||||
const Drawer = ({
|
||||
open,
|
||||
onClose,
|
||||
side = 'right',
|
||||
showOverlay = true,
|
||||
modal = false,
|
||||
needCheckChunks = false,
|
||||
children,
|
||||
panelClassName,
|
||||
panelContentClassName,
|
||||
}: React.PropsWithChildren<DrawerProps>) => {
|
||||
const panelContentRef = useRef<HTMLDivElement>(null)
|
||||
const currSegment = useSegmentListContext(s => s.currSegment)
|
||||
const currChildChunk = useSegmentListContext(s => s.currChildChunk)
|
||||
|
||||
useKeyPress('esc', (e) => {
|
||||
if (!open) return
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
const shouldCloseDrawer = useCallback((target: Node | null) => {
|
||||
const panelContent = panelContentRef.current
|
||||
if (!panelContent) return false
|
||||
const chunks = document.querySelectorAll('.chunk-card')
|
||||
const childChunks = document.querySelectorAll('.child-chunk')
|
||||
const isClickOnChunk = Array.from(chunks).some((chunk) => {
|
||||
return chunk && chunk.contains(target)
|
||||
})
|
||||
const isClickOnChildChunk = Array.from(childChunks).some((chunk) => {
|
||||
return chunk && chunk.contains(target)
|
||||
})
|
||||
const reopenChunkDetail = (currSegment.showModal && isClickOnChildChunk)
|
||||
|| (currChildChunk.showModal && isClickOnChunk && !isClickOnChildChunk) || (!isClickOnChunk && !isClickOnChildChunk)
|
||||
return target && !panelContent.contains(target) && (!needCheckChunks || reopenChunkDetail)
|
||||
}, [currSegment, currChildChunk, needCheckChunks])
|
||||
|
||||
const onDownCapture = useCallback((e: PointerEvent) => {
|
||||
if (!open || modal) return
|
||||
const panelContent = panelContentRef.current
|
||||
if (!panelContent) return
|
||||
const target = e.target as Node | null
|
||||
if (shouldCloseDrawer(target))
|
||||
queueMicrotask(onClose)
|
||||
}, [shouldCloseDrawer, onClose, open, modal])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('pointerdown', onDownCapture, { capture: true })
|
||||
return () =>
|
||||
window.removeEventListener('pointerdown', onDownCapture, { capture: true })
|
||||
}, [onDownCapture])
|
||||
|
||||
const isHorizontal = side === 'left' || side === 'right'
|
||||
|
||||
const content = (
|
||||
<div className='pointer-events-none fixed inset-0 z-[9999]'>
|
||||
{showOverlay ? (
|
||||
<div
|
||||
onClick={modal ? onClose : undefined}
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
|
||||
open && 'opacity-100',
|
||||
modal && open ? 'pointer-events-auto' : 'pointer-events-none',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Drawer panel */}
|
||||
<div
|
||||
role='dialog'
|
||||
aria-modal={modal ? 'true' : 'false'}
|
||||
className={cn(
|
||||
'pointer-events-auto fixed flex flex-col',
|
||||
side === 'right' && 'right-0',
|
||||
side === 'left' && 'left-0',
|
||||
side === 'bottom' && 'bottom-0',
|
||||
side === 'top' && 'top-0',
|
||||
isHorizontal ? 'h-screen' : 'w-screen',
|
||||
panelClassName,
|
||||
)}
|
||||
>
|
||||
<div ref={panelContentRef} className={cn('flex grow flex-col', panelContentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return open && createPortal(content, document.body)
|
||||
}
|
||||
|
||||
export default Drawer
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { type FC } from 'react'
|
||||
import { RiFileList2Line } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type IEmptyProps = {
|
||||
onClearFilter: () => void
|
||||
}
|
||||
|
||||
const EmptyCard = React.memo(() => {
|
||||
return (
|
||||
<div className='h-32 w-full shrink-0 rounded-xl bg-background-section-burn opacity-30' />
|
||||
)
|
||||
})
|
||||
|
||||
EmptyCard.displayName = 'EmptyCard'
|
||||
|
||||
type LineProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Line = React.memo(({
|
||||
className,
|
||||
}: LineProps) => {
|
||||
return (
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='2' height='241' viewBox='0 0 2 241' fill='none' className={className}>
|
||||
<path d='M1 0.5L1 240.5' stroke='url(#paint0_linear_1989_74474)' />
|
||||
<defs>
|
||||
<linearGradient id='paint0_linear_1989_74474' x1='-7.99584' y1='240.5' x2='-7.88094' y2='0.50004' gradientUnits='userSpaceOnUse'>
|
||||
<stop stopColor='white' stopOpacity='0.01' />
|
||||
<stop offset='0.503965' stopColor='#101828' stopOpacity='0.08' />
|
||||
<stop offset='1' stopColor='white' stopOpacity='0.01' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
||||
Line.displayName = 'Line'
|
||||
|
||||
const Empty: FC<IEmptyProps> = ({
|
||||
onClearFilter,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={'relative z-0 flex h-full items-center justify-center'}>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='relative z-10 flex h-14 w-14 items-center justify-center rounded-xl border border-divider-subtle bg-components-card-bg shadow-lg shadow-shadow-shadow-5'>
|
||||
<RiFileList2Line className='h-6 w-6 text-text-secondary' />
|
||||
<Line className='absolute -right-px top-1/2 -translate-y-1/2' />
|
||||
<Line className='absolute -left-px top-1/2 -translate-y-1/2' />
|
||||
<Line className='absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 rotate-90' />
|
||||
<Line className='absolute left-1/2 top-full -translate-x-1/2 -translate-y-1/2 rotate-90' />
|
||||
</div>
|
||||
<div className='system-md-regular mt-3 text-text-tertiary'>
|
||||
{t('datasetDocuments.segment.empty')}
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
className='system-sm-medium mt-1 text-text-accent'
|
||||
onClick={onClearFilter}
|
||||
>
|
||||
{t('datasetDocuments.segment.clearFilter')}
|
||||
</button>
|
||||
</div>
|
||||
<div className='absolute left-0 top-0 -z-20 flex h-full w-full flex-col gap-y-3 overflow-hidden'>
|
||||
{
|
||||
Array.from({ length: 10 }).map((_, i) => (
|
||||
<EmptyCard key={i} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div className='absolute left-0 top-0 -z-10 h-full w-full bg-dataset-chunk-list-mask-bg' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Empty)
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import Drawer from './drawer'
|
||||
import cn from '@/utils/classnames'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type IFullScreenDrawerProps = {
|
||||
isOpen: boolean
|
||||
onClose?: () => void
|
||||
fullScreen: boolean
|
||||
showOverlay?: boolean
|
||||
needCheckChunks?: boolean
|
||||
modal?: boolean
|
||||
}
|
||||
|
||||
const FullScreenDrawer = ({
|
||||
isOpen,
|
||||
onClose = noop,
|
||||
fullScreen,
|
||||
children,
|
||||
showOverlay = true,
|
||||
needCheckChunks = false,
|
||||
modal = false,
|
||||
}: React.PropsWithChildren<IFullScreenDrawerProps>) => {
|
||||
return (
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
panelClassName={cn(
|
||||
fullScreen
|
||||
? 'w-full'
|
||||
: 'w-[560px] pb-2 pr-2 pt-16',
|
||||
)}
|
||||
panelContentClassName={cn(
|
||||
'bg-components-panel-bg',
|
||||
!fullScreen && 'rounded-xl border-[0.5px] border-components-panel-border',
|
||||
)}
|
||||
showOverlay={showOverlay}
|
||||
needCheckChunks={needCheckChunks}
|
||||
modal={modal}
|
||||
>
|
||||
{children}
|
||||
</Drawer>)
|
||||
}
|
||||
|
||||
export default FullScreenDrawer
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from '@/utils/classnames'
|
||||
import type { SegmentDetailModel } from '@/models/datasets'
|
||||
import TagInput from '@/app/components/base/tag-input'
|
||||
|
||||
type IKeywordsProps = {
|
||||
segInfo?: Partial<SegmentDetailModel> & { id: string }
|
||||
className?: string
|
||||
keywords: string[]
|
||||
onKeywordsChange: (keywords: string[]) => void
|
||||
isEditMode?: boolean
|
||||
actionType?: 'edit' | 'add' | 'view'
|
||||
}
|
||||
|
||||
const Keywords: FC<IKeywordsProps> = ({
|
||||
segInfo,
|
||||
className,
|
||||
keywords,
|
||||
onKeywordsChange,
|
||||
isEditMode,
|
||||
actionType = 'view',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={classNames('flex flex-col', className)}>
|
||||
<div className='system-xs-medium-uppercase text-text-tertiary'>{t('datasetDocuments.segment.keywords')}</div>
|
||||
<div className='flex max-h-[200px] w-full flex-wrap gap-1 overflow-auto text-text-tertiary'>
|
||||
{(!segInfo?.keywords?.length && actionType === 'view')
|
||||
? '-'
|
||||
: (
|
||||
<TagInput
|
||||
items={keywords}
|
||||
onChange={newKeywords => onKeywordsChange(newKeywords)}
|
||||
disableAdd={!isEditMode}
|
||||
disableRemove={!isEditMode || (keywords.length === 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Keywords.displayName = 'Keywords'
|
||||
|
||||
export default React.memo(Keywords)
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { type FC, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import { useCountDown } from 'ahooks'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type IDefaultContentProps = {
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const DefaultContent: FC<IDefaultContentProps> = React.memo(({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='pb-4'>
|
||||
<span className='title-2xl-semi-bold text-text-primary'>{t('datasetDocuments.segment.regenerationConfirmTitle')}</span>
|
||||
<p className='system-md-regular text-text-secondary'>{t('datasetDocuments.segment.regenerationConfirmMessage')}</p>
|
||||
</div>
|
||||
<div className='flex justify-end gap-x-2 pt-6'>
|
||||
<Button onClick={onCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button variant='warning' destructive onClick={onConfirm}>
|
||||
{t('common.operation.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
DefaultContent.displayName = 'DefaultContent'
|
||||
|
||||
const RegeneratingContent: FC = React.memo(() => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='pb-4'>
|
||||
<span className='title-2xl-semi-bold text-text-primary'>{t('datasetDocuments.segment.regeneratingTitle')}</span>
|
||||
<p className='system-md-regular text-text-secondary'>{t('datasetDocuments.segment.regeneratingMessage')}</p>
|
||||
</div>
|
||||
<div className='flex justify-end pt-6'>
|
||||
<Button variant='warning' destructive disabled className='inline-flex items-center gap-x-0.5'>
|
||||
<RiLoader2Line className='h-4 w-4 animate-spin text-components-button-destructive-primary-text-disabled' />
|
||||
<span>{t('common.operation.regenerate')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
RegeneratingContent.displayName = 'RegeneratingContent'
|
||||
|
||||
type IRegenerationCompletedContentProps = {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const RegenerationCompletedContent: FC<IRegenerationCompletedContentProps> = React.memo(({
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const targetTime = useRef(Date.now() + 5000)
|
||||
const [countdown] = useCountDown({
|
||||
targetDate: targetTime.current,
|
||||
onEnd: () => {
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='pb-4'>
|
||||
<span className='title-2xl-semi-bold text-text-primary'>{t('datasetDocuments.segment.regenerationSuccessTitle')}</span>
|
||||
<p className='system-md-regular text-text-secondary'>{t('datasetDocuments.segment.regenerationSuccessMessage')}</p>
|
||||
</div>
|
||||
<div className='flex justify-end pt-6'>
|
||||
<Button variant='primary' onClick={onClose}>
|
||||
{`${t('common.operation.close')}${countdown === 0 ? '' : `(${Math.round(countdown / 1000)})`}`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
RegenerationCompletedContent.displayName = 'RegenerationCompletedContent'
|
||||
|
||||
type IRegenerationModalProps = {
|
||||
isShow: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const RegenerationModal: FC<IRegenerationModalProps> = ({
|
||||
isShow,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onClose,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [updateSucceeded, setUpdateSucceeded] = useState(false)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
eventEmitter?.useSubscription((v) => {
|
||||
if (v === 'update-segment') {
|
||||
setLoading(true)
|
||||
setUpdateSucceeded(false)
|
||||
}
|
||||
if (v === 'update-segment-success')
|
||||
setUpdateSucceeded(true)
|
||||
if (v === 'update-segment-done')
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal isShow={isShow} onClose={noop} className='!max-w-[480px] !rounded-2xl' wrapperClassName='!z-[10000]'>
|
||||
{!loading && !updateSucceeded && <DefaultContent onCancel={onCancel} onConfirm={onConfirm} />}
|
||||
{loading && !updateSucceeded && <RegeneratingContent />}
|
||||
{!loading && updateSucceeded && <RegenerationCompletedContent onClose={onClose} />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default RegenerationModal
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { type FC, useMemo } from 'react'
|
||||
import { Chunk } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ISegmentIndexTagProps = {
|
||||
positionId?: string | number
|
||||
label?: string
|
||||
className?: string
|
||||
labelPrefix?: string
|
||||
iconClassName?: string
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
export const SegmentIndexTag: FC<ISegmentIndexTagProps> = ({
|
||||
positionId,
|
||||
label,
|
||||
className,
|
||||
labelPrefix = 'Chunk',
|
||||
iconClassName,
|
||||
labelClassName,
|
||||
}) => {
|
||||
const localPositionId = useMemo(() => {
|
||||
const positionIdStr = String(positionId)
|
||||
if (positionIdStr.length >= 2)
|
||||
return `${labelPrefix}-${positionId}`
|
||||
return `${labelPrefix}-${positionIdStr.padStart(2, '0')}`
|
||||
}, [positionId, labelPrefix])
|
||||
return (
|
||||
<div className={cn('flex items-center', className)}>
|
||||
<Chunk className={cn('mr-0.5 h-3 w-3 p-[1px] text-text-tertiary', iconClassName)} />
|
||||
<div className={cn('system-xs-medium text-text-tertiary', labelClassName)}>
|
||||
{label || localPositionId}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
SegmentIndexTag.displayName = 'SegmentIndexTag'
|
||||
|
||||
export default React.memo(SegmentIndexTag)
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const Tag = ({ text, className }: { text: string; className?: string }) => {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-x-0.5', className)}>
|
||||
<span className='text-xs font-medium text-text-quaternary'>#</span>
|
||||
<span className='max-w-12 shrink-0 truncate text-xs text-text-tertiary'>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Tag.displayName = 'Tag'
|
||||
|
||||
export default React.memo(Tag)
|
||||