This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View 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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="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

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="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

View 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

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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: '' },
})
}

View File

@@ -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 },
})
}

View File

@@ -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 },
})
}

View File

@@ -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,
})),
})
}

View File

@@ -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 },
})
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,5 @@
export enum AddDocumentsStep {
dataSource = 'dataSource',
processDocuments = 'processDocuments',
processingDocuments = 'processingDocuments',
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

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