mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-16 02:18:06 -05:00
Compare commits
9 Commits
feat/imper
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ff68b39ce | ||
|
|
55700b9bf4 | ||
|
|
51e376847f | ||
|
|
feb994c819 | ||
|
|
12470a630c | ||
|
|
b813bf7f27 | ||
|
|
81cc88b2e2 | ||
|
|
87e6057033 | ||
|
|
f1796d13df |
@@ -4678,3 +4678,349 @@ export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReductoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='400'
|
||||
height='400'
|
||||
viewBox='50 40 300 320'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M85.3434 70.7805H314.657V240.307L226.44 329.219H85.3434V70.7805ZM107.796 93.2319H292.205V204.487H206.493V306.767H107.801L107.796 93.2319Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PulseIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 6 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M0 6.63667C0 6.28505 0.284685 6 0.635863 6H1.54133C1.89251 6 2.17719 6.28505 2.17719 6.63667V7.54329C2.17719 7.89492 1.89251 8.17997 1.54133 8.17997H0.635863C0.284686 8.17997 0 7.89492 0 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 6.63667C3.11318 6.28505 3.39787 6 3.74905 6H4.65452C5.00569 6 5.29038 6.28505 5.29038 6.63667V7.54329C5.29038 7.89492 5.00569 8.17997 4.65452 8.17997H3.74905C3.39787 8.17997 3.11318 7.89492 3.11318 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 6.63667C6.22637 6.28505 6.51105 6 6.86223 6H7.7677C8.11888 6 8.40356 6.28505 8.40356 6.63667V7.54329C8.40356 7.89492 8.11888 8.17997 7.7677 8.17997H6.86223C6.51105 8.17997 6.22637 7.89492 6.22637 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 6.63667C9.33955 6.28505 9.62424 6 9.97541 6H10.8809C11.2321 6 11.5167 6.28505 11.5167 6.63667V7.54329C11.5167 7.89492 11.2321 8.17997 10.8809 8.17997H9.97541C9.62424 8.17997 9.33955 7.89492 9.33955 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 6.63667C12.4527 6.28505 12.7374 6 13.0886 6H13.9941C14.3452 6 14.6299 6.28505 14.6299 6.63667V7.54329C14.6299 7.89492 14.3452 8.17997 13.9941 8.17997H13.0886C12.7374 8.17997 12.4527 7.89492 12.4527 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 6.63667C15.5659 6.28505 15.8506 6 16.2018 6H17.1073C17.4584 6 17.7431 6.28505 17.7431 6.63667V7.54329C17.7431 7.89492 17.4584 8.17997 17.1073 8.17997H16.2018C15.8506 8.17997 15.5659 7.89492 15.5659 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 6.63667C18.6791 6.28505 18.9638 6 19.315 6H20.2204C20.5716 6 20.8563 6.28505 20.8563 6.63667V7.54329C20.8563 7.89492 20.5716 8.17997 20.2204 8.17997H19.315C18.9638 8.17997 18.6791 7.89492 18.6791 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 6.63667C21.7923 6.28505 22.077 6 22.4282 6H23.3336C23.6848 6 23.9695 6.28505 23.9695 6.63667V7.54329C23.9695 7.89492 23.6848 8.17997 23.3336 8.17997H22.4282C22.077 8.17997 21.7923 7.89492 21.7923 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 9.75382C0 9.4022 0.284685 9.11715 0.635863 9.11715H1.54133C1.89251 9.11715 2.17719 9.4022 2.17719 9.75382V10.6604C2.17719 11.0121 1.89251 11.2971 1.54133 11.2971H0.635863C0.284686 11.2971 0 11.0121 0 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 9.75382C3.11318 9.4022 3.39787 9.11715 3.74905 9.11715H4.65452C5.00569 9.11715 5.29038 9.4022 5.29038 9.75382V10.6604C5.29038 11.0121 5.00569 11.2971 4.65452 11.2971H3.74905C3.39787 11.2971 3.11318 11.0121 3.11318 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 9.75382C6.22637 9.4022 6.51105 9.11715 6.86223 9.11715H7.7677C8.11888 9.11715 8.40356 9.4022 8.40356 9.75382V10.6604C8.40356 11.0121 8.11888 11.2971 7.7677 11.2971H6.86223C6.51105 11.2971 6.22637 11.0121 6.22637 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 9.75382C9.33955 9.4022 9.62424 9.11715 9.97541 9.11715H10.8809C11.2321 9.11715 11.5167 9.4022 11.5167 9.75382V10.6604C11.5167 11.0121 11.2321 11.2971 10.8809 11.2971H9.97541C9.62424 11.2971 9.33955 11.0121 9.33955 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 9.75382C12.4527 9.4022 12.7374 9.11715 13.0886 9.11715H13.9941C14.3452 9.11715 14.6299 9.4022 14.6299 9.75382V10.6604C14.6299 11.0121 14.3452 11.2971 13.9941 11.2971H13.0886C12.7374 11.2971 12.4527 11.0121 12.4527 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 9.75382C15.5659 9.4022 15.8506 9.11715 16.2018 9.11715H17.1073C17.4584 9.11715 17.7431 9.4022 17.7431 9.75382V10.6604C17.7431 11.0121 17.4584 11.2971 17.1073 11.2971H16.2018C15.8506 11.2971 15.5659 11.0121 15.5659 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 9.75382C18.6791 9.4022 18.9638 9.11715 19.315 9.11715H20.2204C20.5716 9.11715 20.8563 9.4022 20.8563 9.75382V10.6604C20.8563 11.0121 20.5716 11.2971 20.2204 11.2971H19.315C18.9638 11.2971 18.6791 11.0121 18.6791 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 9.75382C21.7923 9.4022 22.077 9.11715 22.4282 9.11715H23.3336C23.6848 9.11715 23.9695 9.4022 23.9695 9.75382V10.6604C23.9695 11.0121 23.6848 11.2971 23.3336 11.2971H22.4282C22.077 11.2971 21.7923 11.0121 21.7923 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 12.871C0 12.5193 0.284685 12.2343 0.635863 12.2343H1.54133C1.89251 12.2343 2.17719 12.5193 2.17719 12.871V13.7776C2.17719 14.1292 1.89251 14.4143 1.54133 14.4143H0.635863C0.284686 14.4143 0 14.1292 0 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 12.871C3.11318 12.5193 3.39787 12.2343 3.74905 12.2343H4.65452C5.00569 12.2343 5.29038 12.5193 5.29038 12.871V13.7776C5.29038 14.1292 5.00569 14.4143 4.65452 14.4143H3.74905C3.39787 14.4143 3.11318 14.1292 3.11318 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 12.871C6.22637 12.5193 6.51105 12.2343 6.86223 12.2343H7.7677C8.11888 12.2343 8.40356 12.5193 8.40356 12.871V13.7776C8.40356 14.1292 8.11888 14.4143 7.7677 14.4143H6.86223C6.51105 14.4143 6.22637 14.1292 6.22637 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 12.871C9.33955 12.5193 9.62424 12.2343 9.97541 12.2343H10.8809C11.2321 12.2343 11.5167 12.5193 11.5167 12.871V13.7776C11.5167 14.1292 11.2321 14.4143 10.8809 14.4143H9.97541C9.62424 14.4143 9.33955 14.1292 9.33955 13.7776V12.871Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 12.871C12.4527 12.5193 12.7374 12.2343 13.0886 12.2343H13.9941C14.3452 12.2343 14.6299 12.5193 14.6299 12.871V13.7776C14.6299 14.1292 14.3452 14.4143 13.9941 14.4143H13.0886C12.7374 14.4143 12.4527 14.1292 12.4527 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 12.871C15.5659 12.5193 15.8506 12.2343 16.2018 12.2343H17.1073C17.4584 12.2343 17.7431 12.5193 17.7431 12.871V13.7776C17.7431 14.1292 17.4584 14.4143 17.1073 14.4143H16.2018C15.8506 14.4143 15.5659 14.1292 15.5659 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 12.871C18.6791 12.5193 18.9638 12.2343 19.315 12.2343H20.2204C20.5716 12.2343 20.8563 12.5193 20.8563 12.871V13.7776C20.8563 14.1292 20.5716 14.4143 20.2204 14.4143H19.315C18.9638 14.4143 18.6791 14.1292 18.6791 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 12.871C21.7923 12.5193 22.077 12.2343 22.4282 12.2343H23.3336C23.6848 12.2343 23.9695 12.5193 23.9695 12.871V13.7776C23.9695 14.1292 23.6848 14.4143 23.3336 14.4143H22.4282C22.077 14.4143 21.7923 14.1292 21.7923 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 15.9881C0 15.6365 0.284685 15.3514 0.635863 15.3514H1.54133C1.89251 15.3514 2.17719 15.6365 2.17719 15.9881V16.8947C2.17719 17.2464 1.89251 17.5314 1.54133 17.5314H0.635863C0.284686 17.5314 0 17.2464 0 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 15.9881C3.11318 15.6365 3.39787 15.3514 3.74905 15.3514H4.65452C5.00569 15.3514 5.29038 15.6365 5.29038 15.9881V16.8947C5.29038 17.2464 5.00569 17.5314 4.65452 17.5314H3.74905C3.39787 17.5314 3.11318 17.2464 3.11318 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 15.9881C6.22637 15.6365 6.51105 15.3514 6.86223 15.3514H7.7677C8.11888 15.3514 8.40356 15.6365 8.40356 15.9881V16.8947C8.40356 17.2464 8.11888 17.5314 7.7677 17.5314H6.86223C6.51105 17.5314 6.22637 17.2464 6.22637 16.8947V15.9881Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 15.9881C9.33955 15.6365 9.62424 15.3514 9.97541 15.3514H10.8809C11.2321 15.3514 11.5167 15.6365 11.5167 15.9881V16.8947C11.5167 17.2464 11.2321 17.5314 10.8809 17.5314H9.97541C9.62424 17.5314 9.33955 17.2464 9.33955 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 15.9881C12.4527 15.6365 12.7374 15.3514 13.0886 15.3514H13.9941C14.3452 15.3514 14.6299 15.6365 14.6299 15.9881V16.8947C14.6299 17.2464 14.3452 17.5314 13.9941 17.5314H13.0886C12.7374 17.5314 12.4527 17.2464 12.4527 16.8947V15.9881Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 15.9881C15.5659 15.6365 15.8506 15.3514 16.2018 15.3514H17.1073C17.4584 15.3514 17.7431 15.6365 17.7431 15.9881V16.8947C17.7431 17.2464 17.4584 17.5314 17.1073 17.5314H16.2018C15.8506 17.5314 15.5659 17.2464 15.5659 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 15.9881C18.6791 15.6365 18.9638 15.3514 19.315 15.3514H20.2204C20.5716 15.3514 20.8563 15.6365 20.8563 15.9881V16.8947C20.8563 17.2464 20.5716 17.5314 20.2204 17.5314H19.315C18.9638 17.5314 18.6791 17.2464 18.6791 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 15.9881C21.7923 15.6365 22.077 15.3514 22.4282 15.3514H23.3336C23.6848 15.3514 23.9695 15.6365 23.9695 15.9881V16.8947C23.9695 17.2464 23.6848 17.5314 23.3336 17.5314H22.4282C22.077 17.5314 21.7923 17.2464 21.7923 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 19.1053C0 18.7536 0.284685 18.4686 0.635863 18.4686H1.54133C1.89251 18.4686 2.17719 18.7536 2.17719 19.1053V20.0119C2.17719 20.3635 1.89251 20.6486 1.54133 20.6486H0.635863C0.284686 20.6486 0 20.3635 0 20.0119V19.1053Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 19.1053C3.11318 18.7536 3.39787 18.4686 3.74905 18.4686H4.65452C5.00569 18.4686 5.29038 18.7536 5.29038 19.1053V20.0119C5.29038 20.3635 5.00569 20.6486 4.65452 20.6486H3.74905C3.39787 20.6486 3.11318 20.3635 3.11318 20.0119V19.1053Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 19.1053C6.22637 18.7536 6.51105 18.4686 6.86223 18.4686H7.7677C8.11888 18.4686 8.40356 18.7536 8.40356 19.1053V20.0119C8.40356 20.3635 8.11888 20.6486 7.7677 20.6486H6.86223C6.51105 20.6486 6.22637 20.3635 6.22637 20.0119V19.1053Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 19.1053C9.33955 18.7536 9.62424 18.4686 9.97541 18.4686H10.8809C11.2321 18.4686 11.5167 18.7536 11.5167 19.1053V20.0119C11.5167 20.3635 11.2321 20.6486 10.8809 20.6486H9.97541C9.62424 20.6486 9.33955 20.3635 9.33955 20.0119V19.1053Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 19.1053C12.4527 18.7536 12.7374 18.4686 13.0886 18.4686H13.9941C14.3452 18.4686 14.6299 18.7536 14.6299 19.1053V20.0119C14.6299 20.3635 14.3452 20.6486 13.9941 20.6486H13.0886C12.7374 20.6486 12.4527 20.3635 12.4527 20.0119V19.1053Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 19.1053C15.5659 18.7536 15.8506 18.4686 16.2018 18.4686H17.1073C17.4584 18.4686 17.7431 18.7536 17.7431 19.1053V20.0119C17.7431 20.3635 17.4584 20.6486 17.1073 20.6486H16.2018C15.8506 20.6486 15.5659 20.3635 15.5659 20.0119V19.1053Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 19.1053C18.6791 18.7536 18.9638 18.4686 19.315 18.4686H20.2204C20.5716 18.4686 20.8563 18.7536 20.8563 19.1053V20.0119C20.8563 20.3635 20.5716 20.6486 20.2204 20.6486H19.315C18.9638 20.6486 18.6791 20.3635 18.6791 20.0119V19.1053Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 19.1053C21.7923 18.7536 22.077 18.4686 22.4282 18.4686H23.3336C23.6848 18.4686 23.9695 18.7536 23.9695 19.1053V20.0119C23.9695 20.3635 23.6848 20.6486 23.3336 20.6486H22.4282C22.077 20.6486 21.7923 20.3635 21.7923 20.0119V19.1053Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M0 22.2224C0 21.8708 0.284685 21.5857 0.635863 21.5857H1.54133C1.89251 21.5857 2.17719 21.8708 2.17719 22.2224V23.129C2.17719 23.4807 1.89251 23.7657 1.54133 23.7657H0.635863C0.284686 23.7657 0 23.4807 0 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 22.2224C3.11318 21.8708 3.39787 21.5857 3.74905 21.5857H4.65452C5.00569 21.5857 5.29038 21.8708 5.29038 22.2224V23.129C5.29038 23.4807 5.00569 23.7657 4.65452 23.7657H3.74905C3.39787 23.7657 3.11318 23.4807 3.11318 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 22.2224C6.22637 21.8708 6.51105 21.5857 6.86223 21.5857H7.7677C8.11888 21.5857 8.40356 21.8708 8.40356 22.2224V23.129C8.40356 23.4807 8.11888 23.7657 7.7677 23.7657H6.86223C6.51105 23.7657 6.22637 23.4807 6.22637 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 22.2224C9.33955 21.8708 9.62424 21.5857 9.97541 21.5857H10.8809C11.2321 21.5857 11.5167 21.8708 11.5167 22.2224V23.129C11.5167 23.4807 11.2321 23.7657 10.8809 23.7657H9.97541C9.62424 23.7657 9.33955 23.4807 9.33955 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 22.2224C12.4527 21.8708 12.7374 21.5857 13.0886 21.5857H13.9941C14.3452 21.5857 14.6299 21.8708 14.6299 22.2224V23.129C14.6299 23.4807 14.3452 23.7657 13.9941 23.7657H13.0886C12.7374 23.7657 12.4527 23.4807 12.4527 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 22.2224C15.5659 21.8708 15.8506 21.5857 16.2018 21.5857H17.1073C17.4584 21.5857 17.7431 21.8708 17.7431 22.2224V23.129C17.7431 23.4807 17.4584 23.7657 17.1073 23.7657H16.2018C15.8506 23.7657 15.5659 23.4807 15.5659 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 22.2224C18.6791 21.8708 18.9638 21.5857 19.315 21.5857H20.2204C20.5716 21.5857 20.8563 21.8708 20.8563 22.2224V23.129C20.8563 23.4807 20.5716 23.7657 20.2204 23.7657H19.315C18.9638 23.7657 18.6791 23.4807 18.6791 23.129V22.2224Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 22.2224C21.7923 21.8708 22.077 21.5857 22.4282 21.5857H23.3336C23.6848 21.5857 23.9695 21.8708 23.9695 22.2224V23.129C23.9695 23.4807 23.6848 23.7657 23.3336 23.7657H22.4282C22.077 23.7657 21.7923 23.4807 21.7923 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 25.3396C0 24.9879 0.284685 24.7029 0.635863 24.7029H1.54133C1.89251 24.7029 2.17719 24.9879 2.17719 25.3396V26.2462C2.17719 26.5978 1.89251 26.8829 1.54133 26.8829H0.635863C0.284686 26.8829 0 26.5978 0 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 25.3396C3.11318 24.9879 3.39787 24.7029 3.74905 24.7029H4.65452C5.00569 24.7029 5.29038 24.9879 5.29038 25.3396V26.2462C5.29038 26.5978 5.00569 26.8829 4.65452 26.8829H3.74905C3.39787 26.8829 3.11318 26.5978 3.11318 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 25.3396C6.22637 24.9879 6.51105 24.7029 6.86223 24.7029H7.7677C8.11888 24.7029 8.40356 24.9879 8.40356 25.3396V26.2462C8.40356 26.5978 8.11888 26.8829 7.7677 26.8829H6.86223C6.51105 26.8829 6.22637 26.5978 6.22637 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 25.3396C9.33955 24.9879 9.62424 24.7029 9.97541 24.7029H10.8809C11.2321 24.7029 11.5167 24.9879 11.5167 25.3396V26.2462C11.5167 26.5978 11.2321 26.8829 10.8809 26.8829H9.97541C9.62424 26.8829 9.33955 26.5978 9.33955 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 25.3396C12.4527 24.9879 12.7374 24.7029 13.0886 24.7029H13.9941C14.3452 24.7029 14.6299 24.9879 14.6299 25.3396V26.2462C14.6299 26.5978 14.3452 26.8829 13.9941 26.8829H13.0886C12.7374 26.8829 12.4527 26.5978 12.4527 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 25.3396C15.5659 24.9879 15.8506 24.7029 16.2018 24.7029H17.1073C17.4584 24.7029 17.7431 24.9879 17.7431 25.3396V26.2462C17.7431 26.5978 17.4584 26.8829 17.1073 26.8829H16.2018C15.8506 26.8829 15.5659 26.5978 15.5659 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 25.3396C18.6791 24.9879 18.9638 24.7029 19.315 24.7029H20.2204C20.5716 24.7029 20.8563 24.9879 20.8563 25.3396V26.2462C20.8563 26.5978 20.5716 26.8829 20.2204 26.8829H19.315C18.9638 26.8829 18.6791 26.5978 18.6791 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 25.3396C21.7923 24.9879 22.077 24.7029 22.4282 24.7029H23.3336C23.6848 24.7029 23.9695 24.9879 23.9695 25.3396V26.2462C23.9695 26.5978 23.6848 26.8829 23.3336 26.8829H22.4282C22.077 26.8829 21.7923 26.5978 21.7923 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 28.4567C0 28.1051 0.284685 27.82 0.635863 27.82H1.54133C1.89251 27.82 2.17719 28.1051 2.17719 28.4567V29.3633C2.17719 29.715 1.89251 30 1.54133 30H0.635863C0.284686 30 0 29.715 0 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 28.4567C3.11318 28.1051 3.39787 27.82 3.74905 27.82H4.65452C5.00569 27.82 5.29038 28.1051 5.29038 28.4567V29.3633C5.29038 29.715 5.00569 30 4.65452 30H3.74905C3.39787 30 3.11318 29.715 3.11318 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 28.4567C6.22637 28.1051 6.51105 27.82 6.86223 27.82H7.7677C8.11888 27.82 8.40356 28.1051 8.40356 28.4567V29.3633C8.40356 29.715 8.11888 30 7.7677 30H6.86223C6.51105 30 6.22637 29.715 6.22637 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 28.4567C9.33955 28.1051 9.62424 27.82 9.97541 27.82H10.8809C11.2321 27.82 11.5167 28.1051 11.5167 28.4567V29.3633C11.5167 29.715 11.2321 30 10.8809 30H9.97541C9.62424 30 9.33955 29.715 9.33955 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 28.4567C12.4527 28.1051 12.7374 27.82 13.0886 27.82H13.9941C14.3452 27.82 14.6299 28.1051 14.6299 28.4567V29.3633C14.6299 29.715 14.3452 30 13.9941 30H13.0886C12.7374 30 12.4527 29.715 12.4527 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 28.4567C15.5659 28.1051 15.8506 27.82 16.2018 27.82H17.1073C17.4584 27.82 17.7431 28.1051 17.7431 28.4567V29.3633C17.7431 29.715 17.4584 30 17.1073 30H16.2018C15.8506 30 15.5659 29.715 15.5659 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 28.4567C18.6791 28.1051 18.9638 27.82 19.315 27.82H20.2204C20.5716 27.82 20.8563 28.1051 20.8563 28.4567V29.3633C20.8563 29.715 20.5716 30 20.2204 30H19.315C18.9638 30 18.6791 29.715 18.6791 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 28.4567C21.7923 28.1051 22.077 27.82 22.4282 27.82H23.3336C23.6848 27.82 23.9695 28.1051 23.9695 28.4567V29.3633C23.9695 29.715 23.6848 30 23.3336 30H22.4282C22.077 30 21.7923 29.715 21.7923 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,9 +84,11 @@ import {
|
||||
PolymarketIcon,
|
||||
PostgresIcon,
|
||||
PosthogIcon,
|
||||
PulseIcon,
|
||||
QdrantIcon,
|
||||
RDSIcon,
|
||||
RedditIcon,
|
||||
ReductoIcon,
|
||||
ResendIcon,
|
||||
S3Icon,
|
||||
SalesforceIcon,
|
||||
@@ -208,9 +210,11 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
polymarket: PolymarketIcon,
|
||||
postgresql: PostgresIcon,
|
||||
posthog: PosthogIcon,
|
||||
pulse: PulseIcon,
|
||||
qdrant: QdrantIcon,
|
||||
rds: RDSIcon,
|
||||
reddit: RedditIcon,
|
||||
reducto: ReductoIcon,
|
||||
resend: ResendIcon,
|
||||
s3: S3Icon,
|
||||
salesforce: SalesforceIcon,
|
||||
|
||||
@@ -79,9 +79,11 @@
|
||||
"polymarket",
|
||||
"postgresql",
|
||||
"posthog",
|
||||
"pulse",
|
||||
"qdrant",
|
||||
"rds",
|
||||
"reddit",
|
||||
"reducto",
|
||||
"resend",
|
||||
"s3",
|
||||
"salesforce",
|
||||
|
||||
72
apps/docs/content/docs/en/tools/pulse.mdx
Normal file
72
apps/docs/content/docs/en/tools/pulse.mdx
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: Pulse
|
||||
description: Extract text from documents using Pulse OCR
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="pulse"
|
||||
color="#E0E0E0"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
The [Pulse](https://www.pulseapi.com/) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
|
||||
|
||||
With Pulse, you can:
|
||||
|
||||
- **Extract text from documents**: Quickly convert scanned PDFs, images, and Office documents to usable text, markdown, or JSON.
|
||||
- **Process documents by URL or upload**: Simply provide a file URL or use upload to extract text from local documents or remote resources.
|
||||
- **Flexible output formats**: Choose between markdown, plain text, or JSON representations of the extracted content for downstream processing.
|
||||
- **Selective page processing**: Specify a range of pages to process, reducing processing time and cost when you only need part of a document.
|
||||
- **Figure and table extraction**: Optionally extract figures and tables, with automatic caption and description generation for populated context.
|
||||
- **Get processing insights**: Receive detailed metadata on each job, including file type, page count, processing time, and more.
|
||||
- **Integration-ready responses**: Incorporate extracted content into research, workflow automation, or data analysis pipelines.
|
||||
|
||||
Ideal for automating tedious document review, enabling content summarization, research, and more, Pulse Parser brings real-world documents into the digital workflow era.
|
||||
|
||||
If you need accurate, scalable, and developer-friendly document parsing capabilities—across formats, languages, and layouts—Pulse empowers your agents to read the world.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via URL or upload.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `pulse_parser`
|
||||
|
||||
Parse documents (PDF, images, Office docs) using Pulse OCR API
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `filePath` | string | Yes | URL to a document to be processed |
|
||||
| `fileUpload` | object | No | File upload data from file-upload component |
|
||||
| `pages` | string | No | Page range to process \(1-indexed, e.g., "1-2,5"\) |
|
||||
| `extractFigure` | boolean | No | Enable figure extraction from the document |
|
||||
| `figureDescription` | boolean | No | Generate descriptions/captions for extracted figures |
|
||||
| `returnHtml` | boolean | No | Include HTML in the response |
|
||||
| `chunking` | string | No | Chunking strategies \(comma-separated: semantic, header, page, recursive\) |
|
||||
| `chunkSize` | number | No | Maximum characters per chunk when chunking is enabled |
|
||||
| `apiKey` | string | Yes | Pulse API key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `markdown` | string | Extracted content in markdown format |
|
||||
| `page_count` | number | Number of pages in the document |
|
||||
| `job_id` | string | Unique job identifier |
|
||||
| `bounding_boxes` | json | Bounding box layout information |
|
||||
| `extraction_url` | string | URL for extraction results \(for large documents\) |
|
||||
| `html` | string | HTML content if requested |
|
||||
| `structured_output` | json | Structured output if schema was provided |
|
||||
| `chunks` | json | Chunked content if chunking was enabled |
|
||||
| `figures` | json | Extracted figures if figure extraction was enabled |
|
||||
|
||||
|
||||
63
apps/docs/content/docs/en/tools/reducto.mdx
Normal file
63
apps/docs/content/docs/en/tools/reducto.mdx
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: Reducto
|
||||
description: Extract text from PDF documents
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="reducto"
|
||||
color="#5c0c5c"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
The [Reducto](https://reducto.ai/) tool enables fast and accurate extraction of text and data from PDF documents via OCR (Optical Character Recognition). Reducto is designed for agent workflows, making it easy to process uploaded or linked PDFs and transform their contents into ready-to-use information.
|
||||
|
||||
With the Reducto tool, you can:
|
||||
|
||||
- **Extract text and tables from PDFs**: Quickly convert scanned or digital PDFs to text, markdown, or structured JSON.
|
||||
- **Parse PDFs from uploads or URLs**: Process documents either by uploading a PDF or specifying a direct URL.
|
||||
- **Customize output formatting**: Choose your preferred output format—markdown, plain text, or JSON—and specify table formats as markdown or HTML.
|
||||
- **Select specific pages**: Optionally extract content from particular pages to optimize processing and focus on what matters.
|
||||
- **Receive detailed processing metadata**: Alongside extracted content, get job details, processing times, source file info, page counts, and OCR usage stats for audit and automation.
|
||||
|
||||
Whether you’re automating workflow steps, extracting business-critical information, or unlocking archival documents for search and analysis, Reducto’s OCR parser gives you structured, actionable data from even the most complex PDFs.
|
||||
|
||||
Looking for reliable and scalable PDF parsing? Reducto is optimized for developer and agent use—providing accuracy, speed, and flexibility for modern document understanding.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `reducto_parser`
|
||||
|
||||
Parse PDF documents using Reducto OCR API
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `filePath` | string | Yes | URL to a PDF document to be processed |
|
||||
| `fileUpload` | object | No | File upload data from file-upload component |
|
||||
| `pages` | array | No | Specific pages to process \(1-indexed page numbers\) |
|
||||
| `tableOutputFormat` | string | No | Table output format \(html or markdown\). Defaults to markdown. |
|
||||
| `apiKey` | string | Yes | Reducto API key \(REDUCTO_API_KEY\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `job_id` | string | Unique identifier for the processing job |
|
||||
| `duration` | number | Processing time in seconds |
|
||||
| `usage` | json | Resource consumption data |
|
||||
| `result` | json | Parsed document content with chunks and blocks |
|
||||
| `pdf_url` | string | Storage URL of converted PDF |
|
||||
| `studio_link` | string | Link to Reducto studio interface |
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
:root {
|
||||
--sidebar-width: 232px; /* SIDEBAR_WIDTH.DEFAULT */
|
||||
--panel-width: 290px; /* PANEL_WIDTH.DEFAULT */
|
||||
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
|
||||
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
|
||||
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
|
||||
--terminal-height: 155px; /* TERMINAL_HEIGHT.DEFAULT */
|
||||
@@ -77,24 +77,6 @@
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected node ring indicator
|
||||
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
|
||||
*/
|
||||
.react-flow__node.selected > div > div {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.react-flow__node.selected > div > div::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1.75px var(--brand-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color tokens - single source of truth for all colors
|
||||
* Light mode: Warm theme
|
||||
@@ -576,32 +558,6 @@ input[type="search"]::-ms-clear {
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
.streaming-effect {
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
|
||||
.streaming-effect::after {
|
||||
content: "";
|
||||
@apply pointer-events-none absolute left-0 top-0 h-full w-full;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(128, 128, 128, 0) 0%,
|
||||
rgba(128, 128, 128, 0.1) 50%,
|
||||
rgba(128, 128, 128, 0) 100%
|
||||
);
|
||||
animation: code-shimmer 1.5s infinite;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dark .streaming-effect::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(180, 180, 180, 0) 0%,
|
||||
rgba(180, 180, 180, 0.1) 50%,
|
||||
rgba(180, 180, 180, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.loading-placeholder::placeholder {
|
||||
animation: placeholder-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
169
apps/sim/app/api/tools/pulse/parse/route.ts
Normal file
169
apps/sim/app/api/tools/pulse/parse/route.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('PulseParseAPI')
|
||||
|
||||
const PulseParseSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
filePath: z.string().min(1, 'File path is required'),
|
||||
pages: z.string().optional(),
|
||||
extractFigure: z.boolean().optional(),
|
||||
figureDescription: z.boolean().optional(),
|
||||
returnHtml: z.boolean().optional(),
|
||||
chunking: z.string().optional(),
|
||||
chunkSize: z.number().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Pulse parse attempt`, {
|
||||
error: authResult.error || 'Missing userId',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Unauthorized',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
const body = await request.json()
|
||||
const validatedData = PulseParseSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Pulse parse request`, {
|
||||
filePath: validatedData.filePath,
|
||||
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
|
||||
userId,
|
||||
})
|
||||
|
||||
let fileUrl = validatedData.filePath
|
||||
|
||||
if (validatedData.filePath?.includes('/api/files/serve/')) {
|
||||
try {
|
||||
const storageKey = extractStorageKey(validatedData.filePath)
|
||||
const context = inferContextFromKey(storageKey)
|
||||
|
||||
const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false)
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
|
||||
userId,
|
||||
key: storageKey,
|
||||
context,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'File not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
|
||||
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to generate file access URL',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else if (validatedData.filePath?.startsWith('/')) {
|
||||
const baseUrl = getBaseUrl()
|
||||
fileUrl = `${baseUrl}${validatedData.filePath}`
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file_url', fileUrl)
|
||||
|
||||
if (validatedData.pages) {
|
||||
formData.append('pages', validatedData.pages)
|
||||
}
|
||||
if (validatedData.extractFigure !== undefined) {
|
||||
formData.append('extract_figure', String(validatedData.extractFigure))
|
||||
}
|
||||
if (validatedData.figureDescription !== undefined) {
|
||||
formData.append('figure_description', String(validatedData.figureDescription))
|
||||
}
|
||||
if (validatedData.returnHtml !== undefined) {
|
||||
formData.append('return_html', String(validatedData.returnHtml))
|
||||
}
|
||||
if (validatedData.chunking) {
|
||||
formData.append('chunking', validatedData.chunking)
|
||||
}
|
||||
if (validatedData.chunkSize !== undefined) {
|
||||
formData.append('chunk_size', String(validatedData.chunkSize))
|
||||
}
|
||||
|
||||
const pulseResponse = await fetch('https://api.runpulse.com/extract', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': validatedData.apiKey,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!pulseResponse.ok) {
|
||||
const errorText = await pulseResponse.text()
|
||||
logger.error(`[${requestId}] Pulse API error:`, errorText)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Pulse API error: ${pulseResponse.statusText}`,
|
||||
},
|
||||
{ status: pulseResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const pulseData = await pulseResponse.json()
|
||||
|
||||
logger.info(`[${requestId}] Pulse parse successful`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: pulseData,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error in Pulse parse:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
167
apps/sim/app/api/tools/reducto/parse/route.ts
Normal file
167
apps/sim/app/api/tools/reducto/parse/route.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('ReductoParseAPI')
|
||||
|
||||
const ReductoParseSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
filePath: z.string().min(1, 'File path is required'),
|
||||
pages: z.array(z.number()).optional(),
|
||||
tableOutputFormat: z.enum(['html', 'md']).optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Reducto parse attempt`, {
|
||||
error: authResult.error || 'Missing userId',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Unauthorized',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
const body = await request.json()
|
||||
const validatedData = ReductoParseSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Reducto parse request`, {
|
||||
filePath: validatedData.filePath,
|
||||
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
|
||||
userId,
|
||||
})
|
||||
|
||||
let fileUrl = validatedData.filePath
|
||||
|
||||
if (validatedData.filePath?.includes('/api/files/serve/')) {
|
||||
try {
|
||||
const storageKey = extractStorageKey(validatedData.filePath)
|
||||
const context = inferContextFromKey(storageKey)
|
||||
|
||||
const hasAccess = await verifyFileAccess(
|
||||
storageKey,
|
||||
userId,
|
||||
undefined, // customConfig
|
||||
context, // context
|
||||
false // isLocal
|
||||
)
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
|
||||
userId,
|
||||
key: storageKey,
|
||||
context,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'File not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
|
||||
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to generate file access URL',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else if (validatedData.filePath?.startsWith('/')) {
|
||||
const baseUrl = getBaseUrl()
|
||||
fileUrl = `${baseUrl}${validatedData.filePath}`
|
||||
}
|
||||
|
||||
const reductoBody: Record<string, unknown> = {
|
||||
input: fileUrl,
|
||||
}
|
||||
|
||||
if (validatedData.pages && validatedData.pages.length > 0) {
|
||||
reductoBody.settings = {
|
||||
page_range: validatedData.pages,
|
||||
}
|
||||
}
|
||||
|
||||
if (validatedData.tableOutputFormat) {
|
||||
reductoBody.formatting = {
|
||||
table_output_format: validatedData.tableOutputFormat,
|
||||
}
|
||||
}
|
||||
|
||||
const reductoResponse = await fetch('https://platform.reducto.ai/parse', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${validatedData.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(reductoBody),
|
||||
})
|
||||
|
||||
if (!reductoResponse.ok) {
|
||||
const errorText = await reductoResponse.text()
|
||||
logger.error(`[${requestId}] Reducto API error:`, errorText)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Reducto API error: ${reductoResponse.statusText}`,
|
||||
},
|
||||
{ status: reductoResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const reductoData = await reductoResponse.json()
|
||||
|
||||
logger.info(`[${requestId}] Reducto parse successful`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: reductoData,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error in Reducto parse:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ export const ActionBar = memo(
|
||||
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
|
||||
const isResponseBlock = blockType === 'response'
|
||||
const isNoteBlock = blockType === 'note'
|
||||
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
|
||||
|
||||
/**
|
||||
* Get appropriate tooltip message based on disabled state
|
||||
@@ -125,7 +126,7 @@ export const ActionBar = memo(
|
||||
'dark:border-transparent dark:bg-[var(--surface-4)]'
|
||||
)}
|
||||
>
|
||||
{!isNoteBlock && (
|
||||
{!isNoteBlock && !isSubflowBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -148,7 +149,7 @@ export const ActionBar = memo(
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isStartBlock && !isResponseBlock && (
|
||||
{!isStartBlock && !isResponseBlock && !isSubflowBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -169,7 +170,7 @@ export const ActionBar = memo(
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isNoteBlock && (
|
||||
{!isNoteBlock && !isSubflowBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -94,6 +94,9 @@ interface ProcessedAttachment {
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
/** Timeout for FileReader operations in milliseconds */
|
||||
const FILE_READ_TIMEOUT_MS = 60000
|
||||
|
||||
/**
|
||||
* Reads files and converts them to data URLs for image display
|
||||
* @param chatFiles - Array of chat files to process
|
||||
@@ -107,8 +110,37 @@ const processFileAttachments = async (chatFiles: ChatFile[]): Promise<ProcessedA
|
||||
try {
|
||||
dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
let settled = false
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
reader.abort()
|
||||
reject(new Error(`File read timed out after ${FILE_READ_TIMEOUT_MS}ms`))
|
||||
}
|
||||
}, FILE_READ_TIMEOUT_MS)
|
||||
|
||||
reader.onload = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timeoutId)
|
||||
resolve(reader.result as string)
|
||||
}
|
||||
}
|
||||
reader.onerror = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timeoutId)
|
||||
reject(reader.error)
|
||||
}
|
||||
}
|
||||
reader.onabort = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timeoutId)
|
||||
reject(new Error('File read aborted'))
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file.file)
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -202,7 +234,6 @@ export function Chat() {
|
||||
const triggerWorkflowUpdate = useWorkflowStore((state) => state.triggerUpdate)
|
||||
const setSubBlockValue = useSubBlockStore((state) => state.setValue)
|
||||
|
||||
// Chat state (UI and messages from unified store)
|
||||
const {
|
||||
isChatOpen,
|
||||
chatPosition,
|
||||
@@ -230,19 +261,16 @@ export function Chat() {
|
||||
const { data: session } = useSession()
|
||||
const { addToQueue } = useOperationQueue()
|
||||
|
||||
// Local state
|
||||
const [chatMessage, setChatMessage] = useState('')
|
||||
const [promptHistory, setPromptHistory] = useState<string[]>([])
|
||||
const [historyIndex, setHistoryIndex] = useState(-1)
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false)
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const streamReaderRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null)
|
||||
const preventZoomRef = usePreventZoom()
|
||||
|
||||
// File upload hook
|
||||
const {
|
||||
chatFiles,
|
||||
uploadErrors,
|
||||
@@ -257,6 +285,38 @@ export function Chat() {
|
||||
handleDrop,
|
||||
} = useChatFileUpload()
|
||||
|
||||
const filePreviewUrls = useRef<Map<string, string>>(new Map())
|
||||
|
||||
const getFilePreviewUrl = useCallback((file: ChatFile): string | null => {
|
||||
if (!file.type.startsWith('image/')) return null
|
||||
|
||||
const existing = filePreviewUrls.current.get(file.id)
|
||||
if (existing) return existing
|
||||
|
||||
const url = URL.createObjectURL(file.file)
|
||||
filePreviewUrls.current.set(file.id, url)
|
||||
return url
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const currentFileIds = new Set(chatFiles.map((f) => f.id))
|
||||
const urlMap = filePreviewUrls.current
|
||||
|
||||
for (const [fileId, url] of urlMap.entries()) {
|
||||
if (!currentFileIds.has(fileId)) {
|
||||
URL.revokeObjectURL(url)
|
||||
urlMap.delete(fileId)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const url of urlMap.values()) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
urlMap.clear()
|
||||
}
|
||||
}, [chatFiles])
|
||||
|
||||
/**
|
||||
* Resolves the unified start block for chat execution, if available.
|
||||
*/
|
||||
@@ -322,13 +382,11 @@ export function Chat() {
|
||||
const shouldShowConfigureStartInputsButton =
|
||||
Boolean(startBlockId) && missingStartReservedFields.length > 0
|
||||
|
||||
// Get actual position (default if not set)
|
||||
const actualPosition = useMemo(
|
||||
() => getChatPosition(chatPosition, chatWidth, chatHeight),
|
||||
[chatPosition, chatWidth, chatHeight]
|
||||
)
|
||||
|
||||
// Drag hook
|
||||
const { handleMouseDown } = useFloatDrag({
|
||||
position: actualPosition,
|
||||
width: chatWidth,
|
||||
@@ -336,7 +394,6 @@ export function Chat() {
|
||||
onPositionChange: setChatPosition,
|
||||
})
|
||||
|
||||
// Boundary sync hook - keeps chat within bounds when layout changes
|
||||
useFloatBoundarySync({
|
||||
isOpen: isChatOpen,
|
||||
position: actualPosition,
|
||||
@@ -345,7 +402,6 @@ export function Chat() {
|
||||
onPositionChange: setChatPosition,
|
||||
})
|
||||
|
||||
// Resize hook - enables resizing from all edges and corners
|
||||
const {
|
||||
cursor: resizeCursor,
|
||||
handleMouseMove: handleResizeMouseMove,
|
||||
@@ -359,13 +415,11 @@ export function Chat() {
|
||||
onDimensionsChange: setChatDimensions,
|
||||
})
|
||||
|
||||
// Get output entries from console
|
||||
const outputEntries = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
return entries.filter((entry) => entry.workflowId === activeWorkflowId && entry.output)
|
||||
}, [entries, activeWorkflowId])
|
||||
|
||||
// Get filtered messages for current workflow
|
||||
const workflowMessages = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
return messages
|
||||
@@ -373,14 +427,11 @@ export function Chat() {
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
}, [messages, activeWorkflowId])
|
||||
|
||||
// Check if any message is currently streaming
|
||||
const isStreaming = useMemo(() => {
|
||||
// Match copilot semantics: only treat as streaming if the LAST message is streaming
|
||||
const lastMessage = workflowMessages[workflowMessages.length - 1]
|
||||
return Boolean(lastMessage?.isStreaming)
|
||||
}, [workflowMessages])
|
||||
|
||||
// Map chat messages to copilot message format (type -> role) for scroll hook
|
||||
const messagesForScrollHook = useMemo(() => {
|
||||
return workflowMessages.map((msg) => ({
|
||||
...msg,
|
||||
@@ -388,8 +439,6 @@ export function Chat() {
|
||||
}))
|
||||
}, [workflowMessages])
|
||||
|
||||
// Scroll management hook - reuse copilot's implementation
|
||||
// Use immediate scroll behavior to keep the view pinned to the bottom during streaming
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(
|
||||
messagesForScrollHook,
|
||||
isStreaming,
|
||||
@@ -398,7 +447,6 @@ export function Chat() {
|
||||
}
|
||||
)
|
||||
|
||||
// Memoize user messages for performance
|
||||
const userMessages = useMemo(() => {
|
||||
return workflowMessages
|
||||
.filter((msg) => msg.type === 'user')
|
||||
@@ -406,7 +454,6 @@ export function Chat() {
|
||||
.filter((content): content is string => typeof content === 'string')
|
||||
}, [workflowMessages])
|
||||
|
||||
// Update prompt history when workflow changes
|
||||
useEffect(() => {
|
||||
if (!activeWorkflowId) {
|
||||
setPromptHistory([])
|
||||
@@ -419,7 +466,7 @@ export function Chat() {
|
||||
}, [activeWorkflowId, userMessages])
|
||||
|
||||
/**
|
||||
* Auto-scroll to bottom when messages load
|
||||
* Auto-scroll to bottom when messages load and chat is open
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (workflowMessages.length > 0 && isChatOpen) {
|
||||
@@ -427,7 +474,6 @@ export function Chat() {
|
||||
}
|
||||
}, [workflowMessages.length, scrollToBottom, isChatOpen])
|
||||
|
||||
// Get selected workflow outputs (deduplicated)
|
||||
const selectedOutputs = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
const selected = selectedWorkflowOutputs[activeWorkflowId]
|
||||
@@ -448,7 +494,6 @@ export function Chat() {
|
||||
}, delay)
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timeoutRef.current && clearTimeout(timeoutRef.current)
|
||||
@@ -456,7 +501,6 @@ export function Chat() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// React to execution cancellation from run button
|
||||
useEffect(() => {
|
||||
if (!isExecuting && isStreaming) {
|
||||
const lastMessage = workflowMessages[workflowMessages.length - 1]
|
||||
@@ -500,7 +544,6 @@ export function Chat() {
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
|
||||
// Process only complete SSE messages; keep any partial trailing data in buffer
|
||||
const separatorIndex = buffer.lastIndexOf('\n\n')
|
||||
if (separatorIndex === -1) {
|
||||
continue
|
||||
@@ -550,7 +593,6 @@ export function Chat() {
|
||||
}
|
||||
finalizeMessageStream(responseMessageId)
|
||||
} finally {
|
||||
// Only clear ref if it's still our reader (prevents clobbering a new stream)
|
||||
if (streamReaderRef.current === reader) {
|
||||
streamReaderRef.current = null
|
||||
}
|
||||
@@ -979,8 +1021,7 @@ export function Chat() {
|
||||
{chatFiles.length > 0 && (
|
||||
<div className='mt-[4px] flex gap-[6px] overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{chatFiles.map((file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const previewUrl = isImage ? URL.createObjectURL(file.file) : null
|
||||
const previewUrl = getFilePreviewUrl(file)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -997,7 +1038,6 @@ export function Chat() {
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className='h-full w-full object-cover'
|
||||
onLoad={() => URL.revokeObjectURL(previewUrl)}
|
||||
/>
|
||||
) : (
|
||||
<div className='min-w-0 flex-1'>
|
||||
|
||||
@@ -113,16 +113,17 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className='mb-2 flex flex-wrap gap-[6px]'>
|
||||
{message.attachments.map((attachment) => {
|
||||
const isImage = attachment.type.startsWith('image/')
|
||||
const hasValidDataUrl =
|
||||
attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:')
|
||||
// Only treat as displayable image if we have both image type AND valid data URL
|
||||
const canDisplayAsImage = attachment.type.startsWith('image/') && hasValidDataUrl
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className={`group relative flex-shrink-0 overflow-hidden rounded-[6px] bg-[var(--surface-2)] ${
|
||||
hasValidDataUrl ? 'cursor-pointer' : ''
|
||||
} ${isImage ? 'h-[40px] w-[40px]' : 'flex min-w-[80px] max-w-[120px] items-center justify-center px-[8px] py-[2px]'}`}
|
||||
} ${canDisplayAsImage ? 'h-[40px] w-[40px]' : 'flex min-w-[80px] max-w-[120px] items-center justify-center px-[8px] py-[2px]'}`}
|
||||
onClick={(e) => {
|
||||
if (hasValidDataUrl) {
|
||||
e.preventDefault()
|
||||
@@ -131,7 +132,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isImage && hasValidDataUrl ? (
|
||||
{canDisplayAsImage ? (
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.name}
|
||||
|
||||
@@ -331,13 +331,16 @@ export function OutputSelect({
|
||||
return (
|
||||
<Combobox
|
||||
size='sm'
|
||||
className='!w-fit !py-[2px] [&>svg]:!ml-[4px] [&>svg]:!h-3 [&>svg]:!w-3 [&>span]:!text-[var(--text-secondary)] min-w-[100px] rounded-[6px] bg-transparent px-[9px] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-6)] dark:hover:bg-transparent [&>span]:text-center'
|
||||
className='!w-fit !py-[2px] min-w-[100px] rounded-[6px] px-[9px]'
|
||||
groups={comboboxGroups}
|
||||
options={[]}
|
||||
multiSelect
|
||||
multiSelectValues={normalizedSelectedValues}
|
||||
onMultiSelectChange={onOutputSelect}
|
||||
placeholder={selectedDisplayText}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{selectedDisplayText}</span>
|
||||
}
|
||||
disabled={disabled || workflowOutputs.length === 0}
|
||||
align={align}
|
||||
maxHeight={maxHeight}
|
||||
|
||||
@@ -24,10 +24,11 @@ export function useChatFileUpload() {
|
||||
|
||||
/**
|
||||
* Validate and add files
|
||||
* Uses functional state update to avoid stale closure issues with rapid file additions
|
||||
*/
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
const remainingSlots = Math.max(0, MAX_FILES - chatFiles.length)
|
||||
const addFiles = useCallback((files: File[]) => {
|
||||
setChatFiles((currentFiles) => {
|
||||
const remainingSlots = Math.max(0, MAX_FILES - currentFiles.length)
|
||||
const candidateFiles = files.slice(0, remainingSlots)
|
||||
const errors: string[] = []
|
||||
const validNewFiles: ChatFile[] = []
|
||||
@@ -39,11 +40,14 @@ export function useChatFileUpload() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const isDuplicate = chatFiles.some(
|
||||
// Check for duplicates against current files and newly added valid files
|
||||
const isDuplicateInCurrent = currentFiles.some(
|
||||
(existingFile) => existingFile.name === file.name && existingFile.size === file.size
|
||||
)
|
||||
if (isDuplicate) {
|
||||
const isDuplicateInNew = validNewFiles.some(
|
||||
(newFile) => newFile.name === file.name && newFile.size === file.size
|
||||
)
|
||||
if (isDuplicateInCurrent || isDuplicateInNew) {
|
||||
errors.push(`${file.name} already added`)
|
||||
continue
|
||||
}
|
||||
@@ -57,20 +61,20 @@ export function useChatFileUpload() {
|
||||
})
|
||||
}
|
||||
|
||||
// Update errors outside the state setter to avoid nested state updates
|
||||
if (errors.length > 0) {
|
||||
setUploadErrors(errors)
|
||||
// Use setTimeout to avoid state update during render
|
||||
setTimeout(() => setUploadErrors(errors), 0)
|
||||
} else if (validNewFiles.length > 0) {
|
||||
setTimeout(() => setUploadErrors([]), 0)
|
||||
}
|
||||
|
||||
if (validNewFiles.length > 0) {
|
||||
setChatFiles([...chatFiles, ...validNewFiles])
|
||||
// Clear errors when files are successfully added
|
||||
if (errors.length === 0) {
|
||||
setUploadErrors([])
|
||||
}
|
||||
return [...currentFiles, ...validNewFiles]
|
||||
}
|
||||
},
|
||||
[chatFiles]
|
||||
)
|
||||
return currentFiles
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Remove a file
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useViewport } from 'reactflow'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
@@ -20,30 +19,31 @@ interface CursorRenderData {
|
||||
}
|
||||
|
||||
const CursorsComponent = () => {
|
||||
const { presenceUsers } = useSocket()
|
||||
const { presenceUsers, currentSocketId } = useSocket()
|
||||
const viewport = useViewport()
|
||||
const session = useSession()
|
||||
const currentUserId = session.data?.user?.id
|
||||
const preventZoomRef = usePreventZoom()
|
||||
|
||||
const cursors = useMemo<CursorRenderData[]>(() => {
|
||||
return presenceUsers
|
||||
.filter((user): user is typeof user & { cursor: CursorPoint } => Boolean(user.cursor))
|
||||
.filter((user) => user.userId !== currentUserId)
|
||||
.filter((user) => user.socketId !== currentSocketId)
|
||||
.map((user) => ({
|
||||
id: user.socketId,
|
||||
name: user.userName?.trim() || 'Collaborator',
|
||||
cursor: user.cursor,
|
||||
color: getUserColor(user.userId),
|
||||
}))
|
||||
}, [currentUserId, presenceUsers])
|
||||
}, [currentSocketId, presenceUsers])
|
||||
|
||||
if (!cursors.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={preventZoomRef} className='pointer-events-none absolute inset-0 z-30 select-none'>
|
||||
<div
|
||||
ref={preventZoomRef}
|
||||
className='pointer-events-none absolute inset-0 z-[5] select-none overflow-hidden'
|
||||
>
|
||||
{cursors.map(({ id, name, cursor, color }) => {
|
||||
const x = cursor.x * viewport.zoom + viewport.x
|
||||
const y = cursor.y * viewport.zoom + viewport.y
|
||||
|
||||
@@ -4,13 +4,13 @@ import type { NodeProps } from 'reactflow'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
BLOCK_DIMENSIONS,
|
||||
useBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { ActionBar } from '../workflow-block/components'
|
||||
import type { WorkflowBlockProps } from '../workflow-block/types'
|
||||
|
||||
interface NoteBlockNodeData extends WorkflowBlockProps {}
|
||||
|
||||
@@ -3,16 +3,12 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Clipboard } from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Code,
|
||||
Combobox,
|
||||
Label,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
@@ -602,48 +598,19 @@ console.log(limits);`
|
||||
<span>{copied.async ? 'Copied' : 'Copy'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className='min-w-0 max-w-full'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
|
||||
>
|
||||
<span className='whitespace-nowrap text-[12px]'>
|
||||
{getAsyncExampleTitle()}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
maxHeight={300}
|
||||
maxWidth={300}
|
||||
minWidth={160}
|
||||
border
|
||||
>
|
||||
<PopoverItem
|
||||
active={asyncExampleType === 'execute'}
|
||||
onClick={() => setAsyncExampleType('execute')}
|
||||
>
|
||||
Execute Job
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={asyncExampleType === 'status'}
|
||||
onClick={() => setAsyncExampleType('status')}
|
||||
>
|
||||
Check Status
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={asyncExampleType === 'rate-limits'}
|
||||
onClick={() => setAsyncExampleType('rate-limits')}
|
||||
>
|
||||
Rate Limits
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
size='sm'
|
||||
className='!w-fit !py-[2px] min-w-[100px] rounded-[6px] px-[9px]'
|
||||
options={[
|
||||
{ label: 'Execute Job', value: 'execute' },
|
||||
{ label: 'Check Status', value: 'status' },
|
||||
{ label: 'Rate Limits', value: 'rate-limits' },
|
||||
]}
|
||||
value={asyncExampleType}
|
||||
onChange={(value) => setAsyncExampleType(value as AsyncExampleType)}
|
||||
align='end'
|
||||
dropdownWidth={160}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Code.Viewer
|
||||
|
||||
@@ -334,7 +334,6 @@ export function GeneralDeploy({
|
||||
}}
|
||||
onPaneClick={() => setExpandedSelectedBlockId(null)}
|
||||
selectedBlockId={expandedSelectedBlockId}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
||||
|
||||
@@ -336,6 +336,10 @@ export function Code({
|
||||
setCode('')
|
||||
}
|
||||
|
||||
handleStreamChunkRef.current = (chunk: string) => {
|
||||
setCode((prev) => prev + chunk)
|
||||
}
|
||||
|
||||
handleGeneratedContentRef.current = (generatedCode: string) => {
|
||||
setCode(generatedCode)
|
||||
if (!isPreview && !disabled) {
|
||||
@@ -691,11 +695,7 @@ export function Code({
|
||||
/>
|
||||
)}
|
||||
|
||||
<CodeEditor.Container
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
isStreaming={isAiStreaming}
|
||||
>
|
||||
<CodeEditor.Container onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
|
||||
<div className='absolute top-2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
{wandConfig?.enabled &&
|
||||
!isAiStreaming &&
|
||||
@@ -761,6 +761,11 @@ export function Code({
|
||||
}}
|
||||
onFocus={() => {
|
||||
hasEditedSinceFocusRef.current = false
|
||||
// Show tag dropdown on focus when code is empty
|
||||
if (!isPreview && !disabled && !readOnly && code.trim() === '') {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
|
||||
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
|
||||
|
||||
@@ -115,6 +115,7 @@ export function ConditionInput({
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map())
|
||||
|
||||
/**
|
||||
* Determines if a reference string should be highlighted in the editor.
|
||||
@@ -728,6 +729,20 @@ export function ConditionInput({
|
||||
})
|
||||
}, [conditionalBlocks.length])
|
||||
|
||||
// Capture textarea refs from Editor components (condition mode)
|
||||
useEffect(() => {
|
||||
if (!isRouterMode && containerRef.current) {
|
||||
conditionalBlocks.forEach((block) => {
|
||||
const textarea = containerRef.current?.querySelector(
|
||||
`[data-block-id="${block.id}"] textarea`
|
||||
) as HTMLTextAreaElement | null
|
||||
if (textarea) {
|
||||
inputRefs.current.set(block.id, textarea)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [conditionalBlocks, isRouterMode])
|
||||
|
||||
// Show loading or empty state if not ready or no blocks
|
||||
if (!isReady || conditionalBlocks.length === 0) {
|
||||
return (
|
||||
@@ -842,6 +857,9 @@ export function ConditionInput({
|
||||
onDrop={(e) => handleDrop(block.id, e)}
|
||||
>
|
||||
<Textarea
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current.set(block.id, el)
|
||||
}}
|
||||
data-router-block-id={block.id}
|
||||
value={block.value}
|
||||
onChange={(e) => {
|
||||
@@ -869,6 +887,15 @@ export function ConditionInput({
|
||||
)
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!isPreview && !disabled && block.value.trim() === '') {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id ? { ...b, showTags: true, cursorPosition: 0 } : b
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
setConditionalBlocks((blocks) =>
|
||||
@@ -929,6 +956,11 @@ export function ConditionInput({
|
||||
)
|
||||
)
|
||||
}}
|
||||
inputRef={
|
||||
{
|
||||
current: inputRefs.current.get(block.id) || null,
|
||||
} as React.RefObject<HTMLTextAreaElement>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1006,6 +1038,15 @@ export function ConditionInput({
|
||||
)
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!isPreview && !disabled && block.value.trim() === '') {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id ? { ...b, showTags: true, cursorPosition: 0 } : b
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
highlight={(codeToHighlight) => {
|
||||
const placeholders: {
|
||||
placeholder: string
|
||||
@@ -1113,6 +1154,11 @@ export function ConditionInput({
|
||||
)
|
||||
)
|
||||
}}
|
||||
inputRef={
|
||||
{
|
||||
current: inputRefs.current.get(block.id) || null,
|
||||
} as React.RefObject<HTMLTextAreaElement>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -288,6 +288,7 @@ export function DocumentTagEntry({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onScroll={(e) => syncOverlayScroll(cellKey, e.currentTarget.scrollLeft)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -214,6 +214,7 @@ export function EvalInput({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
placeholder='How accurate is the response?'
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
|
||||
@@ -237,6 +237,7 @@ function InputMappingField({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onScroll={(e) => handleScroll(e)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -276,6 +276,7 @@ export function KnowledgeTagFilters({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onScroll={(e) => syncOverlayScroll(cellKey, e.currentTarget.scrollLeft)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type React from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -159,6 +160,27 @@ export function LongInput({
|
||||
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
/**
|
||||
* Callback to show tag dropdown when input is empty and focused
|
||||
*/
|
||||
const shouldForceTagDropdown = useCallback(
|
||||
({
|
||||
value,
|
||||
}: {
|
||||
value: string
|
||||
cursor: number
|
||||
event: 'focus'
|
||||
}): { show: boolean } | undefined => {
|
||||
if (isPreview || disabled) return { show: false }
|
||||
// Show tag dropdown on focus when input is empty
|
||||
if (value.trim() === '') {
|
||||
return { show: true }
|
||||
}
|
||||
return { show: false }
|
||||
},
|
||||
[isPreview, disabled]
|
||||
)
|
||||
|
||||
// During streaming, use local content; otherwise use the controller value
|
||||
const value = useMemo(() => {
|
||||
if (wandHook.isStreaming) return localContent
|
||||
@@ -294,6 +316,7 @@ export function LongInput({
|
||||
disabled={disabled}
|
||||
isStreaming={wandHook.isStreaming}
|
||||
previewValue={previewValue}
|
||||
shouldForceTagDropdown={shouldForceTagDropdown}
|
||||
>
|
||||
{({ ref, onChange: handleChange, onKeyDown, onDrop, onDragOver, onFocus }) => {
|
||||
const setRefs = (el: HTMLTextAreaElement | null) => {
|
||||
@@ -303,7 +326,7 @@ export function LongInput({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('group relative w-full', wandHook.isStreaming && 'streaming-effect')}
|
||||
className='group relative w-full'
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
<Textarea
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
@@ -111,7 +112,14 @@ function McpInputWithTags({
|
||||
data-lpignore='true'
|
||||
data-1p-ignore
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.removeAttribute('readOnly')}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.removeAttribute('readOnly')
|
||||
// Show tag dropdown on focus when input is empty
|
||||
if (!disabled && (value?.trim() === '' || !value)) {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
className={cn(!isPassword && 'text-transparent caret-foreground')}
|
||||
/>
|
||||
{!isPassword && (
|
||||
@@ -136,6 +144,7 @@ function McpInputWithTags({
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}}
|
||||
inputRef={inputRef as RefObject<HTMLInputElement>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -225,6 +234,13 @@ function McpTextareaWithTags({
|
||||
onChange={handleChange}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onFocus={() => {
|
||||
// Show tag dropdown on focus when input is empty
|
||||
if (!disabled && (value?.trim() === '' || !value)) {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
@@ -254,6 +270,7 @@ function McpTextareaWithTags({
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}}
|
||||
inputRef={textareaRef as RefObject<HTMLTextAreaElement>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
@@ -8,12 +8,30 @@ import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/
|
||||
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
const MIN_TEXTAREA_HEIGHT_PX = 80
|
||||
const MAX_TEXTAREA_HEIGHT_PX = 320
|
||||
|
||||
/** Pattern to match complete message objects in JSON */
|
||||
const COMPLETE_MESSAGE_PATTERN =
|
||||
/"role"\s*:\s*"(system|user|assistant)"[^}]*"content"\s*:\s*"((?:[^"\\]|\\.)*)"/g
|
||||
|
||||
/** Pattern to match incomplete content at end of buffer */
|
||||
const INCOMPLETE_CONTENT_PATTERN = /"content"\s*:\s*"((?:[^"\\]|\\.)*)$/
|
||||
|
||||
/** Pattern to match role before content */
|
||||
const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^{]*$/
|
||||
|
||||
/**
|
||||
* Unescapes JSON string content
|
||||
*/
|
||||
const unescapeContent = (str: string): string =>
|
||||
str.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
||||
|
||||
/**
|
||||
* Interface for individual message in the messages array
|
||||
*/
|
||||
@@ -38,6 +56,8 @@ interface MessagesInputProps {
|
||||
previewValue?: Message[] | null
|
||||
/** Whether the input is disabled */
|
||||
disabled?: boolean
|
||||
/** Ref to expose wand control handlers to parent */
|
||||
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +75,7 @@ export function MessagesInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
wandControlRef,
|
||||
}: MessagesInputProps) {
|
||||
const [messages, setMessages] = useSubBlockValue<Message[]>(blockId, subBlockId, false)
|
||||
const [localMessages, setLocalMessages] = useState<Message[]>([{ role: 'user', content: '' }])
|
||||
@@ -68,6 +89,142 @@ export function MessagesInput({
|
||||
disabled,
|
||||
})
|
||||
|
||||
/**
|
||||
* Gets the current messages as JSON string for wand context
|
||||
*/
|
||||
const getMessagesJson = useCallback((): string => {
|
||||
if (localMessages.length === 0) return ''
|
||||
// Filter out empty messages for cleaner context
|
||||
const nonEmptyMessages = localMessages.filter((m) => m.content.trim() !== '')
|
||||
if (nonEmptyMessages.length === 0) return ''
|
||||
return JSON.stringify(nonEmptyMessages, null, 2)
|
||||
}, [localMessages])
|
||||
|
||||
/**
|
||||
* Streaming buffer for accumulating JSON content
|
||||
*/
|
||||
const streamBufferRef = useRef<string>('')
|
||||
|
||||
/**
|
||||
* Parses and validates messages from JSON content
|
||||
*/
|
||||
const parseMessages = useCallback((content: string): Message[] | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (Array.isArray(parsed)) {
|
||||
const validMessages: Message[] = parsed
|
||||
.filter(
|
||||
(m): m is { role: string; content: string } =>
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
typeof m.role === 'string' &&
|
||||
typeof m.content === 'string'
|
||||
)
|
||||
.map((m) => ({
|
||||
role: (['system', 'user', 'assistant'].includes(m.role)
|
||||
? m.role
|
||||
: 'user') as Message['role'],
|
||||
content: m.content,
|
||||
}))
|
||||
return validMessages.length > 0 ? validMessages : null
|
||||
}
|
||||
} catch {
|
||||
// Parsing failed
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Extracts messages from streaming JSON buffer
|
||||
* Uses simple pattern matching for efficiency
|
||||
*/
|
||||
const extractStreamingMessages = useCallback(
|
||||
(buffer: string): Message[] => {
|
||||
// Try complete JSON parse first
|
||||
const complete = parseMessages(buffer)
|
||||
if (complete) return complete
|
||||
|
||||
const result: Message[] = []
|
||||
|
||||
// Reset regex lastIndex for global pattern
|
||||
COMPLETE_MESSAGE_PATTERN.lastIndex = 0
|
||||
let match
|
||||
while ((match = COMPLETE_MESSAGE_PATTERN.exec(buffer)) !== null) {
|
||||
result.push({ role: match[1] as Message['role'], content: unescapeContent(match[2]) })
|
||||
}
|
||||
|
||||
// Check for incomplete message at end (content still streaming)
|
||||
const lastContentIdx = buffer.lastIndexOf('"content"')
|
||||
if (lastContentIdx !== -1) {
|
||||
const tail = buffer.slice(lastContentIdx)
|
||||
const incomplete = tail.match(INCOMPLETE_CONTENT_PATTERN)
|
||||
if (incomplete) {
|
||||
const head = buffer.slice(0, lastContentIdx)
|
||||
const roleMatch = head.match(ROLE_BEFORE_CONTENT_PATTERN)
|
||||
if (roleMatch) {
|
||||
const content = unescapeContent(incomplete[1])
|
||||
// Only add if not duplicate of last complete message
|
||||
if (result.length === 0 || result[result.length - 1].content !== content) {
|
||||
result.push({ role: roleMatch[1] as Message['role'], content })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
[parseMessages]
|
||||
)
|
||||
|
||||
/**
|
||||
* Wand hook for AI-assisted content generation
|
||||
*/
|
||||
const wandHook = useWand({
|
||||
wandConfig: config.wandConfig,
|
||||
currentValue: getMessagesJson(),
|
||||
onStreamStart: () => {
|
||||
streamBufferRef.current = ''
|
||||
setLocalMessages([{ role: 'system', content: '' }])
|
||||
},
|
||||
onStreamChunk: (chunk) => {
|
||||
streamBufferRef.current += chunk
|
||||
const extracted = extractStreamingMessages(streamBufferRef.current)
|
||||
if (extracted.length > 0) {
|
||||
setLocalMessages(extracted)
|
||||
}
|
||||
},
|
||||
onGeneratedContent: (content) => {
|
||||
const validMessages = parseMessages(content)
|
||||
if (validMessages) {
|
||||
setLocalMessages(validMessages)
|
||||
setMessages(validMessages)
|
||||
} else {
|
||||
// Fallback: treat as raw system prompt
|
||||
const trimmed = content.trim()
|
||||
if (trimmed) {
|
||||
const fallback: Message[] = [{ role: 'system', content: trimmed }]
|
||||
setLocalMessages(fallback)
|
||||
setMessages(fallback)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Expose wand control handlers to parent via ref
|
||||
*/
|
||||
useImperativeHandle(
|
||||
wandControlRef,
|
||||
() => ({
|
||||
onWandTrigger: (prompt: string) => {
|
||||
wandHook.generateStream({ prompt })
|
||||
},
|
||||
isWandActive: wandHook.isPromptVisible,
|
||||
isWandStreaming: wandHook.isStreaming,
|
||||
}),
|
||||
[wandHook]
|
||||
)
|
||||
|
||||
/**
|
||||
* Initialize local state from stored or preview value
|
||||
*/
|
||||
@@ -308,7 +465,7 @@ export function MessagesInput({
|
||||
}, [currentMessages, autoResizeTextarea])
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
<div className='flex w-full flex-col gap-[10px]'>
|
||||
{currentMessages.map((message, index) => (
|
||||
<div
|
||||
key={`message-${index}`}
|
||||
@@ -364,7 +521,7 @@ export function MessagesInput({
|
||||
type='button'
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'-ml-1.5 -my-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]',
|
||||
'group -ml-1.5 -my-1 flex items-center gap-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]',
|
||||
(isPreview || disabled) &&
|
||||
'cursor-default hover:bg-transparent hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
@@ -372,6 +529,14 @@ export function MessagesInput({
|
||||
aria-label='Select message role'
|
||||
>
|
||||
{formatRole(message.role)}
|
||||
{!isPreview && !disabled && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3 w-3 flex-shrink-0 transition-transform duration-100',
|
||||
openPopoverIndex === index && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent minWidth={140} align='start'>
|
||||
@@ -486,6 +651,7 @@ export function MessagesInput({
|
||||
}}
|
||||
onDrop={fieldHandlers.onDrop}
|
||||
onDragOver={fieldHandlers.onDragOver}
|
||||
onFocus={fieldHandlers.onFocus}
|
||||
onScroll={(e) => {
|
||||
const overlay = overlayRefs.current[fieldId]
|
||||
if (overlay) {
|
||||
|
||||
@@ -192,6 +192,24 @@ export function ShortInput({
|
||||
[isApiKeyField, isPreview, disabled, readOnly]
|
||||
)
|
||||
|
||||
const shouldForceTagDropdown = useCallback(
|
||||
({
|
||||
value,
|
||||
}: {
|
||||
value: string
|
||||
cursor: number
|
||||
event: 'focus'
|
||||
}): { show: boolean } | undefined => {
|
||||
if (isPreview || disabled || readOnly) return { show: false }
|
||||
// Show tag dropdown on focus when input is empty (unless it's an API key field)
|
||||
if (!isApiKeyField && value.trim() === '') {
|
||||
return { show: true }
|
||||
}
|
||||
return { show: false }
|
||||
},
|
||||
[isPreview, disabled, readOnly, isApiKeyField]
|
||||
)
|
||||
|
||||
const baseValue = isPreview ? previewValue : propValue !== undefined ? propValue : undefined
|
||||
|
||||
const effectiveValue =
|
||||
@@ -316,6 +334,7 @@ export function ShortInput({
|
||||
isStreaming={wandHook.isStreaming}
|
||||
previewValue={previewValue}
|
||||
shouldForceEnvDropdown={shouldForceEnvDropdown}
|
||||
shouldForceTagDropdown={shouldForceTagDropdown}
|
||||
>
|
||||
{({
|
||||
ref,
|
||||
@@ -356,9 +375,9 @@ export function ShortInput({
|
||||
type='text'
|
||||
value={displayValue}
|
||||
onChange={handleChange as (e: React.ChangeEvent<HTMLInputElement>) => void}
|
||||
onFocus={() => {
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true)
|
||||
onFocus()
|
||||
onFocus(e)
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
onDrop={onDrop as (e: React.DragEvent<HTMLInputElement>) => void}
|
||||
|
||||
@@ -211,6 +211,7 @@ export function FieldFormat({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onScroll={(e) => syncNameOverlayScroll(field.id, e.currentTarget.scrollLeft)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
@@ -464,6 +465,7 @@ export function FieldFormat({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onScroll={(e) => syncOverlayScroll(field.id, e.currentTarget.scrollLeft)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -45,6 +45,16 @@ export interface SubBlockInputControllerProps {
|
||||
cursor: number
|
||||
event: 'change' | 'focus' | 'deleteAll'
|
||||
}) => { show: boolean; searchTerm?: string } | undefined
|
||||
/**
|
||||
* Optional callback to force/show the tag dropdown (e.g., empty inputs).
|
||||
* Return { show: true } to override defaults.
|
||||
* Called on 'focus' event.
|
||||
*/
|
||||
shouldForceTagDropdown?: (args: {
|
||||
value: string
|
||||
cursor: number
|
||||
event: 'focus'
|
||||
}) => { show: boolean } | undefined
|
||||
/** Render prop for the actual input element. */
|
||||
children: (args: {
|
||||
ref: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
|
||||
@@ -54,7 +64,7 @@ export interface SubBlockInputControllerProps {
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDrop: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onFocus: () => void
|
||||
onFocus: (e: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onScroll?: (e: React.UIEvent<HTMLTextAreaElement>) => void
|
||||
}) => React.ReactNode
|
||||
}
|
||||
@@ -76,6 +86,7 @@ export function SubBlockInputController(props: SubBlockInputControllerProps): Re
|
||||
previewValue,
|
||||
workspaceId,
|
||||
shouldForceEnvDropdown,
|
||||
shouldForceTagDropdown,
|
||||
children,
|
||||
} = props
|
||||
|
||||
@@ -92,6 +103,7 @@ export function SubBlockInputController(props: SubBlockInputControllerProps): Re
|
||||
previewValue,
|
||||
workspaceId,
|
||||
shouldForceEnvDropdown,
|
||||
shouldForceTagDropdown,
|
||||
})
|
||||
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
|
||||
@@ -238,6 +238,7 @@ export function Table({
|
||||
onScroll={handleScroll}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
disabled={isPreview || disabled}
|
||||
autoComplete='off'
|
||||
className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
|
||||
@@ -1210,7 +1210,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const textAfterCursor = liveValue.slice(liveCursor)
|
||||
|
||||
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
|
||||
if (lastOpenBracket === -1) return
|
||||
|
||||
let processedTag = tag
|
||||
|
||||
@@ -1256,17 +1255,25 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const nextCloseBracket = textAfterCursor.indexOf('>')
|
||||
let remainingTextAfterCursor = textAfterCursor
|
||||
let newValue: string
|
||||
|
||||
if (nextCloseBracket !== -1) {
|
||||
const textBetween = textAfterCursor.slice(0, nextCloseBracket)
|
||||
if (/^[a-zA-Z0-9._]*$/.test(textBetween)) {
|
||||
remainingTextAfterCursor = textAfterCursor.slice(nextCloseBracket + 1)
|
||||
if (lastOpenBracket === -1) {
|
||||
// No '<' found - insert the full tag at cursor position
|
||||
newValue = `${textBeforeCursor}<${processedTag}>${textAfterCursor}`
|
||||
} else {
|
||||
// '<' found - replace from '<' to cursor (and consume trailing '>' if present)
|
||||
const nextCloseBracket = textAfterCursor.indexOf('>')
|
||||
let remainingTextAfterCursor = textAfterCursor
|
||||
|
||||
if (nextCloseBracket !== -1) {
|
||||
const textBetween = textAfterCursor.slice(0, nextCloseBracket)
|
||||
if (/^[a-zA-Z0-9._]*$/.test(textBetween)) {
|
||||
remainingTextAfterCursor = textAfterCursor.slice(nextCloseBracket + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newValue = `${textBeforeCursor.slice(0, lastOpenBracket)}<${processedTag}>${remainingTextAfterCursor}`
|
||||
newValue = `${textBeforeCursor.slice(0, lastOpenBracket)}<${processedTag}>${remainingTextAfterCursor}`
|
||||
}
|
||||
|
||||
onSelect(newValue)
|
||||
onClose?.()
|
||||
|
||||
@@ -381,6 +381,13 @@ export function VariablesInput({
|
||||
e.target.selectionStart ?? undefined
|
||||
)
|
||||
}
|
||||
onFocus={() => {
|
||||
if (!isPreview && !disabled && !assignment.value?.trim()) {
|
||||
setActiveFieldId(assignment.id)
|
||||
setCursorPosition(0)
|
||||
setShowTags(true)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
assignment.type === 'object'
|
||||
? '{\n "key": "value"\n}'
|
||||
@@ -434,6 +441,13 @@ export function VariablesInput({
|
||||
e.target.selectionStart ?? undefined
|
||||
)
|
||||
}
|
||||
onFocus={() => {
|
||||
if (!isPreview && !disabled && !assignment.value?.trim()) {
|
||||
setActiveFieldId(assignment.id)
|
||||
setCursorPosition(0)
|
||||
setShowTags(true)
|
||||
}
|
||||
}}
|
||||
placeholder={`${assignment.type} value`}
|
||||
disabled={isPreview || disabled}
|
||||
autoComplete='off'
|
||||
@@ -475,6 +489,11 @@ export function VariablesInput({
|
||||
inputValue={assignment.value || ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTags(false)}
|
||||
inputRef={
|
||||
{
|
||||
current: valueInputRefs.current[assignment.id] || null,
|
||||
} as React.RefObject<HTMLTextAreaElement | HTMLInputElement>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,16 @@ export interface UseSubBlockInputOptions {
|
||||
cursor: number
|
||||
event: 'change' | 'focus' | 'deleteAll'
|
||||
}) => { show: boolean; searchTerm?: string } | undefined
|
||||
/**
|
||||
* Optional callback to force/show the tag dropdown (e.g., empty inputs).
|
||||
* Return { show: true } to override defaults.
|
||||
* Called on 'focus' event.
|
||||
*/
|
||||
shouldForceTagDropdown?: (args: {
|
||||
value: string
|
||||
cursor: number
|
||||
event: 'focus'
|
||||
}) => { show: boolean } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +99,7 @@ export interface UseSubBlockInputResult {
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDrop: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onFocus: () => void
|
||||
onFocus: (e: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onScroll?: (e: React.UIEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
/** Workspace id for env var dropdown scoping. */
|
||||
@@ -114,6 +124,7 @@ export interface UseSubBlockInputResult {
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDrop: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onFocus: (e: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
}
|
||||
/** Hide dropdowns for a specific field */
|
||||
hideFieldDropdowns: (fieldId: string) => void
|
||||
@@ -153,6 +164,7 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI
|
||||
previewValue,
|
||||
workspaceId: workspaceIdProp,
|
||||
shouldForceEnvDropdown,
|
||||
shouldForceTagDropdown,
|
||||
} = options
|
||||
|
||||
const params = useParams()
|
||||
@@ -338,22 +350,42 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI
|
||||
[config?.connectionDroppable, valueString, onChange, isPreview, setStoreValue]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (shouldForceEnvDropdown) {
|
||||
// Use a slight delay to ensure the input ref is populated
|
||||
setTimeout(() => {
|
||||
const forced = shouldForceEnvDropdown({
|
||||
value: (inputRef.current as any)?.value ?? valueString,
|
||||
cursor: (inputRef.current as any)?.selectionStart ?? valueString.length,
|
||||
const handleFocus = useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
// Get values directly from the event target - no setTimeout needed
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const currentValue = target.value ?? valueString
|
||||
const currentCursor = target.selectionStart ?? currentValue.length
|
||||
|
||||
// Check if tag dropdown should be forced (takes priority as it's more commonly used)
|
||||
if (shouldForceTagDropdown) {
|
||||
const forcedTag = shouldForceTagDropdown({
|
||||
value: currentValue,
|
||||
cursor: currentCursor,
|
||||
event: 'focus',
|
||||
})
|
||||
if (forced?.show) {
|
||||
setShowEnvVars(true)
|
||||
setSearchTerm(forced.searchTerm ?? '')
|
||||
if (forcedTag?.show) {
|
||||
setCursorPosition(currentCursor)
|
||||
setShowTags(true)
|
||||
return // Exit early if tag dropdown is shown
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}, [shouldForceEnvDropdown, valueString])
|
||||
}
|
||||
|
||||
// Check if env var dropdown should be forced
|
||||
if (shouldForceEnvDropdown) {
|
||||
const forcedEnv = shouldForceEnvDropdown({
|
||||
value: currentValue,
|
||||
cursor: currentCursor,
|
||||
event: 'focus',
|
||||
})
|
||||
if (forcedEnv?.show) {
|
||||
setShowEnvVars(true)
|
||||
setSearchTerm(forcedEnv.searchTerm ?? '')
|
||||
}
|
||||
}
|
||||
},
|
||||
[shouldForceEnvDropdown, shouldForceTagDropdown, valueString]
|
||||
)
|
||||
|
||||
const onScroll = useCallback((_: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
// Intentionally empty; consumers may mirror scroll to overlays if needed
|
||||
@@ -469,6 +501,17 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI
|
||||
if (config?.connectionDroppable === false) return
|
||||
e.preventDefault()
|
||||
},
|
||||
onFocus: (e: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
// Show tag dropdown on focus when field value is empty
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const currentValue = target.value ?? fieldValue
|
||||
if (!isDisabled && !isStreaming && currentValue.trim() === '') {
|
||||
updateFieldState(fieldId, {
|
||||
showTags: true,
|
||||
cursorPosition: target.selectionStart ?? 0,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
[isDisabled, isStreaming, config?.connectionDroppable, updateFieldState]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
|
||||
import { AlertTriangle, Wand2 } from 'lucide-react'
|
||||
import { Label, Tooltip } from '@/components/emcn/components'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, ArrowUp } from 'lucide-react'
|
||||
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import {
|
||||
@@ -154,22 +153,23 @@ const getPreviewValue = (
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the label with optional validation, description tooltips, and inline wand control.
|
||||
* Renders the label with optional validation and description tooltips.
|
||||
*
|
||||
* @remarks
|
||||
* Handles JSON validation indicators for code blocks, required field markers,
|
||||
* and AI generation (wand) input interface.
|
||||
* Handles JSON validation indicators for code blocks and required field markers.
|
||||
* Includes inline AI generate button when wand is enabled.
|
||||
*
|
||||
* @param config - The sub-block configuration defining the label content
|
||||
* @param isValidJson - Whether the JSON content is valid (for code blocks)
|
||||
* @param wandState - State and handlers for the AI wand feature
|
||||
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
|
||||
* @param wandState - Optional state and handlers for the AI wand feature
|
||||
* @returns The label JSX element, or `null` for switch types or when no title is defined
|
||||
*/
|
||||
const renderLabel = (
|
||||
config: SubBlockConfig,
|
||||
isValidJson: boolean,
|
||||
wandState: {
|
||||
subBlockValues?: Record<string, any>,
|
||||
wandState?: {
|
||||
isSearchActive: boolean
|
||||
searchQuery: string
|
||||
isWandEnabled: boolean
|
||||
@@ -182,31 +182,19 @@ const renderLabel = (
|
||||
onSearchSubmit: () => void
|
||||
onSearchCancel: () => void
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
},
|
||||
subBlockValues?: Record<string, any>
|
||||
}
|
||||
): JSX.Element | null => {
|
||||
if (config.type === 'switch') return null
|
||||
if (!config.title) return null
|
||||
|
||||
const {
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
isWandEnabled,
|
||||
isPreview,
|
||||
isStreaming,
|
||||
disabled,
|
||||
onSearchClick,
|
||||
onSearchBlur,
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
onSearchCancel,
|
||||
searchInputRef,
|
||||
} = wandState
|
||||
|
||||
const required = isFieldRequired(config, subBlockValues)
|
||||
const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled
|
||||
|
||||
return (
|
||||
<Label className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||
<Label
|
||||
className='flex items-center justify-between gap-[6px] pl-[2px]'
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className='flex items-center gap-[6px] whitespace-nowrap'>
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
@@ -226,42 +214,55 @@ const renderLabel = (
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wand inline prompt */}
|
||||
{isWandEnabled && !isPreview && !disabled && (
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end pr-[4px]'>
|
||||
{!isSearchActive ? (
|
||||
{showWand && (
|
||||
<>
|
||||
{!wandState.isSearchActive ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[12px] w-[12px] flex-shrink-0 p-0 hover:bg-transparent'
|
||||
aria-label='Generate with AI'
|
||||
onClick={onSearchClick}
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={wandState.onSearchClick}
|
||||
>
|
||||
<Wand2 className='!h-[12px] !w-[12px] bg-transparent text-[var(--text-secondary)]' />
|
||||
Generate
|
||||
</Button>
|
||||
) : (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type='text'
|
||||
value={isStreaming ? 'Generating...' : searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onBlur={onSearchBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) {
|
||||
onSearchSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
onSearchCancel()
|
||||
}
|
||||
}}
|
||||
disabled={isStreaming}
|
||||
className={cn(
|
||||
'h-[12px] w-full min-w-[100px] border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none',
|
||||
isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Describe...'
|
||||
/>
|
||||
<div className='-my-1 flex items-center gap-[4px]'>
|
||||
<Input
|
||||
ref={wandState.searchInputRef}
|
||||
value={wandState.isStreaming ? 'Generating...' : wandState.searchQuery}
|
||||
onChange={(e) => wandState.onSearchChange(e.target.value)}
|
||||
onBlur={wandState.onSearchBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && wandState.searchQuery.trim() && !wandState.isStreaming) {
|
||||
wandState.onSearchSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
wandState.onSearchCancel()
|
||||
}
|
||||
}}
|
||||
disabled={wandState.isStreaming}
|
||||
className={cn(
|
||||
'h-5 max-w-[200px] flex-1 text-[11px]',
|
||||
wandState.isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Generate...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
wandState.onSearchSubmit()
|
||||
}}
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
)
|
||||
@@ -875,6 +876,7 @@ function SubBlockComponent({
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as any}
|
||||
disabled={isDisabled}
|
||||
wandControlRef={wandControlRef}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -885,25 +887,20 @@ function SubBlockComponent({
|
||||
|
||||
return (
|
||||
<div onMouseDown={handleMouseDown} className='subblock-content flex flex-col gap-[10px]'>
|
||||
{renderLabel(
|
||||
config,
|
||||
isValidJson,
|
||||
{
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
isWandEnabled,
|
||||
isPreview,
|
||||
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
|
||||
disabled: isDisabled,
|
||||
onSearchClick: handleSearchClick,
|
||||
onSearchBlur: handleSearchBlur,
|
||||
onSearchChange: handleSearchChange,
|
||||
onSearchSubmit: handleSearchSubmit,
|
||||
onSearchCancel: handleSearchCancel,
|
||||
searchInputRef,
|
||||
},
|
||||
subBlockValues
|
||||
)}
|
||||
{renderLabel(config, isValidJson, subBlockValues, {
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
isWandEnabled,
|
||||
isPreview,
|
||||
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
|
||||
disabled: isDisabled,
|
||||
onSearchClick: handleSearchClick,
|
||||
onSearchBlur: handleSearchBlur,
|
||||
onSearchChange: handleSearchChange,
|
||||
onSearchSubmit: handleSearchSubmit,
|
||||
onSearchCancel: handleSearchCancel,
|
||||
searchInputRef,
|
||||
})}
|
||||
{renderInput()}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -342,7 +342,7 @@ export function Editor() {
|
||||
ref={subBlocksRef}
|
||||
className='subblocks-section flex flex-1 flex-col overflow-hidden'
|
||||
>
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[8px] pb-[8px]'>
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px]'>
|
||||
{subBlocks.length === 0 ? (
|
||||
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
|
||||
This block has no subblocks
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||
import { Button, Trash } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
|
||||
/**
|
||||
@@ -18,11 +18,16 @@ import { usePanelEditorStore } from '@/stores/panel'
|
||||
const SubflowNodeStyles: React.FC = () => {
|
||||
return (
|
||||
<style jsx global>{`
|
||||
/* Z-index management for subflow nodes */
|
||||
/* Z-index management for subflow nodes - default behind blocks */
|
||||
.workflow-container .react-flow__node-subflowNode {
|
||||
z-index: -1 !important;
|
||||
}
|
||||
|
||||
/* Selected subflows appear above other subflows but below blocks (z-21) */
|
||||
.workflow-container .react-flow__node-subflowNode:has([data-subflow-selected='true']) {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
/* Drag-over states */
|
||||
.loop-node-drag-over,
|
||||
.parallel-node-drag-over {
|
||||
@@ -63,8 +68,8 @@ export interface SubflowNodeData {
|
||||
*/
|
||||
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const currentBlock = currentWorkflow.getBlockById(id)
|
||||
@@ -80,6 +85,8 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = currentBlockId === id
|
||||
|
||||
const isPreviewSelected = data?.isPreviewSelected || false
|
||||
|
||||
/**
|
||||
* Calculate the nesting level of this subflow node based on its parent hierarchy.
|
||||
* Used to apply appropriate styling for nested containers.
|
||||
@@ -125,8 +132,6 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
|
||||
}
|
||||
|
||||
const isPreviewSelected = data?.isPreviewSelected || false
|
||||
|
||||
/**
|
||||
* Determine the ring styling based on subflow state priority:
|
||||
* 1. Focused (selected in editor) or preview selected - blue ring
|
||||
@@ -162,7 +167,12 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
data-node-id={id}
|
||||
data-type='subflowNode'
|
||||
data-nesting-level={nestingLevel}
|
||||
data-subflow-selected={isFocused || isPreviewSelected}
|
||||
>
|
||||
{!isPreview && (
|
||||
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
|
||||
)}
|
||||
|
||||
{/* Header Section */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -180,18 +190,6 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
{!isPreview && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
collaborativeBatchRemoveBlocks([id])
|
||||
}}
|
||||
className='h-[14px] w-[14px] p-0 opacity-0 transition-opacity duration-100 group-hover:opacity-100'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isPreview && (
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useBlockConnections } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-block-connections'
|
||||
|
||||
interface ConnectionsProps {
|
||||
blockId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays incoming connections at the bottom left of the workflow block
|
||||
*/
|
||||
export function Connections({ blockId }: ConnectionsProps) {
|
||||
const { incomingConnections, hasIncomingConnections } = useBlockConnections(blockId)
|
||||
|
||||
if (!hasIncomingConnections) return null
|
||||
|
||||
const connectionCount = incomingConnections.length
|
||||
const connectionText = `${connectionCount} ${connectionCount === 1 ? 'connection' : 'connections'}`
|
||||
|
||||
return (
|
||||
<div className='pointer-events-none absolute top-full left-0 ml-[8px] flex items-center gap-[8px] pt-[8px] opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>{connectionText}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ActionBar } from './action-bar/action-bar'
|
||||
export { Connections } from './connections/connections'
|
||||
@@ -9,10 +9,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
ActionBar,
|
||||
Connections,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import {
|
||||
useBlockProperties,
|
||||
useChildWorkflow,
|
||||
@@ -934,8 +931,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
|
||||
)}
|
||||
|
||||
{shouldShowDefaultHandles && <Connections blockId={id} />}
|
||||
|
||||
{shouldShowDefaultHandles && (
|
||||
<Handle
|
||||
type='target'
|
||||
|
||||
@@ -126,16 +126,7 @@ export function WorkflowControls() {
|
||||
</PopoverTrigger>
|
||||
<Tooltip.Content side='top'>{mode === 'hand' ? 'Mover' : 'Pointer'}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<PopoverContent align='center' side='top' sideOffset={8} maxWidth={100} minWidth={100}>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
setMode('cursor')
|
||||
setIsCanvasModeOpen(false)
|
||||
}}
|
||||
>
|
||||
<Cursor className='h-3 w-3' />
|
||||
<span>Pointer</span>
|
||||
</PopoverItem>
|
||||
<PopoverContent side='top' sideOffset={8} maxWidth={100} minWidth={100}>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
setMode('hand')
|
||||
@@ -145,6 +136,15 @@ export function WorkflowControls() {
|
||||
<Hand className='h-3 w-3' />
|
||||
<span>Mover</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
setMode('cursor')
|
||||
setIsCanvasModeOpen(false)
|
||||
}}
|
||||
>
|
||||
<Cursor className='h-3 w-3' />
|
||||
<span>Pointer</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workfl
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import { getBlockRingStyles } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
/**
|
||||
@@ -21,7 +21,8 @@ interface UseBlockVisualProps {
|
||||
|
||||
/**
|
||||
* Provides visual state and interaction handlers for workflow blocks.
|
||||
* Computes ring styling based on execution, diff, deletion, and run path states.
|
||||
* Computes ring styling based on editor open state, execution, diff, deletion, and run path states.
|
||||
* Ring is shown only when the editor panel is open for this block, not during selection/dragging.
|
||||
* In preview mode, uses isPreviewSelected for selection highlighting.
|
||||
*
|
||||
* @param props - The hook properties
|
||||
@@ -36,13 +37,15 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
|
||||
const {
|
||||
isEnabled,
|
||||
isActive: blockIsActive,
|
||||
isActive: isExecuting,
|
||||
diffStatus,
|
||||
isDeletedBlock,
|
||||
} = useBlockState(blockId, currentWorkflow, data)
|
||||
|
||||
// In preview mode, use isPreviewSelected for selection state
|
||||
const isActive = isPreview ? isPreviewSelected : blockIsActive
|
||||
// Check if the editor panel is open for this block
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const activeTab = usePanelStore((state) => state.activeTab)
|
||||
const isEditorOpen = !isPreview && currentBlockId === blockId && activeTab === 'editor'
|
||||
|
||||
const lastRunPath = useExecutionStore((state) => state.lastRunPath)
|
||||
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
|
||||
@@ -58,14 +61,24 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
const { hasRing, ringClassName: ringStyles } = useMemo(
|
||||
() =>
|
||||
getBlockRingStyles({
|
||||
isActive,
|
||||
isExecuting: isPreview ? false : isExecuting,
|
||||
isEditorOpen: isPreview ? isPreviewSelected : isEditorOpen,
|
||||
isPending: isPreview ? false : isPending,
|
||||
isDeletedBlock: isPreview ? false : isDeletedBlock,
|
||||
diffStatus: isPreview ? undefined : diffStatus,
|
||||
runPathStatus,
|
||||
isPreviewSelection: isPreview && isPreviewSelected,
|
||||
}),
|
||||
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
|
||||
[
|
||||
isExecuting,
|
||||
isEditorOpen,
|
||||
isPending,
|
||||
isDeletedBlock,
|
||||
diffStatus,
|
||||
runPathStatus,
|
||||
isPreview,
|
||||
isPreviewSelected,
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,10 @@ export type BlockDiffStatus = 'new' | 'edited' | null | undefined
|
||||
export type BlockRunPathStatus = 'success' | 'error' | undefined
|
||||
|
||||
export interface BlockRingOptions {
|
||||
isActive: boolean
|
||||
/** Whether the block is executing (shows green pulsing ring) */
|
||||
isExecuting: boolean
|
||||
/** Whether the editor panel is open for this block (shows blue ring) */
|
||||
isEditorOpen: boolean
|
||||
isPending: boolean
|
||||
isDeletedBlock: boolean
|
||||
diffStatus: BlockDiffStatus
|
||||
@@ -15,17 +18,25 @@ export interface BlockRingOptions {
|
||||
|
||||
/**
|
||||
* Derives visual ring visibility and class names for workflow blocks
|
||||
* based on execution, diff, deletion, and run-path states.
|
||||
* based on editor open state, execution, diff, deletion, and run-path states.
|
||||
*/
|
||||
export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
hasRing: boolean
|
||||
ringClassName: string
|
||||
} {
|
||||
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
|
||||
options
|
||||
const {
|
||||
isExecuting,
|
||||
isEditorOpen,
|
||||
isPending,
|
||||
isDeletedBlock,
|
||||
diffStatus,
|
||||
runPathStatus,
|
||||
isPreviewSelection,
|
||||
} = options
|
||||
|
||||
const hasRing =
|
||||
isActive ||
|
||||
isExecuting ||
|
||||
isEditorOpen ||
|
||||
isPending ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
@@ -33,37 +44,41 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
!!runPathStatus
|
||||
|
||||
const ringClassName = cn(
|
||||
// Preview selection: static blue ring (standard thickness, no animation)
|
||||
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
|
||||
// Executing block: pulsing success ring with prominent thickness
|
||||
isActive &&
|
||||
!isPreviewSelection &&
|
||||
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||
// Executing block: pulsing success ring with prominent thickness (highest priority)
|
||||
isExecuting && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||
// Editor open or preview selection: static blue ring
|
||||
!isExecuting &&
|
||||
(isEditorOpen || isPreviewSelection) &&
|
||||
'ring-[1.75px] ring-[var(--brand-secondary)]',
|
||||
// Non-active states use standard ring utilities
|
||||
!isActive && hasRing && 'ring-[1.75px]',
|
||||
!isExecuting && !isEditorOpen && !isPreviewSelection && hasRing && 'ring-[1.75px]',
|
||||
// Pending state: warning ring
|
||||
!isActive && isPending && 'ring-[var(--warning)]',
|
||||
!isExecuting && !isEditorOpen && isPending && 'ring-[var(--warning)]',
|
||||
// Deleted state (highest priority after active/pending)
|
||||
!isActive && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
!isExecuting && !isEditorOpen && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
// Diff states
|
||||
!isActive &&
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[var(--brand-tertiary-2)]',
|
||||
!isActive &&
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'edited' &&
|
||||
'ring-[var(--warning)]',
|
||||
// Run path states (lowest priority - only show if no other states active)
|
||||
!isActive &&
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'success' &&
|
||||
'ring-[var(--border-success)]',
|
||||
!isActive &&
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
|
||||
@@ -161,6 +161,24 @@ function calculatePasteOffset(
|
||||
}
|
||||
}
|
||||
|
||||
function mapEdgesByNode(edges: Edge[], nodeIds: Set<string>): Map<string, Edge[]> {
|
||||
const result = new Map<string, Edge[]>()
|
||||
edges.forEach((edge) => {
|
||||
if (nodeIds.has(edge.source)) {
|
||||
const list = result.get(edge.source) ?? []
|
||||
list.push(edge)
|
||||
result.set(edge.source, list)
|
||||
return
|
||||
}
|
||||
if (nodeIds.has(edge.target)) {
|
||||
const list = result.get(edge.target) ?? []
|
||||
list.push(edge)
|
||||
result.set(edge.target, list)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/** Custom node types for ReactFlow. */
|
||||
const nodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowBlock,
|
||||
@@ -178,12 +196,16 @@ const edgeTypes: EdgeTypes = {
|
||||
const defaultEdgeOptions = { type: 'custom' }
|
||||
|
||||
const reactFlowStyles = [
|
||||
'bg-[var(--bg)]',
|
||||
'[&_.react-flow__edges]:!z-0',
|
||||
'[&_.react-flow__node]:!z-[21]',
|
||||
'[&_.react-flow__handle]:!z-[30]',
|
||||
'[&_.react-flow__edge-labels]:!z-[60]',
|
||||
'[&_.react-flow__pane]:!bg-transparent',
|
||||
'[&_.react-flow__renderer]:!bg-transparent',
|
||||
'[&_.react-flow__pane]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__pane]:select-none',
|
||||
'[&_.react-flow__selectionpane]:select-none',
|
||||
'[&_.react-flow__renderer]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__viewport]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__background]:hidden',
|
||||
].join(' ')
|
||||
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
|
||||
@@ -200,7 +222,6 @@ interface BlockData {
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
distance: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,6 +343,59 @@ const WorkflowContent = React.memo(() => {
|
||||
return resizeLoopNodes(updateNodeDimensions)
|
||||
}, [resizeLoopNodes, updateNodeDimensions])
|
||||
|
||||
/** Checks if a node can be placed inside a container (loop/parallel). */
|
||||
const canNodeEnterContainer = useCallback(
|
||||
(node: Node): boolean => {
|
||||
if (node.data?.type === 'starter') return false
|
||||
if (node.type === 'subflowNode') return false
|
||||
const block = blocks[node.id]
|
||||
return !(block && TriggerUtils.isTriggerBlock(block))
|
||||
},
|
||||
[blocks]
|
||||
)
|
||||
|
||||
/** Shifts position updates to ensure nodes stay within container bounds. */
|
||||
const shiftUpdatesToContainerBounds = useCallback(
|
||||
<T extends { newPosition: { x: number; y: number } }>(rawUpdates: T[]): T[] => {
|
||||
if (rawUpdates.length === 0) return rawUpdates
|
||||
|
||||
const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x))
|
||||
const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y))
|
||||
|
||||
const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING
|
||||
const targetMinY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
|
||||
|
||||
const shiftX = minX < targetMinX ? targetMinX - minX : 0
|
||||
const shiftY = minY < targetMinY ? targetMinY - minY : 0
|
||||
|
||||
if (shiftX === 0 && shiftY === 0) return rawUpdates
|
||||
|
||||
return rawUpdates.map((u) => ({
|
||||
...u,
|
||||
newPosition: {
|
||||
x: u.newPosition.x + shiftX,
|
||||
y: u.newPosition.y + shiftY,
|
||||
},
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/** Applies highlight styling to a container node during drag operations. */
|
||||
const highlightContainerNode = useCallback(
|
||||
(containerId: string, containerKind: 'loop' | 'parallel') => {
|
||||
clearDragHighlights()
|
||||
const containerElement = document.querySelector(`[data-id="${containerId}"]`)
|
||||
if (containerElement) {
|
||||
containerElement.classList.add(
|
||||
containerKind === 'loop' ? 'loop-node-drag-over' : 'parallel-node-drag-over'
|
||||
)
|
||||
document.body.style.cursor = 'copy'
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
|
||||
|
||||
const isWorkflowEmpty = useMemo(() => Object.keys(blocks).length === 0, [blocks])
|
||||
@@ -503,6 +577,99 @@ const WorkflowContent = React.memo(() => {
|
||||
[collaborativeBatchUpdateParent]
|
||||
)
|
||||
|
||||
/**
|
||||
* Executes a batch parent update for nodes being moved into or out of containers.
|
||||
* Consolidates the common logic used by onNodeDragStop and onSelectionDragStop.
|
||||
*/
|
||||
const executeBatchParentUpdate = useCallback(
|
||||
(nodesToProcess: Node[], targetParentId: string | null, logMessage: string) => {
|
||||
// Build set of node IDs for efficient lookup
|
||||
const nodeIds = new Set(nodesToProcess.map((n) => n.id))
|
||||
|
||||
// Filter to nodes whose parent is actually changing
|
||||
const nodesNeedingUpdate = nodesToProcess.filter((n) => {
|
||||
const block = blocks[n.id]
|
||||
if (!block) return false
|
||||
const currentParent = block.data?.parentId || null
|
||||
// Skip if the node's parent is also being moved (keep children with their parent)
|
||||
if (currentParent && nodeIds.has(currentParent)) return false
|
||||
return currentParent !== targetParentId
|
||||
})
|
||||
|
||||
if (nodesNeedingUpdate.length === 0) return
|
||||
|
||||
// Filter out nodes that cannot enter containers (when target is a container)
|
||||
const validNodes = targetParentId
|
||||
? nodesNeedingUpdate.filter(canNodeEnterContainer)
|
||||
: nodesNeedingUpdate
|
||||
|
||||
if (validNodes.length === 0) return
|
||||
|
||||
// Find boundary edges (edges that cross the container boundary)
|
||||
const movingNodeIds = new Set(validNodes.map((n) => n.id))
|
||||
const boundaryEdges = edgesForDisplay.filter((e) => {
|
||||
const sourceInSelection = movingNodeIds.has(e.source)
|
||||
const targetInSelection = movingNodeIds.has(e.target)
|
||||
return sourceInSelection !== targetInSelection
|
||||
})
|
||||
const boundaryEdgesByNode = mapEdgesByNode(boundaryEdges, movingNodeIds)
|
||||
|
||||
// Build position updates
|
||||
const rawUpdates = validNodes.map((n) => {
|
||||
const edgesForThisNode = boundaryEdgesByNode.get(n.id) ?? []
|
||||
const newPosition = targetParentId
|
||||
? calculateRelativePosition(n.id, targetParentId, true)
|
||||
: getNodeAbsolutePosition(n.id)
|
||||
return {
|
||||
blockId: n.id,
|
||||
newParentId: targetParentId,
|
||||
newPosition,
|
||||
affectedEdges: edgesForThisNode,
|
||||
}
|
||||
})
|
||||
|
||||
// Shift to container bounds if moving into a container
|
||||
const updates = targetParentId ? shiftUpdatesToContainerBounds(rawUpdates) : rawUpdates
|
||||
|
||||
collaborativeBatchUpdateParent(updates)
|
||||
|
||||
// Update display nodes
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
const update = updates.find((u) => u.blockId === node.id)
|
||||
if (update) {
|
||||
return {
|
||||
...node,
|
||||
position: update.newPosition,
|
||||
parentId: update.newParentId ?? undefined,
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
)
|
||||
|
||||
// Resize container if moving into one
|
||||
if (targetParentId) {
|
||||
resizeLoopNodesWrapper()
|
||||
}
|
||||
|
||||
logger.info(logMessage, {
|
||||
targetParentId,
|
||||
nodeCount: validNodes.length,
|
||||
})
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
edgesForDisplay,
|
||||
canNodeEnterContainer,
|
||||
calculateRelativePosition,
|
||||
getNodeAbsolutePosition,
|
||||
shiftUpdatesToContainerBounds,
|
||||
collaborativeBatchUpdateParent,
|
||||
resizeLoopNodesWrapper,
|
||||
]
|
||||
)
|
||||
|
||||
const addBlock = useCallback(
|
||||
(
|
||||
id: string,
|
||||
@@ -515,6 +682,9 @@ const WorkflowContent = React.memo(() => {
|
||||
autoConnectEdge?: Edge,
|
||||
triggerMode?: boolean
|
||||
) => {
|
||||
pendingSelectionRef.current = new Set([id])
|
||||
setSelectedEdges(new Map())
|
||||
|
||||
const blockData: Record<string, unknown> = { ...(data || {}) }
|
||||
if (parentId) blockData.parentId = parentId
|
||||
if (extent) blockData.extent = extent
|
||||
@@ -533,7 +703,7 @@ const WorkflowContent = React.memo(() => {
|
||||
collaborativeBatchAddBlocks([block], autoConnectEdge ? [autoConnectEdge] : [], {}, {}, {})
|
||||
usePanelEditorStore.getState().setCurrentBlockId(id)
|
||||
},
|
||||
[collaborativeBatchAddBlocks]
|
||||
[collaborativeBatchAddBlocks, setSelectedEdges]
|
||||
)
|
||||
|
||||
const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore(
|
||||
@@ -674,104 +844,52 @@ const WorkflowContent = React.memo(() => {
|
||||
copyBlocks(blockIds)
|
||||
}, [contextMenuBlocks, copyBlocks])
|
||||
|
||||
/**
|
||||
* Executes a paste operation with validation and selection handling.
|
||||
* Consolidates shared logic for context paste, duplicate, and keyboard paste.
|
||||
*/
|
||||
const executePasteOperation = useCallback(
|
||||
(operation: 'paste' | 'duplicate', pasteOffset: { x: number; y: number }) => {
|
||||
const pasteData = preparePasteData(pasteOffset)
|
||||
if (!pasteData) return
|
||||
|
||||
const pastedBlocksArray = Object.values(pasteData.blocks)
|
||||
const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation)
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocksArray.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
pasteData.edges,
|
||||
pasteData.loops,
|
||||
pasteData.parallels,
|
||||
pasteData.subBlockValues
|
||||
)
|
||||
},
|
||||
[preparePasteData, blocks, addNotification, activeWorkflowId, collaborativeBatchAddBlocks]
|
||||
)
|
||||
|
||||
const handleContextPaste = useCallback(() => {
|
||||
if (!hasClipboard()) return
|
||||
|
||||
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
|
||||
|
||||
const pasteData = preparePasteData(pasteOffset)
|
||||
if (!pasteData) return
|
||||
|
||||
const {
|
||||
blocks: pastedBlocks,
|
||||
edges: pastedEdges,
|
||||
loops: pastedLoops,
|
||||
parallels: pastedParallels,
|
||||
subBlockValues: pastedSubBlockValues,
|
||||
} = pasteData
|
||||
|
||||
const pastedBlocksArray = Object.values(pastedBlocks)
|
||||
const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'paste')
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocksArray.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
pastedEdges,
|
||||
pastedLoops,
|
||||
pastedParallels,
|
||||
pastedSubBlockValues
|
||||
)
|
||||
}, [
|
||||
hasClipboard,
|
||||
clipboard,
|
||||
screenToFlowPosition,
|
||||
preparePasteData,
|
||||
blocks,
|
||||
activeWorkflowId,
|
||||
addNotification,
|
||||
collaborativeBatchAddBlocks,
|
||||
])
|
||||
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
|
||||
}, [hasClipboard, executePasteOperation, clipboard, screenToFlowPosition])
|
||||
|
||||
const handleContextDuplicate = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
copyBlocks(blockIds)
|
||||
const pasteData = preparePasteData(DEFAULT_PASTE_OFFSET)
|
||||
if (!pasteData) return
|
||||
|
||||
const {
|
||||
blocks: pastedBlocks,
|
||||
edges: pastedEdges,
|
||||
loops: pastedLoops,
|
||||
parallels: pastedParallels,
|
||||
subBlockValues: pastedSubBlockValues,
|
||||
} = pasteData
|
||||
|
||||
const pastedBlocksArray = Object.values(pastedBlocks)
|
||||
const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'duplicate')
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocksArray.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
pastedEdges,
|
||||
pastedLoops,
|
||||
pastedParallels,
|
||||
pastedSubBlockValues
|
||||
)
|
||||
}, [
|
||||
contextMenuBlocks,
|
||||
copyBlocks,
|
||||
preparePasteData,
|
||||
blocks,
|
||||
activeWorkflowId,
|
||||
addNotification,
|
||||
collaborativeBatchAddBlocks,
|
||||
])
|
||||
copyBlocks(contextMenuBlocks.map((b) => b.id))
|
||||
executePasteOperation('duplicate', DEFAULT_PASTE_OFFSET)
|
||||
}, [contextMenuBlocks, copyBlocks, executePasteOperation])
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
@@ -880,36 +998,7 @@ const WorkflowContent = React.memo(() => {
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
||||
if (effectivePermissions.canEdit && hasClipboard()) {
|
||||
event.preventDefault()
|
||||
|
||||
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
|
||||
|
||||
const pasteData = preparePasteData(pasteOffset)
|
||||
if (pasteData) {
|
||||
const pastedBlocks = Object.values(pasteData.blocks)
|
||||
const validation = validateTriggerPaste(pastedBlocks, blocks, 'paste')
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocks.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocks,
|
||||
pasteData.edges,
|
||||
pasteData.loops,
|
||||
pasteData.parallels,
|
||||
pasteData.subBlockValues
|
||||
)
|
||||
}
|
||||
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -926,15 +1015,11 @@ const WorkflowContent = React.memo(() => {
|
||||
redo,
|
||||
getNodes,
|
||||
copyBlocks,
|
||||
preparePasteData,
|
||||
collaborativeBatchAddBlocks,
|
||||
hasClipboard,
|
||||
effectivePermissions.canEdit,
|
||||
blocks,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
clipboard,
|
||||
screenToFlowPosition,
|
||||
executePasteOperation,
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -962,34 +1047,42 @@ const WorkflowContent = React.memo(() => {
|
||||
const containerAtPoint = isPointInLoopNode(newNodePosition)
|
||||
const nodeIndex = new Map(getNodes().map((n) => [n.id, n]))
|
||||
|
||||
const candidates = Object.entries(blocks)
|
||||
.filter(([id, block]) => {
|
||||
if (!block.enabled) return false
|
||||
if (block.type === 'response') return false
|
||||
const node = nodeIndex.get(id)
|
||||
if (!node) return false
|
||||
const closest = Object.entries(blocks).reduce<{
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
distanceSquared: number
|
||||
} | null>((acc, [id, block]) => {
|
||||
if (!block.enabled) return acc
|
||||
if (block.type === 'response') return acc
|
||||
const node = nodeIndex.get(id)
|
||||
if (!node) return acc
|
||||
|
||||
const blockParentId = blocks[id]?.data?.parentId
|
||||
const dropParentId = containerAtPoint?.loopId
|
||||
if (dropParentId !== blockParentId) return false
|
||||
const blockParentId = blocks[id]?.data?.parentId
|
||||
const dropParentId = containerAtPoint?.loopId
|
||||
if (dropParentId !== blockParentId) return acc
|
||||
|
||||
return true
|
||||
})
|
||||
.map(([id, block]) => {
|
||||
const anchor = getNodeAnchorPosition(id)
|
||||
const distance = Math.sqrt(
|
||||
(anchor.x - newNodePosition.x) ** 2 + (anchor.y - newNodePosition.y) ** 2
|
||||
)
|
||||
const anchor = getNodeAnchorPosition(id)
|
||||
const distanceSquared =
|
||||
(anchor.x - newNodePosition.x) ** 2 + (anchor.y - newNodePosition.y) ** 2
|
||||
if (!acc || distanceSquared < acc.distanceSquared) {
|
||||
return {
|
||||
id,
|
||||
type: block.type,
|
||||
position: anchor,
|
||||
distance,
|
||||
distanceSquared,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
}
|
||||
return acc
|
||||
}, null)
|
||||
|
||||
return candidates[0] || null
|
||||
if (!closest) return null
|
||||
|
||||
return {
|
||||
id: closest.id,
|
||||
type: closest.type,
|
||||
position: closest.position,
|
||||
}
|
||||
},
|
||||
[blocks, getNodes, getNodeAnchorPosition, isPointInLoopNode]
|
||||
)
|
||||
@@ -1053,15 +1146,28 @@ const WorkflowContent = React.memo(() => {
|
||||
candidateBlocks: { id: string; type: string; position: { x: number; y: number } }[],
|
||||
targetPosition: { x: number; y: number }
|
||||
): { id: string; type: string; position: { x: number; y: number } } | undefined => {
|
||||
return candidateBlocks
|
||||
.filter((b) => b.type !== 'response')
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - targetPosition.x) ** 2 + (b.position.y - targetPosition.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
const closest = candidateBlocks.reduce<{
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
distanceSquared: number
|
||||
} | null>((acc, block) => {
|
||||
if (block.type === 'response') return acc
|
||||
const distanceSquared =
|
||||
(block.position.x - targetPosition.x) ** 2 + (block.position.y - targetPosition.y) ** 2
|
||||
if (!acc || distanceSquared < acc.distanceSquared) {
|
||||
return { ...block, distanceSquared }
|
||||
}
|
||||
return acc
|
||||
}, null)
|
||||
|
||||
return closest
|
||||
? {
|
||||
id: closest.id,
|
||||
type: closest.type,
|
||||
position: closest.position,
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
[]
|
||||
)
|
||||
@@ -1637,39 +1743,27 @@ const WorkflowContent = React.memo(() => {
|
||||
// Check if hovering over a container node
|
||||
const containerInfo = isPointInLoopNode(position)
|
||||
|
||||
// Clear any previous highlighting
|
||||
clearDragHighlights()
|
||||
|
||||
// Highlight container if hovering over it and not dragging a subflow
|
||||
// Subflow drag is marked by body class flag set by toolbar
|
||||
const isSubflowDrag = document.body.classList.contains('sim-drag-subflow')
|
||||
|
||||
if (containerInfo && !isSubflowDrag) {
|
||||
const containerElement = document.querySelector(`[data-id="${containerInfo.loopId}"]`)
|
||||
if (containerElement) {
|
||||
// Determine the type of container node for appropriate styling
|
||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||
if (
|
||||
containerNode?.type === 'subflowNode' &&
|
||||
(containerNode.data as SubflowNodeData)?.kind === 'loop'
|
||||
) {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if (
|
||||
containerNode?.type === 'subflowNode' &&
|
||||
(containerNode.data as SubflowNodeData)?.kind === 'parallel'
|
||||
) {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||
if (containerNode?.type === 'subflowNode') {
|
||||
const kind = (containerNode.data as SubflowNodeData)?.kind
|
||||
if (kind === 'loop' || kind === 'parallel') {
|
||||
highlightContainerNode(containerInfo.loopId, kind)
|
||||
}
|
||||
document.body.style.cursor = 'copy'
|
||||
}
|
||||
} else {
|
||||
clearDragHighlights()
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error in onDragOver', { err })
|
||||
}
|
||||
},
|
||||
[screenToFlowPosition, isPointInLoopNode, getNodes]
|
||||
[screenToFlowPosition, isPointInLoopNode, getNodes, highlightContainerNode]
|
||||
)
|
||||
|
||||
const loadingWorkflowRef = useRef<string | null>(null)
|
||||
@@ -1974,6 +2068,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const targetInSelection = movingNodeIds.has(e.target)
|
||||
return sourceInSelection !== targetInSelection
|
||||
})
|
||||
const boundaryEdgesByNode = mapEdgesByNode(boundaryEdges, movingNodeIds)
|
||||
|
||||
// Collect absolute positions BEFORE any mutations
|
||||
const absolutePositions = new Map<string, { x: number; y: number }>()
|
||||
@@ -1984,9 +2079,7 @@ const WorkflowContent = React.memo(() => {
|
||||
// Build batch update with all blocks and their affected edges
|
||||
const updates = validBlockIds.map((blockId) => {
|
||||
const absolutePosition = absolutePositions.get(blockId)!
|
||||
const edgesForThisNode = boundaryEdges.filter(
|
||||
(e) => e.source === blockId || e.target === blockId
|
||||
)
|
||||
const edgesForThisNode = boundaryEdgesByNode.get(blockId) ?? []
|
||||
return {
|
||||
blockId,
|
||||
newParentId: null,
|
||||
@@ -2443,23 +2536,9 @@ const WorkflowContent = React.memo(() => {
|
||||
setPotentialParentId(bestContainerMatch.container.id)
|
||||
|
||||
// Add highlight class and change cursor
|
||||
const containerElement = document.querySelector(
|
||||
`[data-id="${bestContainerMatch.container.id}"]`
|
||||
)
|
||||
if (containerElement) {
|
||||
// Apply appropriate class based on container type
|
||||
if (
|
||||
bestContainerMatch.container.type === 'subflowNode' &&
|
||||
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop'
|
||||
) {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if (
|
||||
bestContainerMatch.container.type === 'subflowNode' &&
|
||||
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel'
|
||||
) {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
}
|
||||
document.body.style.cursor = 'copy'
|
||||
const kind = (bestContainerMatch.container.data as SubflowNodeData)?.kind
|
||||
if (kind === 'loop' || kind === 'parallel') {
|
||||
highlightContainerNode(bestContainerMatch.container.id, kind)
|
||||
}
|
||||
} else {
|
||||
// Remove highlighting if no longer over a container
|
||||
@@ -2476,6 +2555,7 @@ const WorkflowContent = React.memo(() => {
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
updateContainerDimensionsDuringDrag,
|
||||
highlightContainerNode,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -2529,103 +2609,12 @@ const WorkflowContent = React.memo(() => {
|
||||
previousPositions: multiNodeDragStartRef.current,
|
||||
})
|
||||
|
||||
// Process parent updates for nodes whose parent is changing
|
||||
// Check each node individually - don't rely on dragStartParentId since
|
||||
// multi-node selections can contain nodes from different parents
|
||||
const selectedNodeIds = new Set(selectedNodes.map((n) => n.id))
|
||||
const nodesNeedingParentUpdate = selectedNodes.filter((n) => {
|
||||
const block = blocks[n.id]
|
||||
if (!block) return false
|
||||
const currentParent = block.data?.parentId || null
|
||||
// Skip if the node's parent is also being moved (keep children with their parent)
|
||||
if (currentParent && selectedNodeIds.has(currentParent)) return false
|
||||
// Node needs update if current parent !== target parent
|
||||
return currentParent !== potentialParentId
|
||||
})
|
||||
|
||||
if (nodesNeedingParentUpdate.length > 0) {
|
||||
// Filter out nodes that cannot be moved into subflows (when target is a subflow)
|
||||
const validNodes = nodesNeedingParentUpdate.filter((n) => {
|
||||
// These restrictions only apply when moving INTO a subflow
|
||||
if (potentialParentId) {
|
||||
if (n.data?.type === 'starter') return false
|
||||
const block = blocks[n.id]
|
||||
if (block && TriggerUtils.isTriggerBlock(block)) return false
|
||||
if (n.type === 'subflowNode') return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (validNodes.length > 0) {
|
||||
const movingNodeIds = new Set(validNodes.map((n) => n.id))
|
||||
const boundaryEdges = edgesForDisplay.filter((e) => {
|
||||
const sourceInSelection = movingNodeIds.has(e.source)
|
||||
const targetInSelection = movingNodeIds.has(e.target)
|
||||
return sourceInSelection !== targetInSelection
|
||||
})
|
||||
|
||||
const rawUpdates = validNodes.map((n) => {
|
||||
const edgesForThisNode = boundaryEdges.filter(
|
||||
(e) => e.source === n.id || e.target === n.id
|
||||
)
|
||||
const newPosition = potentialParentId
|
||||
? calculateRelativePosition(n.id, potentialParentId, true)
|
||||
: getNodeAbsolutePosition(n.id)
|
||||
return {
|
||||
blockId: n.id,
|
||||
newParentId: potentialParentId,
|
||||
newPosition,
|
||||
affectedEdges: edgesForThisNode,
|
||||
}
|
||||
})
|
||||
|
||||
let updates = rawUpdates
|
||||
if (potentialParentId) {
|
||||
const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x))
|
||||
const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y))
|
||||
|
||||
const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING
|
||||
const targetMinY =
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
|
||||
|
||||
const shiftX = minX < targetMinX ? targetMinX - minX : 0
|
||||
const shiftY = minY < targetMinY ? targetMinY - minY : 0
|
||||
|
||||
updates = rawUpdates.map((u) => ({
|
||||
...u,
|
||||
newPosition: {
|
||||
x: u.newPosition.x + shiftX,
|
||||
y: u.newPosition.y + shiftY,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
collaborativeBatchUpdateParent(updates)
|
||||
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
const update = updates.find((u) => u.blockId === node.id)
|
||||
if (update) {
|
||||
return {
|
||||
...node,
|
||||
position: update.newPosition,
|
||||
parentId: update.newParentId ?? undefined,
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
)
|
||||
|
||||
if (potentialParentId) {
|
||||
resizeLoopNodesWrapper()
|
||||
}
|
||||
|
||||
logger.info('Batch moved nodes to new parent', {
|
||||
targetParentId: potentialParentId,
|
||||
nodeCount: validNodes.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Process parent updates using shared helper
|
||||
executeBatchParentUpdate(
|
||||
selectedNodes,
|
||||
potentialParentId,
|
||||
'Batch moved nodes to new parent'
|
||||
)
|
||||
|
||||
// Clear drag start state
|
||||
setDragStartPosition(null)
|
||||
@@ -2818,31 +2807,15 @@ const WorkflowContent = React.memo(() => {
|
||||
edgesForDisplay,
|
||||
removeEdgesForNode,
|
||||
getNodeAbsolutePosition,
|
||||
calculateRelativePosition,
|
||||
resizeLoopNodesWrapper,
|
||||
getDragStartPosition,
|
||||
setDragStartPosition,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
collaborativeBatchUpdatePositions,
|
||||
collaborativeBatchUpdateParent,
|
||||
executeBatchParentUpdate,
|
||||
]
|
||||
)
|
||||
|
||||
// // Lock selection mode when selection drag starts (captures Shift state at drag start)
|
||||
// const onSelectionStart = useCallback(() => {
|
||||
// if (isShiftPressed) {
|
||||
// setIsSelectionDragActive(true)
|
||||
// }
|
||||
// }, [isShiftPressed])
|
||||
|
||||
// const onSelectionEnd = useCallback(() => {
|
||||
// requestAnimationFrame(() => {
|
||||
// setIsSelectionDragActive(false)
|
||||
// setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks))
|
||||
// })
|
||||
// }, [blocks])
|
||||
|
||||
/** Captures initial positions when selection drag starts (for marquee-selected nodes). */
|
||||
const onSelectionDragStart = useCallback(
|
||||
(_event: React.MouseEvent, nodes: Node[]) => {
|
||||
@@ -2883,13 +2856,7 @@ const WorkflowContent = React.memo(() => {
|
||||
if (nodes.length === 0) return
|
||||
|
||||
// Filter out nodes that can't be placed in containers
|
||||
const eligibleNodes = nodes.filter((n) => {
|
||||
if (n.data?.type === 'starter') return false
|
||||
if (n.type === 'subflowNode') return false
|
||||
const block = blocks[n.id]
|
||||
if (block && TriggerUtils.isTriggerBlock(block)) return false
|
||||
return true
|
||||
})
|
||||
const eligibleNodes = nodes.filter(canNodeEnterContainer)
|
||||
|
||||
// If no eligible nodes, clear any potential parent
|
||||
if (eligibleNodes.length === 0) {
|
||||
@@ -2969,18 +2936,12 @@ const WorkflowContent = React.memo(() => {
|
||||
const bestMatch = sortedContainers[0]
|
||||
|
||||
if (bestMatch.container.id !== potentialParentId) {
|
||||
clearDragHighlights()
|
||||
setPotentialParentId(bestMatch.container.id)
|
||||
|
||||
// Add highlight
|
||||
const containerElement = document.querySelector(`[data-id="${bestMatch.container.id}"]`)
|
||||
if (containerElement) {
|
||||
if ((bestMatch.container.data as SubflowNodeData)?.kind === 'loop') {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if ((bestMatch.container.data as SubflowNodeData)?.kind === 'parallel') {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
}
|
||||
document.body.style.cursor = 'copy'
|
||||
const kind = (bestMatch.container.data as SubflowNodeData)?.kind
|
||||
if (kind === 'loop' || kind === 'parallel') {
|
||||
highlightContainerNode(bestMatch.container.id, kind)
|
||||
}
|
||||
}
|
||||
} else if (potentialParentId) {
|
||||
@@ -2989,12 +2950,13 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
canNodeEnterContainer,
|
||||
getNodes,
|
||||
potentialParentId,
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
clearDragHighlights,
|
||||
highlightContainerNode,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -3009,102 +2971,8 @@ const WorkflowContent = React.memo(() => {
|
||||
previousPositions: multiNodeDragStartRef.current,
|
||||
})
|
||||
|
||||
// Process parent updates for nodes whose parent is changing
|
||||
// Check each node individually - don't rely on dragStartParentId since
|
||||
// multi-node selections can contain nodes from different parents
|
||||
const selectedNodeIds = new Set(nodes.map((n: Node) => n.id))
|
||||
const nodesNeedingParentUpdate = nodes.filter((n: Node) => {
|
||||
const block = blocks[n.id]
|
||||
if (!block) return false
|
||||
const currentParent = block.data?.parentId || null
|
||||
// Skip if the node's parent is also being moved (keep children with their parent)
|
||||
if (currentParent && selectedNodeIds.has(currentParent)) return false
|
||||
// Node needs update if current parent !== target parent
|
||||
return currentParent !== potentialParentId
|
||||
})
|
||||
|
||||
if (nodesNeedingParentUpdate.length > 0) {
|
||||
// Filter out nodes that cannot be moved into subflows (when target is a subflow)
|
||||
const validNodes = nodesNeedingParentUpdate.filter((n: Node) => {
|
||||
// These restrictions only apply when moving INTO a subflow
|
||||
if (potentialParentId) {
|
||||
if (n.data?.type === 'starter') return false
|
||||
const block = blocks[n.id]
|
||||
if (block && TriggerUtils.isTriggerBlock(block)) return false
|
||||
if (n.type === 'subflowNode') return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (validNodes.length > 0) {
|
||||
const movingNodeIds = new Set(validNodes.map((n: Node) => n.id))
|
||||
const boundaryEdges = edgesForDisplay.filter((e) => {
|
||||
const sourceInSelection = movingNodeIds.has(e.source)
|
||||
const targetInSelection = movingNodeIds.has(e.target)
|
||||
return sourceInSelection !== targetInSelection
|
||||
})
|
||||
|
||||
const rawUpdates = validNodes.map((n: Node) => {
|
||||
const edgesForThisNode = boundaryEdges.filter(
|
||||
(e) => e.source === n.id || e.target === n.id
|
||||
)
|
||||
const newPosition = potentialParentId
|
||||
? calculateRelativePosition(n.id, potentialParentId, true)
|
||||
: getNodeAbsolutePosition(n.id)
|
||||
return {
|
||||
blockId: n.id,
|
||||
newParentId: potentialParentId,
|
||||
newPosition,
|
||||
affectedEdges: edgesForThisNode,
|
||||
}
|
||||
})
|
||||
|
||||
let updates = rawUpdates
|
||||
if (potentialParentId) {
|
||||
const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x))
|
||||
const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y))
|
||||
|
||||
const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING
|
||||
const targetMinY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
|
||||
|
||||
const shiftX = minX < targetMinX ? targetMinX - minX : 0
|
||||
const shiftY = minY < targetMinY ? targetMinY - minY : 0
|
||||
|
||||
updates = rawUpdates.map((u) => ({
|
||||
...u,
|
||||
newPosition: {
|
||||
x: u.newPosition.x + shiftX,
|
||||
y: u.newPosition.y + shiftY,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
collaborativeBatchUpdateParent(updates)
|
||||
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
const update = updates.find((u) => u.blockId === node.id)
|
||||
if (update) {
|
||||
return {
|
||||
...node,
|
||||
position: update.newPosition,
|
||||
parentId: update.newParentId ?? undefined,
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
)
|
||||
|
||||
if (potentialParentId) {
|
||||
resizeLoopNodesWrapper()
|
||||
}
|
||||
|
||||
logger.info('Batch moved selection to new parent', {
|
||||
targetParentId: potentialParentId,
|
||||
nodeCount: validNodes.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Process parent updates using shared helper
|
||||
executeBatchParentUpdate(nodes, potentialParentId, 'Batch moved selection to new parent')
|
||||
|
||||
// Clear drag state
|
||||
setDragStartPosition(null)
|
||||
@@ -3114,14 +2982,10 @@ const WorkflowContent = React.memo(() => {
|
||||
[
|
||||
blocks,
|
||||
getNodes,
|
||||
getNodeAbsolutePosition,
|
||||
collaborativeBatchUpdatePositions,
|
||||
collaborativeBatchUpdateParent,
|
||||
calculateRelativePosition,
|
||||
resizeLoopNodesWrapper,
|
||||
potentialParentId,
|
||||
edgesForDisplay,
|
||||
clearDragHighlights,
|
||||
executeBatchParentUpdate,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -3130,6 +2994,23 @@ const WorkflowContent = React.memo(() => {
|
||||
usePanelEditorStore.getState().clearCurrentBlock()
|
||||
}, [])
|
||||
|
||||
/** Prevents native text selection when starting a shift-drag on the pane. */
|
||||
const handleCanvasMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (!event.shiftKey) return
|
||||
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target) return
|
||||
|
||||
const isPaneTarget = Boolean(target.closest('.react-flow__pane, .react-flow__selectionpane'))
|
||||
if (!isPaneTarget) return
|
||||
|
||||
event.preventDefault()
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
selection.removeAllRanges()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles node click to select the node in ReactFlow.
|
||||
* Parent-child conflict resolution happens automatically in onNodesChange.
|
||||
@@ -3303,6 +3184,7 @@ const WorkflowContent = React.memo(() => {
|
||||
onConnectEnd={effectivePermissions.canEdit ? onConnectEnd : undefined}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onDrop={effectivePermissions.canEdit ? onDrop : undefined}
|
||||
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
|
||||
onInit={(instance) => {
|
||||
|
||||
@@ -1156,11 +1156,6 @@ function BlockDetailsSidebarContent({
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{block.name || blockConfig.name}
|
||||
</span>
|
||||
{block.enabled === false && (
|
||||
<Badge variant='red' size='sm'>
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
{onClose && (
|
||||
<Button variant='ghost' className='!p-[4px] flex-shrink-0' onClick={onClose}>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
|
||||
@@ -181,17 +181,18 @@ interface FitViewOnChangeProps {
|
||||
*/
|
||||
function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
|
||||
const { fitView } = useReactFlow()
|
||||
const hasFittedRef = useRef(false)
|
||||
const lastNodeIdsRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (nodeIds.length > 0 && !hasFittedRef.current) {
|
||||
hasFittedRef.current = true
|
||||
// Small delay to ensure nodes are rendered before fitting
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitView({ padding: fitPadding, duration: 200 })
|
||||
}, 50)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
if (!nodeIds.length) return
|
||||
const shouldFit = lastNodeIdsRef.current !== nodeIds
|
||||
if (!shouldFit) return
|
||||
lastNodeIdsRef.current = nodeIds
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitView({ padding: fitPadding, duration: 200 })
|
||||
}, 50)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [nodeIds, fitPadding, fitView])
|
||||
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface UsageIndicatorContextMenuProps {
|
||||
/**
|
||||
* Whether the context menu is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Position of the context menu
|
||||
*/
|
||||
position: { x: number; y: number }
|
||||
/**
|
||||
* Ref for the menu element
|
||||
*/
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
/**
|
||||
* Callback when menu should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Menu items configuration based on plan and permissions
|
||||
*/
|
||||
menuItems: UsageMenuItems
|
||||
}
|
||||
|
||||
interface UsageMenuItems {
|
||||
/**
|
||||
* Show "Set usage limit" option
|
||||
*/
|
||||
showSetLimit: boolean
|
||||
/**
|
||||
* Show "Upgrade to Pro" option (free users)
|
||||
*/
|
||||
showUpgradeToPro: boolean
|
||||
/**
|
||||
* Show "Upgrade to Team" option (free or pro users)
|
||||
*/
|
||||
showUpgradeToTeam: boolean
|
||||
/**
|
||||
* Show "Manage seats" option (team admins)
|
||||
*/
|
||||
showManageSeats: boolean
|
||||
/**
|
||||
* Show "Upgrade to Enterprise" option
|
||||
*/
|
||||
showUpgradeToEnterprise: boolean
|
||||
/**
|
||||
* Show "Contact support" option (enterprise users)
|
||||
*/
|
||||
showContactSupport: boolean
|
||||
/**
|
||||
* Callbacks
|
||||
*/
|
||||
onSetLimit?: () => void
|
||||
onUpgradeToPro?: () => void
|
||||
onUpgradeToTeam?: () => void
|
||||
onManageSeats?: () => void
|
||||
onUpgradeToEnterprise?: () => void
|
||||
onContactSupport?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for usage indicator.
|
||||
* Displays plan-appropriate options in a popover at the right-click position.
|
||||
*/
|
||||
export function UsageIndicatorContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
menuItems,
|
||||
}: UsageIndicatorContextMenuProps) {
|
||||
const {
|
||||
showSetLimit,
|
||||
showUpgradeToPro,
|
||||
showUpgradeToTeam,
|
||||
showManageSeats,
|
||||
showUpgradeToEnterprise,
|
||||
showContactSupport,
|
||||
onSetLimit,
|
||||
onUpgradeToPro,
|
||||
onUpgradeToTeam,
|
||||
onManageSeats,
|
||||
onUpgradeToEnterprise,
|
||||
onContactSupport,
|
||||
} = menuItems
|
||||
|
||||
const hasLimitSection = showSetLimit
|
||||
const hasUpgradeSection =
|
||||
showUpgradeToPro || showUpgradeToTeam || showUpgradeToEnterprise || showContactSupport
|
||||
const hasTeamSection = showManageSeats
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='top' sideOffset={4}>
|
||||
{/* Limit management section */}
|
||||
{showSetLimit && onSetLimit && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onSetLimit()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Set usage limit
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Team management section */}
|
||||
{hasLimitSection && hasTeamSection && <PopoverDivider />}
|
||||
{showManageSeats && onManageSeats && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onManageSeats()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Manage seats
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Upgrade section */}
|
||||
{(hasLimitSection || hasTeamSection) && hasUpgradeSection && <PopoverDivider />}
|
||||
{showUpgradeToPro && onUpgradeToPro && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onUpgradeToPro()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showUpgradeToTeam && onUpgradeToTeam && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onUpgradeToTeam()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Upgrade to Team
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showUpgradeToEnterprise && onUpgradeToEnterprise && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onUpgradeToEnterprise()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Upgrade to Enterprise
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showContactSupport && onContactSupport && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onContactSupport()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Contact support
|
||||
</PopoverItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
import {
|
||||
getFilledPillColor,
|
||||
USAGE_PILL_COLORS,
|
||||
USAGE_THRESHOLDS,
|
||||
} from '@/lib/billing/client/usage-visualization'
|
||||
import { getBillingStatus, getSubscriptionStatus, getUsage } from '@/lib/billing/client/utils'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { UsageIndicatorContextMenu } from './usage-indicator-context-menu'
|
||||
|
||||
const logger = createLogger('UsageIndicator')
|
||||
|
||||
@@ -188,6 +191,8 @@ interface UsageIndicatorProps {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const TYPEFORM_ENTERPRISE_URL = 'https://form.typeform.com/to/jqCO12pF'
|
||||
|
||||
/**
|
||||
* Displays a visual usage indicator with animated pill bar.
|
||||
*/
|
||||
@@ -196,6 +201,15 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
|
||||
const { onOperationConfirmed } = useSocket()
|
||||
const queryClient = useQueryClient()
|
||||
const { handleUpgrade } = useSubscriptionUpgrade()
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position: contextMenuPosition,
|
||||
menuRef: contextMenuRef,
|
||||
handleContextMenu,
|
||||
closeMenu: closeContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
useEffect(() => {
|
||||
const handleOperationConfirmed = () => {
|
||||
@@ -266,6 +280,96 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
|
||||
const filledColor = getFilledPillColor(isCritical, isWarning)
|
||||
|
||||
const isFree = planType === 'free'
|
||||
const isPro = planType === 'pro'
|
||||
const isTeam = planType === 'team'
|
||||
const isEnterprise = planType === 'enterprise'
|
||||
|
||||
const handleUpgradeToPro = useCallback(async () => {
|
||||
try {
|
||||
await handleUpgrade('pro')
|
||||
} catch (error) {
|
||||
logger.error('Failed to upgrade to Pro', { error })
|
||||
}
|
||||
}, [handleUpgrade])
|
||||
|
||||
const handleUpgradeToTeam = useCallback(async () => {
|
||||
try {
|
||||
await handleUpgrade('team')
|
||||
} catch (error) {
|
||||
logger.error('Failed to upgrade to Team', { error })
|
||||
}
|
||||
}, [handleUpgrade])
|
||||
|
||||
const handleSetLimit = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } }))
|
||||
}, [])
|
||||
|
||||
const handleManageSeats = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'team' } }))
|
||||
}, [])
|
||||
|
||||
const handleUpgradeToEnterprise = useCallback(() => {
|
||||
window.open(TYPEFORM_ENTERPRISE_URL, '_blank')
|
||||
}, [])
|
||||
|
||||
const handleContactSupport = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-help-modal'))
|
||||
}, [])
|
||||
|
||||
const contextMenuItems = useMemo(
|
||||
() => ({
|
||||
// Set limit: Only for Pro and Team admins (not free, not enterprise)
|
||||
showSetLimit: (isPro || (isTeam && userCanManageBilling)) && !isEnterprise,
|
||||
// Upgrade to Pro: Only for free users
|
||||
showUpgradeToPro: isFree,
|
||||
// Upgrade to Team: Free users and Pro users with billing permission
|
||||
showUpgradeToTeam: isFree || (isPro && userCanManageBilling),
|
||||
// Manage seats: Only for Team admins
|
||||
showManageSeats: isTeam && userCanManageBilling,
|
||||
// Upgrade to Enterprise: Only for Team admins (not free, not pro, not enterprise)
|
||||
showUpgradeToEnterprise: isTeam && userCanManageBilling,
|
||||
// Contact support: Only for Enterprise admins
|
||||
showContactSupport: isEnterprise && userCanManageBilling,
|
||||
onSetLimit: handleSetLimit,
|
||||
onUpgradeToPro: handleUpgradeToPro,
|
||||
onUpgradeToTeam: handleUpgradeToTeam,
|
||||
onManageSeats: handleManageSeats,
|
||||
onUpgradeToEnterprise: handleUpgradeToEnterprise,
|
||||
onContactSupport: handleContactSupport,
|
||||
}),
|
||||
[
|
||||
isFree,
|
||||
isPro,
|
||||
isTeam,
|
||||
isEnterprise,
|
||||
userCanManageBilling,
|
||||
handleSetLimit,
|
||||
handleUpgradeToPro,
|
||||
handleUpgradeToTeam,
|
||||
handleManageSeats,
|
||||
handleUpgradeToEnterprise,
|
||||
handleContactSupport,
|
||||
]
|
||||
)
|
||||
|
||||
// Check if any context menu items will be visible
|
||||
const hasContextMenuItems =
|
||||
contextMenuItems.showSetLimit ||
|
||||
contextMenuItems.showUpgradeToPro ||
|
||||
contextMenuItems.showUpgradeToTeam ||
|
||||
contextMenuItems.showManageSeats ||
|
||||
contextMenuItems.showUpgradeToEnterprise ||
|
||||
contextMenuItems.showContactSupport
|
||||
|
||||
const handleContextMenuWithCheck = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!hasContextMenuItems) return
|
||||
handleContextMenu(e)
|
||||
},
|
||||
[hasContextMenuItems, handleContextMenu]
|
||||
)
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [wavePosition, setWavePosition] = useState<number | null>(null)
|
||||
|
||||
@@ -359,82 +463,93 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Top row */}
|
||||
<div className='flex h-[18px] items-center justify-between'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
|
||||
{showPlanText && (
|
||||
<>
|
||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
{PLAN_NAMES[planType]}
|
||||
</span>
|
||||
<div className='h-[14px] w-[1.5px] flex-shrink-0 bg-[var(--divider)]' />
|
||||
</>
|
||||
)}
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[4px]'>
|
||||
{statusText.isError ? (
|
||||
<span className='font-medium text-[12px] text-[var(--text-error)]'>
|
||||
{statusText.text}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className='group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenuWithCheck}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Top row */}
|
||||
<div className='flex h-[18px] items-center justify-between'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
|
||||
{showPlanText && (
|
||||
<>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
|
||||
${usage.current.toFixed(2)}
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>/</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
|
||||
${usage.limit.toFixed(2)}
|
||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
{PLAN_NAMES[planType]}
|
||||
</span>
|
||||
<div className='h-[14px] w-[1.5px] flex-shrink-0 bg-[var(--divider)]' />
|
||||
</>
|
||||
)}
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[4px]'>
|
||||
{statusText.isError ? (
|
||||
<span className='font-medium text-[12px] text-[var(--text-error)]'>
|
||||
{statusText.text}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
|
||||
${usage.current.toFixed(2)}
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>/</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
|
||||
${usage.limit.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{badgeConfig.show && (
|
||||
<Badge variant={badgeConfig.variant} size='sm' className='-translate-y-[1px]'>
|
||||
{badgeConfig.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{badgeConfig.show && (
|
||||
<Badge variant={badgeConfig.variant} size='sm' className='-translate-y-[1px]'>
|
||||
{badgeConfig.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pills row */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{Array.from({ length: pillCount }).map((_, i) => {
|
||||
const isFilled = i < filledPillsCount
|
||||
const baseColor = isFilled ? filledColor : USAGE_PILL_COLORS.UNFILLED
|
||||
{/* Pills row */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{Array.from({ length: pillCount }).map((_, i) => {
|
||||
const isFilled = i < filledPillsCount
|
||||
const baseColor = isFilled ? filledColor : USAGE_PILL_COLORS.UNFILLED
|
||||
|
||||
const backgroundColor = baseColor
|
||||
let backgroundImage: string | undefined
|
||||
const backgroundColor = baseColor
|
||||
let backgroundImage: string | undefined
|
||||
|
||||
if (isHovered && wavePosition !== null) {
|
||||
const headIndex = Math.floor(wavePosition)
|
||||
const pillOffsetFromStart = i - startAnimationIndex
|
||||
if (isHovered && wavePosition !== null) {
|
||||
const headIndex = Math.floor(wavePosition)
|
||||
const pillOffsetFromStart = i - startAnimationIndex
|
||||
|
||||
if (pillOffsetFromStart >= 0 && pillOffsetFromStart < headIndex) {
|
||||
backgroundImage = `linear-gradient(to right, ${filledColor}, ${filledColor})`
|
||||
} else if (pillOffsetFromStart === headIndex) {
|
||||
const fillPercent = (wavePosition - headIndex) * 100
|
||||
backgroundImage = `linear-gradient(to right, ${filledColor} ${fillPercent}%, ${baseColor} ${fillPercent}%)`
|
||||
if (pillOffsetFromStart >= 0 && pillOffsetFromStart < headIndex) {
|
||||
backgroundImage = `linear-gradient(to right, ${filledColor}, ${filledColor})`
|
||||
} else if (pillOffsetFromStart === headIndex) {
|
||||
const fillPercent = (wavePosition - headIndex) * 100
|
||||
backgroundImage = `linear-gradient(to right, ${filledColor} ${fillPercent}%, ${baseColor} ${fillPercent}%)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className='h-[6px] flex-1 rounded-[2px]'
|
||||
style={{
|
||||
backgroundColor,
|
||||
backgroundImage,
|
||||
transition: isHovered ? 'none' : 'background-color 200ms',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className='h-[6px] flex-1 rounded-[2px]'
|
||||
style={{
|
||||
backgroundColor,
|
||||
backgroundImage,
|
||||
transition: isHovered ? 'none' : 'background-color 200ms',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsageIndicatorContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
onClose={closeContextMenu}
|
||||
menuItems={contextMenuItems}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverBackButton,
|
||||
@@ -368,17 +369,17 @@ export function ContextMenu({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase caret-white focus:outline-none'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!canSubmitHex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleHexSubmit()
|
||||
}}
|
||||
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--brand-tertiary-2)] text-white disabled:opacity-40'
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
>
|
||||
<Check className='h-[12px] w-[12px]' />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverFolder>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
|
||||
interface EmptyAreaContextMenuProps {
|
||||
/**
|
||||
* Whether the context menu is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Position of the context menu
|
||||
*/
|
||||
position: { x: number; y: number }
|
||||
/**
|
||||
* Ref for the menu element
|
||||
*/
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
/**
|
||||
* Callback when menu should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Callback when create workflow is clicked
|
||||
*/
|
||||
onCreateWorkflow: () => void
|
||||
/**
|
||||
* Callback when create folder is clicked
|
||||
*/
|
||||
onCreateFolder: () => void
|
||||
/**
|
||||
* Whether create workflow is disabled
|
||||
*/
|
||||
disableCreateWorkflow?: boolean
|
||||
/**
|
||||
* Whether create folder is disabled
|
||||
*/
|
||||
disableCreateFolder?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for sidebar empty area.
|
||||
* Displays options to create a workflow or folder when right-clicking on empty space.
|
||||
*/
|
||||
export function EmptyAreaContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onCreateWorkflow,
|
||||
onCreateFolder,
|
||||
disableCreateWorkflow = false,
|
||||
disableCreateFolder = false,
|
||||
}: EmptyAreaContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem
|
||||
disabled={disableCreateWorkflow}
|
||||
onClick={() => {
|
||||
onCreateWorkflow()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Create workflow
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
disabled={disableCreateFolder}
|
||||
onClick={() => {
|
||||
onCreateFolder()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Create folder
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { EmptyAreaContextMenu } from './empty-area-context-menu'
|
||||
@@ -342,7 +342,7 @@ export function FolderItem({
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span
|
||||
className='min-w-0 flex-1 truncate font-medium text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
onDoubleClick={handleDoubleClick}
|
||||
@@ -357,7 +357,7 @@ export function FolderItem({
|
||||
>
|
||||
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useEffect, useMemo } from 'react'
|
||||
import { type CSSProperties, useMemo } from 'react'
|
||||
import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
@@ -19,11 +18,6 @@ const AVATAR_CONFIG = {
|
||||
|
||||
interface AvatarsProps {
|
||||
workflowId: string
|
||||
/**
|
||||
* Callback fired when the presence visibility changes.
|
||||
* Used by parent components to adjust layout (e.g., text truncation spacing).
|
||||
*/
|
||||
onPresenceChange?: (hasAvatars: boolean) => void
|
||||
}
|
||||
|
||||
interface PresenceUser {
|
||||
@@ -85,10 +79,8 @@ function UserAvatar({ user, index }: UserAvatarProps) {
|
||||
* @param props - Component props
|
||||
* @returns Avatar stack for workflow presence
|
||||
*/
|
||||
export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
|
||||
const { presenceUsers, currentWorkflowId } = useSocket()
|
||||
const { data: session } = useSession()
|
||||
const currentUserId = session?.user?.id
|
||||
export function Avatars({ workflowId }: AvatarsProps) {
|
||||
const { presenceUsers, currentWorkflowId, currentSocketId } = useSocket()
|
||||
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
|
||||
|
||||
/**
|
||||
@@ -104,14 +96,14 @@ export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
|
||||
|
||||
/**
|
||||
* Only show presence for the currently active workflow.
|
||||
* Filter out the current user from the list.
|
||||
* Filter out the current socket connection (allows same user's other tabs to appear).
|
||||
*/
|
||||
const workflowUsers = useMemo(() => {
|
||||
if (currentWorkflowId !== workflowId) {
|
||||
return []
|
||||
}
|
||||
return presenceUsers.filter((user) => user.userId !== currentUserId)
|
||||
}, [presenceUsers, currentWorkflowId, workflowId, currentUserId])
|
||||
return presenceUsers.filter((user) => user.socketId !== currentSocketId)
|
||||
}, [presenceUsers, currentWorkflowId, workflowId, currentSocketId])
|
||||
|
||||
/**
|
||||
* Calculate visible users and overflow count
|
||||
@@ -127,19 +119,12 @@ export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
|
||||
return { visibleUsers: visible, overflowCount: overflow }
|
||||
}, [workflowUsers, maxVisible])
|
||||
|
||||
useEffect(() => {
|
||||
const hasAnyAvatars = visibleUsers.length > 0
|
||||
if (typeof onPresenceChange === 'function') {
|
||||
onPresenceChange(hasAnyAvatars)
|
||||
}
|
||||
}, [visibleUsers, onPresenceChange])
|
||||
|
||||
if (visibleUsers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='-space-x-1 flex items-center'>
|
||||
<div className='-space-x-1 flex flex-shrink-0 items-center'>
|
||||
{overflowCount > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
|
||||
@@ -64,8 +64,6 @@ export function WorkflowItem({
|
||||
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
|
||||
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
|
||||
|
||||
const [hasAvatars, setHasAvatars] = useState(false)
|
||||
|
||||
const capturedSelectionRef = useRef<{
|
||||
workflowIds: string[]
|
||||
workflowNames: string | string[]
|
||||
@@ -319,48 +317,50 @@ export function WorkflowItem({
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
<div className={clsx('min-w-0 flex-1', hasAvatars && 'pr-[8px]')}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className={clsx(
|
||||
'w-full border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
active
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'truncate font-medium',
|
||||
active
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className={clsx(
|
||||
'w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
active
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'min-w-0 flex-1 truncate font-medium',
|
||||
active
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
)}
|
||||
{!isEditing && <Avatars workflowId={workflow.id} />}
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Avatars workflowId={workflow.id} onPresenceChange={setHasAvatars} />
|
||||
<button
|
||||
type='button'
|
||||
onPointerDown={handleMorePointerDown}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu'
|
||||
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
|
||||
import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item'
|
||||
import {
|
||||
useContextMenu,
|
||||
useDragDrop,
|
||||
useWorkflowSelection,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
@@ -36,6 +38,9 @@ interface WorkflowListProps {
|
||||
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
||||
onCreateWorkflow?: () => void
|
||||
onCreateFolder?: () => void
|
||||
disableCreate?: boolean
|
||||
}
|
||||
|
||||
const DropIndicatorLine = memo(function DropIndicatorLine({
|
||||
@@ -63,6 +68,9 @@ export function WorkflowList({
|
||||
handleFileChange,
|
||||
fileInputRef,
|
||||
scrollContainerRef,
|
||||
onCreateWorkflow,
|
||||
onCreateFolder,
|
||||
disableCreate = false,
|
||||
}: WorkflowListProps) {
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
@@ -72,6 +80,14 @@ export function WorkflowList({
|
||||
const { isLoading: foldersLoading } = useFolders(workspaceId)
|
||||
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
|
||||
|
||||
const {
|
||||
isOpen: isEmptyAreaMenuOpen,
|
||||
position: emptyAreaMenuPosition,
|
||||
menuRef: emptyAreaMenuRef,
|
||||
handleContextMenu: handleEmptyAreaContextMenu,
|
||||
closeMenu: closeEmptyAreaMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const {
|
||||
dropIndicator,
|
||||
isDragging,
|
||||
@@ -351,36 +367,71 @@ export function WorkflowList({
|
||||
[workflowId]
|
||||
)
|
||||
|
||||
const handleContainerContextMenu = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement
|
||||
const isOnEmptyArea =
|
||||
target === e.currentTarget ||
|
||||
target.classList.contains('space-y-[2px]') ||
|
||||
target.closest('[data-empty-area]')
|
||||
if (!isOnEmptyArea) return
|
||||
if (!onCreateWorkflow && !onCreateFolder) return
|
||||
handleEmptyAreaContextMenu(e)
|
||||
},
|
||||
[handleEmptyAreaContextMenu, onCreateWorkflow, onCreateFolder]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex min-h-full flex-col pb-[8px]' onClick={handleContainerClick}>
|
||||
<>
|
||||
<div
|
||||
className={clsx('relative flex-1 rounded-[4px]', !hasRootItems && 'min-h-[26px]')}
|
||||
{...rootDropZoneHandlers}
|
||||
className='flex min-h-full flex-col pb-[8px]'
|
||||
onClick={handleContainerClick}
|
||||
onContextMenu={handleContainerContextMenu}
|
||||
data-empty-area
|
||||
>
|
||||
{/* Root drop target highlight overlay */}
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
|
||||
showRootInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className='space-y-[2px]'>
|
||||
{rootItems.map((item) =>
|
||||
item.type === 'folder'
|
||||
? renderFolderSection(item.data as FolderTreeNode, 0, null)
|
||||
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
|
||||
)}
|
||||
className={clsx('relative flex-1 rounded-[4px]', !hasRootItems && 'min-h-[26px]')}
|
||||
{...rootDropZoneHandlers}
|
||||
data-empty-area
|
||||
>
|
||||
{/* Root drop target highlight overlay */}
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
|
||||
showRootInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className='space-y-[2px]' data-empty-area>
|
||||
{rootItems.map((item) =>
|
||||
item.type === 'folder'
|
||||
? renderFolderSection(item.data as FolderTreeNode, 0, null)
|
||||
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.json,.zip'
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.json,.zip'
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
{onCreateWorkflow && onCreateFolder && (
|
||||
<EmptyAreaContextMenu
|
||||
isOpen={isEmptyAreaMenuOpen}
|
||||
position={emptyAreaMenuPosition}
|
||||
menuRef={emptyAreaMenuRef}
|
||||
onClose={closeEmptyAreaMenu}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
onCreateFolder={onCreateFolder}
|
||||
disableCreateWorkflow={disableCreate}
|
||||
disableCreateFolder={disableCreate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -639,6 +639,9 @@ export function Sidebar() {
|
||||
handleFileChange={handleImportFileChange}
|
||||
fileInputRef={fileInputRef}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,7 @@ interface SocketContextType {
|
||||
isConnected: boolean
|
||||
isConnecting: boolean
|
||||
currentWorkflowId: string | null
|
||||
currentSocketId: string | null
|
||||
presenceUsers: PresenceUser[]
|
||||
joinWorkflow: (workflowId: string) => void
|
||||
leaveWorkflow: () => void
|
||||
@@ -55,7 +56,6 @@ interface SocketContextType {
|
||||
|
||||
emitCursorUpdate: (cursor: { x: number; y: number } | null) => void
|
||||
emitSelectionUpdate: (selection: { type: 'block' | 'edge' | 'none'; id?: string }) => void
|
||||
// Event handlers for receiving real-time updates
|
||||
onWorkflowOperation: (handler: (data: any) => void) => void
|
||||
onSubblockUpdate: (handler: (data: any) => void) => void
|
||||
onVariableUpdate: (handler: (data: any) => void) => void
|
||||
@@ -75,6 +75,7 @@ const SocketContext = createContext<SocketContextType>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
currentWorkflowId: null,
|
||||
currentSocketId: null,
|
||||
presenceUsers: [],
|
||||
joinWorkflow: () => {},
|
||||
leaveWorkflow: () => {},
|
||||
@@ -108,14 +109,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null)
|
||||
const [currentSocketId, setCurrentSocketId] = useState<string | null>(null)
|
||||
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
// Get current workflow ID from URL params
|
||||
const params = useParams()
|
||||
const urlWorkflowId = params?.workflowId as string | undefined
|
||||
|
||||
// Use refs to store event handlers to avoid stale closures
|
||||
const eventHandlers = useRef<{
|
||||
workflowOperation?: (data: any) => void
|
||||
subblockUpdate?: (data: any) => void
|
||||
@@ -131,9 +131,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
operationFailed?: (data: any) => void
|
||||
}>({})
|
||||
|
||||
// Helper function to generate a fresh socket token
|
||||
const generateSocketToken = async (): Promise<string> => {
|
||||
// Avoid overlapping token requests
|
||||
const res = await fetch('/api/auth/socket-token', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -146,11 +144,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
return token
|
||||
}
|
||||
|
||||
// Initialize socket when user is available - only once per session
|
||||
useEffect(() => {
|
||||
if (!user?.id) return
|
||||
|
||||
// Only initialize if we don't have a socket and aren't already connecting
|
||||
if (initializedRef.current || socket || isConnecting) {
|
||||
logger.info('Socket already exists or is connecting, skipping initialization')
|
||||
return
|
||||
@@ -171,12 +167,12 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
})
|
||||
|
||||
const socketInstance = io(socketUrl, {
|
||||
transports: ['websocket', 'polling'], // Keep polling fallback for reliability
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
reconnectionAttempts: Number.POSITIVE_INFINITY, // Socket.IO handles base reconnection
|
||||
reconnectionDelay: 1000, // Start with 1 second delay
|
||||
reconnectionDelayMax: 30000, // Max 30 second delay
|
||||
timeout: 10000, // Back to original timeout
|
||||
reconnectionAttempts: Number.POSITIVE_INFINITY,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
timeout: 10000,
|
||||
auth: async (cb) => {
|
||||
try {
|
||||
const freshToken = await generateSocketToken()
|
||||
@@ -188,24 +184,21 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
},
|
||||
})
|
||||
|
||||
// Connection events
|
||||
socketInstance.on('connect', () => {
|
||||
setIsConnected(true)
|
||||
setIsConnecting(false)
|
||||
setCurrentSocketId(socketInstance.id ?? null)
|
||||
logger.info('Socket connected successfully', {
|
||||
socketId: socketInstance.id,
|
||||
connected: socketInstance.connected,
|
||||
transport: socketInstance.io.engine?.transport?.name,
|
||||
})
|
||||
|
||||
// Automatically join the current workflow room based on URL
|
||||
// This handles both initial connections and reconnections
|
||||
if (urlWorkflowId) {
|
||||
logger.info(`Joining workflow room after connection: ${urlWorkflowId}`)
|
||||
socketInstance.emit('join-workflow', {
|
||||
workflowId: urlWorkflowId,
|
||||
})
|
||||
// Update our internal state to match the URL
|
||||
setCurrentWorkflowId(urlWorkflowId)
|
||||
}
|
||||
})
|
||||
@@ -213,12 +206,12 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
socketInstance.on('disconnect', (reason) => {
|
||||
setIsConnected(false)
|
||||
setIsConnecting(false)
|
||||
setCurrentSocketId(null)
|
||||
|
||||
logger.info('Socket disconnected', {
|
||||
reason,
|
||||
})
|
||||
|
||||
// Clear presence when disconnected
|
||||
setPresenceUsers([])
|
||||
})
|
||||
|
||||
@@ -232,7 +225,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
transport: error.transport,
|
||||
})
|
||||
|
||||
// Authentication errors now indicate either session expiry or token generation issues
|
||||
if (
|
||||
error.message?.includes('Token validation failed') ||
|
||||
error.message?.includes('Authentication failed') ||
|
||||
@@ -241,19 +233,16 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
logger.warn(
|
||||
'Authentication failed - this could indicate session expiry or token generation issues'
|
||||
)
|
||||
// The fresh token generation on each attempt should handle most cases automatically
|
||||
// If this persists, user may need to refresh page or re-login
|
||||
}
|
||||
})
|
||||
|
||||
// Socket.IO provides reconnection logging with attempt numbers
|
||||
socketInstance.on('reconnect', (attemptNumber) => {
|
||||
setCurrentSocketId(socketInstance.id ?? null)
|
||||
logger.info('Socket reconnected successfully', {
|
||||
attemptNumber,
|
||||
socketId: socketInstance.id,
|
||||
transport: socketInstance.io.engine?.transport?.name,
|
||||
})
|
||||
// Note: Workflow rejoining is handled by the 'connect' event which fires for both initial connections and reconnections
|
||||
})
|
||||
|
||||
socketInstance.on('reconnect_attempt', (attemptNumber) => {
|
||||
@@ -276,32 +265,38 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
setIsConnecting(false)
|
||||
})
|
||||
|
||||
// Presence events
|
||||
socketInstance.on('presence-update', (users: PresenceUser[]) => {
|
||||
setPresenceUsers(users)
|
||||
setPresenceUsers((prev) => {
|
||||
const prevMap = new Map(prev.map((u) => [u.socketId, u]))
|
||||
|
||||
return users.map((user) => {
|
||||
const existing = prevMap.get(user.socketId)
|
||||
if (existing) {
|
||||
return {
|
||||
...user,
|
||||
cursor: user.cursor ?? existing.cursor,
|
||||
selection: user.selection ?? existing.selection,
|
||||
}
|
||||
}
|
||||
return user
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Note: user-joined and user-left events removed in favor of authoritative presence-update
|
||||
|
||||
// Workflow operation events
|
||||
socketInstance.on('workflow-operation', (data) => {
|
||||
eventHandlers.current.workflowOperation?.(data)
|
||||
})
|
||||
|
||||
// Subblock update events
|
||||
socketInstance.on('subblock-update', (data) => {
|
||||
eventHandlers.current.subblockUpdate?.(data)
|
||||
})
|
||||
|
||||
// Variable update events
|
||||
socketInstance.on('variable-update', (data) => {
|
||||
eventHandlers.current.variableUpdate?.(data)
|
||||
})
|
||||
|
||||
// Workflow deletion events
|
||||
socketInstance.on('workflow-deleted', (data) => {
|
||||
logger.warn(`Workflow ${data.workflowId} has been deleted`)
|
||||
// Clear current workflow ID if it matches the deleted workflow
|
||||
if (currentWorkflowId === data.workflowId) {
|
||||
setCurrentWorkflowId(null)
|
||||
setPresenceUsers([])
|
||||
@@ -309,19 +304,16 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
eventHandlers.current.workflowDeleted?.(data)
|
||||
})
|
||||
|
||||
// Workflow revert events
|
||||
socketInstance.on('workflow-reverted', (data) => {
|
||||
logger.info(`Workflow ${data.workflowId} has been reverted to deployed state`)
|
||||
eventHandlers.current.workflowReverted?.(data)
|
||||
})
|
||||
|
||||
// Shared function to rehydrate workflow stores
|
||||
const rehydrateWorkflowStores = async (
|
||||
workflowId: string,
|
||||
workflowState: any,
|
||||
source: 'copilot' | 'workflow-state'
|
||||
) => {
|
||||
// Import stores dynamically
|
||||
const [
|
||||
{ useOperationQueueStore },
|
||||
{ useWorkflowRegistry },
|
||||
@@ -336,14 +328,12 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
import('@/stores/workflow-diff/store'),
|
||||
])
|
||||
|
||||
// Only proceed if this is the active workflow
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
if (activeWorkflowId !== workflowId) {
|
||||
logger.info(`Skipping rehydration - workflow ${workflowId} is not active`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for pending operations
|
||||
const hasPending = useOperationQueueStore
|
||||
.getState()
|
||||
.operations.some((op: any) => op.workflowId === workflowId && op.status !== 'confirmed')
|
||||
@@ -352,7 +342,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract subblock values from blocks
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
const blockState = block as any
|
||||
@@ -362,7 +351,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
})
|
||||
})
|
||||
|
||||
// Replace local workflow store with authoritative server state
|
||||
useWorkflowStore.setState({
|
||||
blocks: workflowState.blocks || {},
|
||||
edges: workflowState.edges || [],
|
||||
@@ -372,7 +360,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
deploymentStatuses: workflowState.deploymentStatuses || {},
|
||||
})
|
||||
|
||||
// Replace subblock store values for this workflow
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
@@ -384,14 +371,12 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Copilot workflow edit events (database has been updated, rehydrate stores)
|
||||
socketInstance.on('copilot-workflow-edit', async (data) => {
|
||||
logger.info(
|
||||
`Copilot edited workflow ${data.workflowId} - rehydrating stores from database`
|
||||
)
|
||||
|
||||
try {
|
||||
// Fetch fresh workflow state directly from API
|
||||
const response = await fetch(`/api/workflows/${data.workflowId}`)
|
||||
if (response.ok) {
|
||||
const responseData = await response.json()
|
||||
@@ -408,39 +393,60 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
}
|
||||
})
|
||||
|
||||
// Operation confirmation events
|
||||
socketInstance.on('operation-confirmed', (data) => {
|
||||
logger.debug('Operation confirmed', { operationId: data.operationId })
|
||||
eventHandlers.current.operationConfirmed?.(data)
|
||||
})
|
||||
|
||||
// Operation failure events
|
||||
socketInstance.on('operation-failed', (data) => {
|
||||
logger.warn('Operation failed', { operationId: data.operationId, error: data.error })
|
||||
eventHandlers.current.operationFailed?.(data)
|
||||
})
|
||||
|
||||
// Cursor update events
|
||||
socketInstance.on('cursor-update', (data) => {
|
||||
setPresenceUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.socketId === data.socketId ? { ...user, cursor: data.cursor } : user
|
||||
)
|
||||
)
|
||||
setPresenceUsers((prev) => {
|
||||
const existingIndex = prev.findIndex((user) => user.socketId === data.socketId)
|
||||
if (existingIndex !== -1) {
|
||||
return prev.map((user) =>
|
||||
user.socketId === data.socketId ? { ...user, cursor: data.cursor } : user
|
||||
)
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
socketId: data.socketId,
|
||||
userId: data.userId,
|
||||
userName: data.userName,
|
||||
avatarUrl: data.avatarUrl,
|
||||
cursor: data.cursor,
|
||||
},
|
||||
]
|
||||
})
|
||||
eventHandlers.current.cursorUpdate?.(data)
|
||||
})
|
||||
|
||||
// Selection update events
|
||||
socketInstance.on('selection-update', (data) => {
|
||||
setPresenceUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.socketId === data.socketId ? { ...user, selection: data.selection } : user
|
||||
)
|
||||
)
|
||||
setPresenceUsers((prev) => {
|
||||
const existingIndex = prev.findIndex((user) => user.socketId === data.socketId)
|
||||
if (existingIndex !== -1) {
|
||||
return prev.map((user) =>
|
||||
user.socketId === data.socketId ? { ...user, selection: data.selection } : user
|
||||
)
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
socketId: data.socketId,
|
||||
userId: data.userId,
|
||||
userName: data.userName,
|
||||
avatarUrl: data.avatarUrl,
|
||||
selection: data.selection,
|
||||
},
|
||||
]
|
||||
})
|
||||
eventHandlers.current.selectionUpdate?.(data)
|
||||
})
|
||||
|
||||
// Enhanced error handling for new server events
|
||||
socketInstance.on('error', (error) => {
|
||||
logger.error('Socket error:', error)
|
||||
})
|
||||
@@ -451,7 +457,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
|
||||
socketInstance.on('operation-forbidden', (error) => {
|
||||
logger.warn('Operation forbidden:', error)
|
||||
// Could show a toast notification to user
|
||||
})
|
||||
|
||||
socketInstance.on('operation-confirmed', (data) => {
|
||||
@@ -477,10 +482,8 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Start the socket initialization
|
||||
initializeSocket()
|
||||
|
||||
// Cleanup on unmount only (not on user change since socket is session-level)
|
||||
return () => {
|
||||
positionUpdateTimeouts.current.forEach((timeoutId) => {
|
||||
clearTimeout(timeoutId)
|
||||
@@ -490,24 +493,20 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
}
|
||||
}, [user?.id])
|
||||
|
||||
// Handle workflow room switching when URL changes (for navigation between workflows)
|
||||
useEffect(() => {
|
||||
if (!socket || !isConnected || !urlWorkflowId) return
|
||||
|
||||
// If we're already in the correct workflow room, no need to switch
|
||||
if (currentWorkflowId === urlWorkflowId) return
|
||||
|
||||
logger.info(
|
||||
`URL workflow changed from ${currentWorkflowId} to ${urlWorkflowId}, switching rooms`
|
||||
)
|
||||
|
||||
// Leave current workflow first if we're in one
|
||||
if (currentWorkflowId) {
|
||||
logger.info(`Leaving current workflow ${currentWorkflowId} before joining ${urlWorkflowId}`)
|
||||
socket.emit('leave-workflow')
|
||||
}
|
||||
|
||||
// Join the new workflow room
|
||||
logger.info(`Joining workflow room: ${urlWorkflowId}`)
|
||||
socket.emit('join-workflow', {
|
||||
workflowId: urlWorkflowId,
|
||||
@@ -515,7 +514,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
setCurrentWorkflowId(urlWorkflowId)
|
||||
}, [socket, isConnected, urlWorkflowId, currentWorkflowId])
|
||||
|
||||
// Cleanup socket on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (socket) {
|
||||
@@ -525,7 +523,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Join workflow room
|
||||
const joinWorkflow = useCallback(
|
||||
(workflowId: string) => {
|
||||
if (!socket || !user?.id) {
|
||||
@@ -533,13 +530,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent duplicate joins to the same workflow
|
||||
if (currentWorkflowId === workflowId) {
|
||||
logger.info(`Already in workflow ${workflowId}, skipping join`)
|
||||
return
|
||||
}
|
||||
|
||||
// Leave current workflow first if we're in one
|
||||
if (currentWorkflowId) {
|
||||
logger.info(`Leaving current workflow ${currentWorkflowId} before joining ${workflowId}`)
|
||||
socket.emit('leave-workflow')
|
||||
@@ -547,14 +542,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
|
||||
logger.info(`Joining workflow: ${workflowId}`)
|
||||
socket.emit('join-workflow', {
|
||||
workflowId, // Server gets user info from authenticated session
|
||||
workflowId,
|
||||
})
|
||||
setCurrentWorkflowId(workflowId)
|
||||
},
|
||||
[socket, user, currentWorkflowId]
|
||||
)
|
||||
|
||||
// Leave current workflow room
|
||||
const leaveWorkflow = useCallback(() => {
|
||||
if (socket && currentWorkflowId) {
|
||||
logger.info(`Leaving workflow: ${currentWorkflowId}`)
|
||||
@@ -566,7 +560,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
setCurrentWorkflowId(null)
|
||||
setPresenceUsers([])
|
||||
|
||||
// Clean up any pending position updates
|
||||
positionUpdateTimeouts.current.forEach((timeoutId) => {
|
||||
clearTimeout(timeoutId)
|
||||
})
|
||||
@@ -575,18 +568,15 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
}
|
||||
}, [socket, currentWorkflowId])
|
||||
|
||||
// Light throttling for position updates to ensure smooth collaborative movement
|
||||
const positionUpdateTimeouts = useRef<Map<string, number>>(new Map())
|
||||
const pendingPositionUpdates = useRef<Map<string, any>>(new Map())
|
||||
|
||||
// Emit workflow operations (blocks, edges, subflows)
|
||||
const emitWorkflowOperation = useCallback(
|
||||
(operation: string, target: string, payload: any, operationId?: string) => {
|
||||
if (!socket || !currentWorkflowId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply light throttling only to position updates for smooth collaborative experience
|
||||
const isPositionUpdate = operation === 'update-position' && target === 'block'
|
||||
const { commit = true } = payload || {}
|
||||
|
||||
@@ -631,30 +621,27 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
positionUpdateTimeouts.current.set(blockId, timeoutId)
|
||||
}
|
||||
} else {
|
||||
// For all non-position updates, emit immediately
|
||||
socket.emit('workflow-operation', {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
operationId, // Include operation ID for queue tracking
|
||||
operationId,
|
||||
})
|
||||
}
|
||||
},
|
||||
[socket, currentWorkflowId]
|
||||
)
|
||||
|
||||
// Emit subblock value updates
|
||||
const emitSubblockUpdate = useCallback(
|
||||
(blockId: string, subblockId: string, value: any, operationId?: string) => {
|
||||
// Only emit if socket is connected and we're in a valid workflow room
|
||||
if (socket && currentWorkflowId) {
|
||||
socket.emit('subblock-update', {
|
||||
blockId,
|
||||
subblockId,
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
operationId, // Include operation ID for queue tracking
|
||||
operationId,
|
||||
})
|
||||
} else {
|
||||
logger.warn('Cannot emit subblock update: no socket connection or workflow room', {
|
||||
@@ -668,17 +655,15 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
[socket, currentWorkflowId]
|
||||
)
|
||||
|
||||
// Emit variable value updates
|
||||
const emitVariableUpdate = useCallback(
|
||||
(variableId: string, field: string, value: any, operationId?: string) => {
|
||||
// Only emit if socket is connected and we're in a valid workflow room
|
||||
if (socket && currentWorkflowId) {
|
||||
socket.emit('variable-update', {
|
||||
variableId,
|
||||
field,
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
operationId, // Include operation ID for queue tracking
|
||||
operationId,
|
||||
})
|
||||
} else {
|
||||
logger.warn('Cannot emit variable update: no socket connection or workflow room', {
|
||||
@@ -692,7 +677,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
[socket, currentWorkflowId]
|
||||
)
|
||||
|
||||
// Cursor throttling optimized for database connection health
|
||||
const lastCursorEmit = useRef(0)
|
||||
const emitCursorUpdate = useCallback(
|
||||
(cursor: { x: number; y: number } | null) => {
|
||||
@@ -708,7 +692,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reduced to 30fps (33ms) to reduce database load while maintaining smooth UX
|
||||
if (now - lastCursorEmit.current >= 33) {
|
||||
socket.emit('cursor-update', { cursor })
|
||||
lastCursorEmit.current = now
|
||||
@@ -717,7 +700,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
[socket, currentWorkflowId]
|
||||
)
|
||||
|
||||
// Emit selection updates
|
||||
const emitSelectionUpdate = useCallback(
|
||||
(selection: { type: 'block' | 'edge' | 'none'; id?: string }) => {
|
||||
if (socket && currentWorkflowId) {
|
||||
@@ -727,7 +709,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
[socket, currentWorkflowId]
|
||||
)
|
||||
|
||||
// Event handler registration functions
|
||||
const onWorkflowOperation = useCallback((handler: (data: any) => void) => {
|
||||
eventHandlers.current.workflowOperation = handler
|
||||
}, [])
|
||||
@@ -779,6 +760,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
currentWorkflowId,
|
||||
currentSocketId,
|
||||
presenceUsers,
|
||||
joinWorkflow,
|
||||
leaveWorkflow,
|
||||
|
||||
@@ -83,9 +83,44 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'messages',
|
||||
// title: 'Messages',
|
||||
title: 'Messages',
|
||||
type: 'messages-input',
|
||||
placeholder: 'Enter messages...',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
prompt: `You are an expert at creating professional, comprehensive LLM agent configurations. Generate or modify a JSON array of messages based on the user's request.
|
||||
|
||||
Current messages: {context}
|
||||
|
||||
RULES:
|
||||
1. Generate ONLY a valid JSON array - no markdown, no explanations
|
||||
2. Each message object must have "role" (system/user/assistant) and "content" (string)
|
||||
3. You can generate any number of messages as needed
|
||||
4. Content can be as long as necessary - don't truncate
|
||||
5. If editing existing messages, preserve structure unless asked to change it
|
||||
6. For new agents, create DETAILED, PROFESSIONAL system prompts that include:
|
||||
- A clear role definition starting with "You are..."
|
||||
- Specific methodology or approach guidelines
|
||||
- Structured output format requirements
|
||||
- Critical thinking or quality guidelines
|
||||
- How to handle edge cases and uncertainty
|
||||
|
||||
EXAMPLES:
|
||||
|
||||
Research agent:
|
||||
[{"role": "system", "content": "You are a Research Specialist who synthesizes information into actionable insights.\\n\\n## Approach\\n- Identify key concepts, historical context, and future implications\\n- Distinguish between: established facts, emerging research, contested positions, and your inferences\\n- Attribute claims clearly (e.g., \\"Research suggests...\\", \\"Industry consensus holds...\\")\\n\\n## Response Structure\\n1. **Executive Summary**: 2-3 sentences on key findings\\n2. **Key Findings**: Numbered insights with supporting context\\n3. **Analysis**: Organized by themes—background, current state, perspectives, implications\\n4. **Limitations**: What's uncertain or incomplete\\n5. **Recommendations**: Actionable next steps\\n\\n## Standards\\n- Present multiple viewpoints; avoid single narratives as definitive\\n- Make assumptions explicit; question conventional wisdom\\n- Assess evidence strength; distinguish correlation from causation\\n- Use hedging language (\\"likely\\", \\"suggests\\") over false certainty\\n- Acknowledge knowledge limits directly"}, {"role": "user", "content": ""}]
|
||||
|
||||
Code reviewer:
|
||||
[{"role": "system", "content": "You are a Senior Code Reviewer with expertise in software architecture, security, and best practices. Your role is to provide thorough, constructive code reviews that improve code quality and help developers grow.\\n\\n## Review Methodology\\n\\n1. **Security First**: Check for vulnerabilities including injection attacks, authentication flaws, data exposure, and insecure dependencies.\\n\\n2. **Code Quality**: Evaluate readability, maintainability, adherence to DRY/SOLID principles, and appropriate abstraction levels.\\n\\n3. **Performance**: Identify potential bottlenecks, unnecessary computations, memory leaks, and optimization opportunities.\\n\\n4. **Testing**: Assess test coverage, edge case handling, and testability of the code structure.\\n\\n## Output Format\\n\\n### Summary\\nBrief overview of the code's purpose and overall assessment.\\n\\n### Critical Issues\\nSecurity vulnerabilities or bugs that must be fixed before merging.\\n\\n### Improvements\\nSuggested enhancements with clear explanations of why and how.\\n\\n### Positive Aspects\\nHighlight well-written code to reinforce good practices.\\n\\nBe specific with line references. Provide code examples for suggested changes. Balance critique with encouragement."}, {"role": "user", "content": "<start.input>"}]
|
||||
|
||||
Writing assistant:
|
||||
[{"role": "system", "content": "You are a skilled Writing Editor and Coach. Your role is to help users improve their writing through constructive feedback, editing suggestions, and guidance on style, clarity, and structure.\\n\\n## Editing Approach\\n\\n1. **Clarity**: Ensure ideas are expressed clearly and concisely. Eliminate jargon unless appropriate for the audience.\\n\\n2. **Structure**: Evaluate logical flow, paragraph organization, and transitions between ideas.\\n\\n3. **Voice & Tone**: Maintain consistency and appropriateness for the intended audience and purpose.\\n\\n4. **Grammar & Style**: Correct errors while respecting the author's voice.\\n\\n## Output Format\\n\\n### Overall Impression\\nBrief assessment of the piece's strengths and areas for improvement.\\n\\n### Structural Feedback\\nComments on organization, flow, and logical progression.\\n\\n### Line-Level Edits\\nSpecific suggestions with explanations, not just corrections.\\n\\n### Revised Version\\nWhen appropriate, provide an edited version demonstrating improvements.\\n\\nBe encouraging while honest. Explain the reasoning behind suggestions to help the writer improve."}, {"role": "user", "content": "<start.input>"}]
|
||||
|
||||
Return ONLY the JSON array.`,
|
||||
placeholder: 'Describe what you want to create or change...',
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
|
||||
@@ -417,7 +417,10 @@ Return ONLY the comment text - no explanations.`,
|
||||
title: 'Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter name',
|
||||
required: true,
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_label', 'linear_create_project', 'linear_create_workflow_state'],
|
||||
},
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
@@ -550,6 +553,10 @@ Return ONLY the search query - no explanations.`,
|
||||
title: 'Start Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_cycle'],
|
||||
},
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_cycle', 'linear_create_project'],
|
||||
@@ -573,6 +580,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
title: 'End Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_cycle'],
|
||||
@@ -1407,13 +1415,10 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
// Operation-specific param mapping
|
||||
switch (params.operation) {
|
||||
case 'linear_read_issues':
|
||||
if (!effectiveTeamId || !effectiveProjectId) {
|
||||
throw new Error('Team ID and Project ID are required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
teamId: effectiveTeamId,
|
||||
projectId: effectiveProjectId,
|
||||
teamId: effectiveTeamId || undefined,
|
||||
projectId: effectiveProjectId || undefined,
|
||||
includeArchived: params.includeArchived,
|
||||
}
|
||||
|
||||
@@ -1427,8 +1432,8 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
}
|
||||
|
||||
case 'linear_create_issue':
|
||||
if (!effectiveTeamId || !effectiveProjectId) {
|
||||
throw new Error('Team ID and Project ID are required.')
|
||||
if (!effectiveTeamId) {
|
||||
throw new Error('Team ID is required.')
|
||||
}
|
||||
if (!params.title?.trim()) {
|
||||
throw new Error('Title is required.')
|
||||
@@ -1436,7 +1441,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
return {
|
||||
...baseParams,
|
||||
teamId: effectiveTeamId,
|
||||
projectId: effectiveProjectId,
|
||||
projectId: effectiveProjectId || undefined,
|
||||
title: params.title.trim(),
|
||||
description: params.description,
|
||||
stateId: params.stateId,
|
||||
@@ -1504,13 +1509,13 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
}
|
||||
|
||||
case 'linear_update_comment':
|
||||
if (!params.commentId?.trim() || !params.body?.trim()) {
|
||||
throw new Error('Comment ID and body are required.')
|
||||
if (!params.commentId?.trim()) {
|
||||
throw new Error('Comment ID is required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
commentId: params.commentId.trim(),
|
||||
body: params.body.trim(),
|
||||
body: params.body?.trim() || undefined,
|
||||
}
|
||||
|
||||
case 'linear_delete_comment':
|
||||
@@ -1637,15 +1642,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
if (!effectiveTeamId || !params.name?.trim() || !params.workflowType) {
|
||||
throw new Error('Team ID, name, and workflow type are required.')
|
||||
}
|
||||
if (!params.color?.trim()) {
|
||||
throw new Error('Color is required for workflow state creation.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
teamId: effectiveTeamId,
|
||||
name: params.name.trim(),
|
||||
type: params.workflowType,
|
||||
color: params.color.trim(),
|
||||
color: params.color?.trim() || undefined,
|
||||
}
|
||||
|
||||
case 'linear_update_workflow_state':
|
||||
@@ -1675,15 +1677,15 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
}
|
||||
|
||||
case 'linear_create_cycle':
|
||||
if (!effectiveTeamId || !params.name?.trim()) {
|
||||
throw new Error('Team ID and cycle name are required.')
|
||||
if (!effectiveTeamId || !params.startDate?.trim() || !params.endDate?.trim()) {
|
||||
throw new Error('Team ID, start date, and end date are required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
teamId: effectiveTeamId,
|
||||
name: params.name.trim(),
|
||||
startsAt: params.startDate,
|
||||
endsAt: params.endDate,
|
||||
name: params.name?.trim() || undefined,
|
||||
startsAt: params.startDate.trim(),
|
||||
endsAt: params.endDate.trim(),
|
||||
}
|
||||
|
||||
case 'linear_get_active_cycle':
|
||||
|
||||
143
apps/sim/blocks/blocks/pulse.ts
Normal file
143
apps/sim/blocks/blocks/pulse.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { PulseIcon } from '@/components/icons'
|
||||
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
|
||||
import type { PulseParserOutput } from '@/tools/pulse/types'
|
||||
|
||||
export const PulseBlock: BlockConfig<PulseParserOutput> = {
|
||||
type: 'pulse',
|
||||
name: 'Pulse',
|
||||
description: 'Extract text from documents using Pulse OCR',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
'Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via URL or upload.',
|
||||
docsLink: 'https://docs.sim.ai/tools/pulse',
|
||||
category: 'tools',
|
||||
bgColor: '#E0E0E0',
|
||||
icon: PulseIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'inputMethod',
|
||||
title: 'Select Input Method',
|
||||
type: 'dropdown' as SubBlockType,
|
||||
options: [
|
||||
{ id: 'url', label: 'Document URL' },
|
||||
{ id: 'upload', label: 'Upload Document' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'filePath',
|
||||
title: 'Document URL',
|
||||
type: 'short-input' as SubBlockType,
|
||||
placeholder: 'Enter full URL to a document (https://example.com/document.pdf)',
|
||||
condition: {
|
||||
field: 'inputMethod',
|
||||
value: 'url',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fileUpload',
|
||||
title: 'Upload Document',
|
||||
type: 'file-upload' as SubBlockType,
|
||||
acceptedTypes: 'application/pdf,image/*,.docx,.pptx,.xlsx',
|
||||
condition: {
|
||||
field: 'inputMethod',
|
||||
value: 'upload',
|
||||
},
|
||||
maxSize: 50,
|
||||
},
|
||||
{
|
||||
id: 'pages',
|
||||
title: 'Specific Pages',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. 1-3,5 (leave empty for all pages)',
|
||||
},
|
||||
{
|
||||
id: 'chunking',
|
||||
title: 'Chunking Strategy',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. semantic,header,page,recursive',
|
||||
},
|
||||
{
|
||||
id: 'chunkSize',
|
||||
title: 'Chunk Size',
|
||||
type: 'short-input',
|
||||
placeholder: 'Max characters per chunk',
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input' as SubBlockType,
|
||||
placeholder: 'Enter your Pulse API key',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['pulse_parser'],
|
||||
config: {
|
||||
tool: () => 'pulse_parser',
|
||||
params: (params) => {
|
||||
if (!params || !params.apiKey || params.apiKey.trim() === '') {
|
||||
throw new Error('Pulse API key is required')
|
||||
}
|
||||
|
||||
const parameters: Record<string, unknown> = {
|
||||
apiKey: params.apiKey.trim(),
|
||||
}
|
||||
|
||||
const inputMethod = params.inputMethod || 'url'
|
||||
if (inputMethod === 'url') {
|
||||
if (!params.filePath || params.filePath.trim() === '') {
|
||||
throw new Error('Document URL is required')
|
||||
}
|
||||
parameters.filePath = params.filePath.trim()
|
||||
} else if (inputMethod === 'upload') {
|
||||
if (!params.fileUpload) {
|
||||
throw new Error('Please upload a document')
|
||||
}
|
||||
parameters.fileUpload = params.fileUpload
|
||||
}
|
||||
|
||||
if (params.pages && params.pages.trim() !== '') {
|
||||
parameters.pages = params.pages.trim()
|
||||
}
|
||||
|
||||
if (params.chunking && params.chunking.trim() !== '') {
|
||||
parameters.chunking = params.chunking.trim()
|
||||
}
|
||||
|
||||
if (params.chunkSize && params.chunkSize.trim() !== '') {
|
||||
const size = Number.parseInt(params.chunkSize.trim(), 10)
|
||||
if (!Number.isNaN(size) && size > 0) {
|
||||
parameters.chunkSize = size
|
||||
}
|
||||
}
|
||||
|
||||
return parameters
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
inputMethod: { type: 'string', description: 'Input method selection' },
|
||||
filePath: { type: 'string', description: 'Document URL' },
|
||||
fileUpload: { type: 'json', description: 'Uploaded document file' },
|
||||
apiKey: { type: 'string', description: 'Pulse API key' },
|
||||
pages: { type: 'string', description: 'Page range selection' },
|
||||
chunking: {
|
||||
type: 'string',
|
||||
description: 'Chunking strategies (semantic, header, page, recursive)',
|
||||
},
|
||||
chunkSize: { type: 'string', description: 'Maximum characters per chunk' },
|
||||
},
|
||||
outputs: {
|
||||
markdown: { type: 'string', description: 'Extracted content in markdown format' },
|
||||
page_count: { type: 'number', description: 'Number of pages in the document' },
|
||||
job_id: { type: 'string', description: 'Unique job identifier' },
|
||||
'plan-info': { type: 'json', description: 'Plan usage information' },
|
||||
bounding_boxes: { type: 'json', description: 'Bounding box layout information' },
|
||||
extraction_url: { type: 'string', description: 'URL for extraction results (large documents)' },
|
||||
html: { type: 'string', description: 'HTML content if requested' },
|
||||
structured_output: { type: 'json', description: 'Structured output if schema was provided' },
|
||||
chunks: { type: 'json', description: 'Chunked content if chunking was enabled' },
|
||||
figures: { type: 'json', description: 'Extracted figures if figure extraction was enabled' },
|
||||
},
|
||||
}
|
||||
148
apps/sim/blocks/blocks/reducto.ts
Normal file
148
apps/sim/blocks/blocks/reducto.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { ReductoIcon } from '@/components/icons'
|
||||
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
|
||||
import type { ReductoParserOutput } from '@/tools/reducto/types'
|
||||
|
||||
export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
|
||||
type: 'reducto',
|
||||
name: 'Reducto',
|
||||
description: 'Extract text from PDF documents',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription: `Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL.`,
|
||||
docsLink: 'https://docs.sim.ai/tools/reducto',
|
||||
category: 'tools',
|
||||
bgColor: '#5c0c5c',
|
||||
icon: ReductoIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'inputMethod',
|
||||
title: 'Select Input Method',
|
||||
type: 'dropdown' as SubBlockType,
|
||||
options: [
|
||||
{ id: 'url', label: 'PDF Document URL' },
|
||||
{ id: 'upload', label: 'Upload PDF Document' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'filePath',
|
||||
title: 'PDF Document URL',
|
||||
type: 'short-input' as SubBlockType,
|
||||
placeholder: 'Enter full URL to a PDF document (https://example.com/document.pdf)',
|
||||
condition: {
|
||||
field: 'inputMethod',
|
||||
value: 'url',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fileUpload',
|
||||
title: 'Upload PDF',
|
||||
type: 'file-upload' as SubBlockType,
|
||||
acceptedTypes: 'application/pdf',
|
||||
condition: {
|
||||
field: 'inputMethod',
|
||||
value: 'upload',
|
||||
},
|
||||
maxSize: 50,
|
||||
},
|
||||
{
|
||||
id: 'pages',
|
||||
title: 'Specific Pages',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. 1,2,3 (1-indexed, leave empty for all)',
|
||||
},
|
||||
{
|
||||
id: 'tableOutputFormat',
|
||||
title: 'Table Format',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ id: 'md', label: 'Markdown' },
|
||||
{ id: 'html', label: 'HTML' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input' as SubBlockType,
|
||||
placeholder: 'Enter your Reducto API key',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['reducto_parser'],
|
||||
config: {
|
||||
tool: () => 'reducto_parser',
|
||||
params: (params) => {
|
||||
if (!params || !params.apiKey || params.apiKey.trim() === '') {
|
||||
throw new Error('Reducto API key is required')
|
||||
}
|
||||
|
||||
const parameters: Record<string, unknown> = {
|
||||
apiKey: params.apiKey.trim(),
|
||||
}
|
||||
|
||||
const inputMethod = params.inputMethod || 'url'
|
||||
if (inputMethod === 'url') {
|
||||
if (!params.filePath || params.filePath.trim() === '') {
|
||||
throw new Error('PDF Document URL is required')
|
||||
}
|
||||
parameters.filePath = params.filePath.trim()
|
||||
} else if (inputMethod === 'upload') {
|
||||
if (!params.fileUpload) {
|
||||
throw new Error('Please upload a PDF document')
|
||||
}
|
||||
parameters.fileUpload = params.fileUpload
|
||||
}
|
||||
|
||||
let pagesArray: number[] | undefined
|
||||
if (params.pages && params.pages.trim() !== '') {
|
||||
try {
|
||||
pagesArray = params.pages
|
||||
.split(',')
|
||||
.map((p: string) => p.trim())
|
||||
.filter((p: string) => p.length > 0)
|
||||
.map((p: string) => {
|
||||
const num = Number.parseInt(p, 10)
|
||||
if (Number.isNaN(num) || num < 0) {
|
||||
throw new Error(`Invalid page number: ${p}`)
|
||||
}
|
||||
return num
|
||||
})
|
||||
|
||||
if (pagesArray && pagesArray.length === 0) {
|
||||
pagesArray = undefined
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`Page number format error: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (pagesArray && pagesArray.length > 0) {
|
||||
parameters.pages = pagesArray
|
||||
}
|
||||
|
||||
if (params.tableOutputFormat) {
|
||||
parameters.tableOutputFormat = params.tableOutputFormat
|
||||
}
|
||||
|
||||
return parameters
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
inputMethod: { type: 'string', description: 'Input method selection' },
|
||||
filePath: { type: 'string', description: 'PDF document URL' },
|
||||
fileUpload: { type: 'json', description: 'Uploaded PDF file' },
|
||||
apiKey: { type: 'string', description: 'Reducto API key' },
|
||||
pages: { type: 'string', description: 'Page selection' },
|
||||
tableOutputFormat: { type: 'string', description: 'Table output format' },
|
||||
},
|
||||
outputs: {
|
||||
job_id: { type: 'string', description: 'Unique identifier for the processing job' },
|
||||
duration: { type: 'number', description: 'Processing time in seconds' },
|
||||
usage: { type: 'json', description: 'Resource consumption data (num_pages, credits)' },
|
||||
result: { type: 'json', description: 'Parsed document content with chunks and blocks' },
|
||||
pdf_url: { type: 'string', description: 'Storage URL of converted PDF' },
|
||||
studio_link: { type: 'string', description: 'Link to Reducto studio interface' },
|
||||
},
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export const ResponseBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
id: 'data',
|
||||
title: 'Response Data',
|
||||
type: 'code',
|
||||
placeholder: '{\n "message": "Hello world",\n "userId": "<variable.userId>"\n}',
|
||||
placeholder: '{\n "message": "Hello world"\n}',
|
||||
language: 'json',
|
||||
condition: { field: 'dataMode', value: 'json' },
|
||||
description:
|
||||
|
||||
@@ -93,9 +93,11 @@ import { PipedriveBlock } from '@/blocks/blocks/pipedrive'
|
||||
import { PolymarketBlock } from '@/blocks/blocks/polymarket'
|
||||
import { PostgreSQLBlock } from '@/blocks/blocks/postgresql'
|
||||
import { PostHogBlock } from '@/blocks/blocks/posthog'
|
||||
import { PulseBlock } from '@/blocks/blocks/pulse'
|
||||
import { QdrantBlock } from '@/blocks/blocks/qdrant'
|
||||
import { RDSBlock } from '@/blocks/blocks/rds'
|
||||
import { RedditBlock } from '@/blocks/blocks/reddit'
|
||||
import { ReductoBlock } from '@/blocks/blocks/reducto'
|
||||
import { ResendBlock } from '@/blocks/blocks/resend'
|
||||
import { ResponseBlock } from '@/blocks/blocks/response'
|
||||
import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router'
|
||||
@@ -237,6 +239,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
microsoft_planner: MicrosoftPlannerBlock,
|
||||
microsoft_teams: MicrosoftTeamsBlock,
|
||||
mistral_parse: MistralParseBlock,
|
||||
reducto: ReductoBlock,
|
||||
mongodb: MongoDBBlock,
|
||||
mysql: MySQLBlock,
|
||||
neo4j: Neo4jBlock,
|
||||
@@ -253,6 +256,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
polymarket: PolymarketBlock,
|
||||
postgresql: PostgreSQLBlock,
|
||||
posthog: PostHogBlock,
|
||||
pulse: PulseBlock,
|
||||
qdrant: QdrantBlock,
|
||||
rds: RDSBlock,
|
||||
sqs: SQSBlock,
|
||||
|
||||
@@ -56,8 +56,6 @@ interface CodeContainerProps {
|
||||
className?: string
|
||||
/** Inline styles for the container */
|
||||
style?: React.CSSProperties
|
||||
/** Whether editor is in streaming/AI generation state */
|
||||
isStreaming?: boolean
|
||||
/** Drag and drop handler */
|
||||
onDragOver?: (e: React.DragEvent) => void
|
||||
/** Drop handler */
|
||||
@@ -77,14 +75,7 @@ interface CodeContainerProps {
|
||||
* </Code.Container>
|
||||
* ```
|
||||
*/
|
||||
function Container({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
isStreaming = false,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}: CodeContainerProps) {
|
||||
function Container({ children, className, style, onDragOver, onDrop }: CodeContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -94,8 +85,6 @@ function Container({
|
||||
'dark:bg-[#1F1F1F]',
|
||||
// Overflow handling for long content
|
||||
'overflow-x-auto overflow-y-auto',
|
||||
// Streaming state
|
||||
isStreaming && 'streaming-effect',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
|
||||
@@ -4678,3 +4678,349 @@ export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReductoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='400'
|
||||
height='400'
|
||||
viewBox='50 40 300 320'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M85.3434 70.7805H314.657V240.307L226.44 329.219H85.3434V70.7805ZM107.796 93.2319H292.205V204.487H206.493V306.767H107.801L107.796 93.2319Z'
|
||||
fill='#FFFFFF'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PulseIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 6 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M0 6.63667C0 6.28505 0.284685 6 0.635863 6H1.54133C1.89251 6 2.17719 6.28505 2.17719 6.63667V7.54329C2.17719 7.89492 1.89251 8.17997 1.54133 8.17997H0.635863C0.284686 8.17997 0 7.89492 0 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 6.63667C3.11318 6.28505 3.39787 6 3.74905 6H4.65452C5.00569 6 5.29038 6.28505 5.29038 6.63667V7.54329C5.29038 7.89492 5.00569 8.17997 4.65452 8.17997H3.74905C3.39787 8.17997 3.11318 7.89492 3.11318 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 6.63667C6.22637 6.28505 6.51105 6 6.86223 6H7.7677C8.11888 6 8.40356 6.28505 8.40356 6.63667V7.54329C8.40356 7.89492 8.11888 8.17997 7.7677 8.17997H6.86223C6.51105 8.17997 6.22637 7.89492 6.22637 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 6.63667C9.33955 6.28505 9.62424 6 9.97541 6H10.8809C11.2321 6 11.5167 6.28505 11.5167 6.63667V7.54329C11.5167 7.89492 11.2321 8.17997 10.8809 8.17997H9.97541C9.62424 8.17997 9.33955 7.89492 9.33955 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 6.63667C12.4527 6.28505 12.7374 6 13.0886 6H13.9941C14.3452 6 14.6299 6.28505 14.6299 6.63667V7.54329C14.6299 7.89492 14.3452 8.17997 13.9941 8.17997H13.0886C12.7374 8.17997 12.4527 7.89492 12.4527 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 6.63667C15.5659 6.28505 15.8506 6 16.2018 6H17.1073C17.4584 6 17.7431 6.28505 17.7431 6.63667V7.54329C17.7431 7.89492 17.4584 8.17997 17.1073 8.17997H16.2018C15.8506 8.17997 15.5659 7.89492 15.5659 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 6.63667C18.6791 6.28505 18.9638 6 19.315 6H20.2204C20.5716 6 20.8563 6.28505 20.8563 6.63667V7.54329C20.8563 7.89492 20.5716 8.17997 20.2204 8.17997H19.315C18.9638 8.17997 18.6791 7.89492 18.6791 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 6.63667C21.7923 6.28505 22.077 6 22.4282 6H23.3336C23.6848 6 23.9695 6.28505 23.9695 6.63667V7.54329C23.9695 7.89492 23.6848 8.17997 23.3336 8.17997H22.4282C22.077 8.17997 21.7923 7.89492 21.7923 7.54329V6.63667Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 9.75382C0 9.4022 0.284685 9.11715 0.635863 9.11715H1.54133C1.89251 9.11715 2.17719 9.4022 2.17719 9.75382V10.6604C2.17719 11.0121 1.89251 11.2971 1.54133 11.2971H0.635863C0.284686 11.2971 0 11.0121 0 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 9.75382C3.11318 9.4022 3.39787 9.11715 3.74905 9.11715H4.65452C5.00569 9.11715 5.29038 9.4022 5.29038 9.75382V10.6604C5.29038 11.0121 5.00569 11.2971 4.65452 11.2971H3.74905C3.39787 11.2971 3.11318 11.0121 3.11318 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 9.75382C6.22637 9.4022 6.51105 9.11715 6.86223 9.11715H7.7677C8.11888 9.11715 8.40356 9.4022 8.40356 9.75382V10.6604C8.40356 11.0121 8.11888 11.2971 7.7677 11.2971H6.86223C6.51105 11.2971 6.22637 11.0121 6.22637 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 9.75382C9.33955 9.4022 9.62424 9.11715 9.97541 9.11715H10.8809C11.2321 9.11715 11.5167 9.4022 11.5167 9.75382V10.6604C11.5167 11.0121 11.2321 11.2971 10.8809 11.2971H9.97541C9.62424 11.2971 9.33955 11.0121 9.33955 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 9.75382C12.4527 9.4022 12.7374 9.11715 13.0886 9.11715H13.9941C14.3452 9.11715 14.6299 9.4022 14.6299 9.75382V10.6604C14.6299 11.0121 14.3452 11.2971 13.9941 11.2971H13.0886C12.7374 11.2971 12.4527 11.0121 12.4527 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 9.75382C15.5659 9.4022 15.8506 9.11715 16.2018 9.11715H17.1073C17.4584 9.11715 17.7431 9.4022 17.7431 9.75382V10.6604C17.7431 11.0121 17.4584 11.2971 17.1073 11.2971H16.2018C15.8506 11.2971 15.5659 11.0121 15.5659 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 9.75382C18.6791 9.4022 18.9638 9.11715 19.315 9.11715H20.2204C20.5716 9.11715 20.8563 9.4022 20.8563 9.75382V10.6604C20.8563 11.0121 20.5716 11.2971 20.2204 11.2971H19.315C18.9638 11.2971 18.6791 11.0121 18.6791 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 9.75382C21.7923 9.4022 22.077 9.11715 22.4282 9.11715H23.3336C23.6848 9.11715 23.9695 9.4022 23.9695 9.75382V10.6604C23.9695 11.0121 23.6848 11.2971 23.3336 11.2971H22.4282C22.077 11.2971 21.7923 11.0121 21.7923 10.6604V9.75382Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 12.871C0 12.5193 0.284685 12.2343 0.635863 12.2343H1.54133C1.89251 12.2343 2.17719 12.5193 2.17719 12.871V13.7776C2.17719 14.1292 1.89251 14.4143 1.54133 14.4143H0.635863C0.284686 14.4143 0 14.1292 0 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 12.871C3.11318 12.5193 3.39787 12.2343 3.74905 12.2343H4.65452C5.00569 12.2343 5.29038 12.5193 5.29038 12.871V13.7776C5.29038 14.1292 5.00569 14.4143 4.65452 14.4143H3.74905C3.39787 14.4143 3.11318 14.1292 3.11318 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 12.871C6.22637 12.5193 6.51105 12.2343 6.86223 12.2343H7.7677C8.11888 12.2343 8.40356 12.5193 8.40356 12.871V13.7776C8.40356 14.1292 8.11888 14.4143 7.7677 14.4143H6.86223C6.51105 14.4143 6.22637 14.1292 6.22637 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 12.871C9.33955 12.5193 9.62424 12.2343 9.97541 12.2343H10.8809C11.2321 12.2343 11.5167 12.5193 11.5167 12.871V13.7776C11.5167 14.1292 11.2321 14.4143 10.8809 14.4143H9.97541C9.62424 14.4143 9.33955 14.1292 9.33955 13.7776V12.871Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 12.871C12.4527 12.5193 12.7374 12.2343 13.0886 12.2343H13.9941C14.3452 12.2343 14.6299 12.5193 14.6299 12.871V13.7776C14.6299 14.1292 14.3452 14.4143 13.9941 14.4143H13.0886C12.7374 14.4143 12.4527 14.1292 12.4527 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 12.871C15.5659 12.5193 15.8506 12.2343 16.2018 12.2343H17.1073C17.4584 12.2343 17.7431 12.5193 17.7431 12.871V13.7776C17.7431 14.1292 17.4584 14.4143 17.1073 14.4143H16.2018C15.8506 14.4143 15.5659 14.1292 15.5659 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 12.871C18.6791 12.5193 18.9638 12.2343 19.315 12.2343H20.2204C20.5716 12.2343 20.8563 12.5193 20.8563 12.871V13.7776C20.8563 14.1292 20.5716 14.4143 20.2204 14.4143H19.315C18.9638 14.4143 18.6791 14.1292 18.6791 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 12.871C21.7923 12.5193 22.077 12.2343 22.4282 12.2343H23.3336C23.6848 12.2343 23.9695 12.5193 23.9695 12.871V13.7776C23.9695 14.1292 23.6848 14.4143 23.3336 14.4143H22.4282C22.077 14.4143 21.7923 14.1292 21.7923 13.7776V12.871Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 15.9881C0 15.6365 0.284685 15.3514 0.635863 15.3514H1.54133C1.89251 15.3514 2.17719 15.6365 2.17719 15.9881V16.8947C2.17719 17.2464 1.89251 17.5314 1.54133 17.5314H0.635863C0.284686 17.5314 0 17.2464 0 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 15.9881C3.11318 15.6365 3.39787 15.3514 3.74905 15.3514H4.65452C5.00569 15.3514 5.29038 15.6365 5.29038 15.9881V16.8947C5.29038 17.2464 5.00569 17.5314 4.65452 17.5314H3.74905C3.39787 17.5314 3.11318 17.2464 3.11318 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 15.9881C6.22637 15.6365 6.51105 15.3514 6.86223 15.3514H7.7677C8.11888 15.3514 8.40356 15.6365 8.40356 15.9881V16.8947C8.40356 17.2464 8.11888 17.5314 7.7677 17.5314H6.86223C6.51105 17.5314 6.22637 17.2464 6.22637 16.8947V15.9881Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 15.9881C9.33955 15.6365 9.62424 15.3514 9.97541 15.3514H10.8809C11.2321 15.3514 11.5167 15.6365 11.5167 15.9881V16.8947C11.5167 17.2464 11.2321 17.5314 10.8809 17.5314H9.97541C9.62424 17.5314 9.33955 17.2464 9.33955 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 15.9881C12.4527 15.6365 12.7374 15.3514 13.0886 15.3514H13.9941C14.3452 15.3514 14.6299 15.6365 14.6299 15.9881V16.8947C14.6299 17.2464 14.3452 17.5314 13.9941 17.5314H13.0886C12.7374 17.5314 12.4527 17.2464 12.4527 16.8947V15.9881Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 15.9881C15.5659 15.6365 15.8506 15.3514 16.2018 15.3514H17.1073C17.4584 15.3514 17.7431 15.6365 17.7431 15.9881V16.8947C17.7431 17.2464 17.4584 17.5314 17.1073 17.5314H16.2018C15.8506 17.5314 15.5659 17.2464 15.5659 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 15.9881C18.6791 15.6365 18.9638 15.3514 19.315 15.3514H20.2204C20.5716 15.3514 20.8563 15.6365 20.8563 15.9881V16.8947C20.8563 17.2464 20.5716 17.5314 20.2204 17.5314H19.315C18.9638 17.5314 18.6791 17.2464 18.6791 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 15.9881C21.7923 15.6365 22.077 15.3514 22.4282 15.3514H23.3336C23.6848 15.3514 23.9695 15.6365 23.9695 15.9881V16.8947C23.9695 17.2464 23.6848 17.5314 23.3336 17.5314H22.4282C22.077 17.5314 21.7923 17.2464 21.7923 16.8947V15.9881Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 19.1053C0 18.7536 0.284685 18.4686 0.635863 18.4686H1.54133C1.89251 18.4686 2.17719 18.7536 2.17719 19.1053V20.0119C2.17719 20.3635 1.89251 20.6486 1.54133 20.6486H0.635863C0.284686 20.6486 0 20.3635 0 20.0119V19.1053Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 19.1053C3.11318 18.7536 3.39787 18.4686 3.74905 18.4686H4.65452C5.00569 18.4686 5.29038 18.7536 5.29038 19.1053V20.0119C5.29038 20.3635 5.00569 20.6486 4.65452 20.6486H3.74905C3.39787 20.6486 3.11318 20.3635 3.11318 20.0119V19.1053Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 19.1053C6.22637 18.7536 6.51105 18.4686 6.86223 18.4686H7.7677C8.11888 18.4686 8.40356 18.7536 8.40356 19.1053V20.0119C8.40356 20.3635 8.11888 20.6486 7.7677 20.6486H6.86223C6.51105 20.6486 6.22637 20.3635 6.22637 20.0119V19.1053Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 19.1053C9.33955 18.7536 9.62424 18.4686 9.97541 18.4686H10.8809C11.2321 18.4686 11.5167 18.7536 11.5167 19.1053V20.0119C11.5167 20.3635 11.2321 20.6486 10.8809 20.6486H9.97541C9.62424 20.6486 9.33955 20.3635 9.33955 20.0119V19.1053Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 19.1053C12.4527 18.7536 12.7374 18.4686 13.0886 18.4686H13.9941C14.3452 18.4686 14.6299 18.7536 14.6299 19.1053V20.0119C14.6299 20.3635 14.3452 20.6486 13.9941 20.6486H13.0886C12.7374 20.6486 12.4527 20.3635 12.4527 20.0119V19.1053Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 19.1053C15.5659 18.7536 15.8506 18.4686 16.2018 18.4686H17.1073C17.4584 18.4686 17.7431 18.7536 17.7431 19.1053V20.0119C17.7431 20.3635 17.4584 20.6486 17.1073 20.6486H16.2018C15.8506 20.6486 15.5659 20.3635 15.5659 20.0119V19.1053Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 19.1053C18.6791 18.7536 18.9638 18.4686 19.315 18.4686H20.2204C20.5716 18.4686 20.8563 18.7536 20.8563 19.1053V20.0119C20.8563 20.3635 20.5716 20.6486 20.2204 20.6486H19.315C18.9638 20.6486 18.6791 20.3635 18.6791 20.0119V19.1053Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 19.1053C21.7923 18.7536 22.077 18.4686 22.4282 18.4686H23.3336C23.6848 18.4686 23.9695 18.7536 23.9695 19.1053V20.0119C23.9695 20.3635 23.6848 20.6486 23.3336 20.6486H22.4282C22.077 20.6486 21.7923 20.3635 21.7923 20.0119V19.1053Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M0 22.2224C0 21.8708 0.284685 21.5857 0.635863 21.5857H1.54133C1.89251 21.5857 2.17719 21.8708 2.17719 22.2224V23.129C2.17719 23.4807 1.89251 23.7657 1.54133 23.7657H0.635863C0.284686 23.7657 0 23.4807 0 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 22.2224C3.11318 21.8708 3.39787 21.5857 3.74905 21.5857H4.65452C5.00569 21.5857 5.29038 21.8708 5.29038 22.2224V23.129C5.29038 23.4807 5.00569 23.7657 4.65452 23.7657H3.74905C3.39787 23.7657 3.11318 23.4807 3.11318 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 22.2224C6.22637 21.8708 6.51105 21.5857 6.86223 21.5857H7.7677C8.11888 21.5857 8.40356 21.8708 8.40356 22.2224V23.129C8.40356 23.4807 8.11888 23.7657 7.7677 23.7657H6.86223C6.51105 23.7657 6.22637 23.4807 6.22637 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 22.2224C9.33955 21.8708 9.62424 21.5857 9.97541 21.5857H10.8809C11.2321 21.5857 11.5167 21.8708 11.5167 22.2224V23.129C11.5167 23.4807 11.2321 23.7657 10.8809 23.7657H9.97541C9.62424 23.7657 9.33955 23.4807 9.33955 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 22.2224C12.4527 21.8708 12.7374 21.5857 13.0886 21.5857H13.9941C14.3452 21.5857 14.6299 21.8708 14.6299 22.2224V23.129C14.6299 23.4807 14.3452 23.7657 13.9941 23.7657H13.0886C12.7374 23.7657 12.4527 23.4807 12.4527 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 22.2224C15.5659 21.8708 15.8506 21.5857 16.2018 21.5857H17.1073C17.4584 21.5857 17.7431 21.8708 17.7431 22.2224V23.129C17.7431 23.4807 17.4584 23.7657 17.1073 23.7657H16.2018C15.8506 23.7657 15.5659 23.4807 15.5659 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 22.2224C18.6791 21.8708 18.9638 21.5857 19.315 21.5857H20.2204C20.5716 21.5857 20.8563 21.8708 20.8563 22.2224V23.129C20.8563 23.4807 20.5716 23.7657 20.2204 23.7657H19.315C18.9638 23.7657 18.6791 23.4807 18.6791 23.129V22.2224Z'
|
||||
fill='#0E7BC9'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 22.2224C21.7923 21.8708 22.077 21.5857 22.4282 21.5857H23.3336C23.6848 21.5857 23.9695 21.8708 23.9695 22.2224V23.129C23.9695 23.4807 23.6848 23.7657 23.3336 23.7657H22.4282C22.077 23.7657 21.7923 23.4807 21.7923 23.129V22.2224Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 25.3396C0 24.9879 0.284685 24.7029 0.635863 24.7029H1.54133C1.89251 24.7029 2.17719 24.9879 2.17719 25.3396V26.2462C2.17719 26.5978 1.89251 26.8829 1.54133 26.8829H0.635863C0.284686 26.8829 0 26.5978 0 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 25.3396C3.11318 24.9879 3.39787 24.7029 3.74905 24.7029H4.65452C5.00569 24.7029 5.29038 24.9879 5.29038 25.3396V26.2462C5.29038 26.5978 5.00569 26.8829 4.65452 26.8829H3.74905C3.39787 26.8829 3.11318 26.5978 3.11318 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 25.3396C6.22637 24.9879 6.51105 24.7029 6.86223 24.7029H7.7677C8.11888 24.7029 8.40356 24.9879 8.40356 25.3396V26.2462C8.40356 26.5978 8.11888 26.8829 7.7677 26.8829H6.86223C6.51105 26.8829 6.22637 26.5978 6.22637 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 25.3396C9.33955 24.9879 9.62424 24.7029 9.97541 24.7029H10.8809C11.2321 24.7029 11.5167 24.9879 11.5167 25.3396V26.2462C11.5167 26.5978 11.2321 26.8829 10.8809 26.8829H9.97541C9.62424 26.8829 9.33955 26.5978 9.33955 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 25.3396C12.4527 24.9879 12.7374 24.7029 13.0886 24.7029H13.9941C14.3452 24.7029 14.6299 24.9879 14.6299 25.3396V26.2462C14.6299 26.5978 14.3452 26.8829 13.9941 26.8829H13.0886C12.7374 26.8829 12.4527 26.5978 12.4527 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 25.3396C15.5659 24.9879 15.8506 24.7029 16.2018 24.7029H17.1073C17.4584 24.7029 17.7431 24.9879 17.7431 25.3396V26.2462C17.7431 26.5978 17.4584 26.8829 17.1073 26.8829H16.2018C15.8506 26.8829 15.5659 26.5978 15.5659 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 25.3396C18.6791 24.9879 18.9638 24.7029 19.315 24.7029H20.2204C20.5716 24.7029 20.8563 24.9879 20.8563 25.3396V26.2462C20.8563 26.5978 20.5716 26.8829 20.2204 26.8829H19.315C18.9638 26.8829 18.6791 26.5978 18.6791 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 25.3396C21.7923 24.9879 22.077 24.7029 22.4282 24.7029H23.3336C23.6848 24.7029 23.9695 24.9879 23.9695 25.3396V26.2462C23.9695 26.5978 23.6848 26.8829 23.3336 26.8829H22.4282C22.077 26.8829 21.7923 26.5978 21.7923 26.2462V25.3396Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M0 28.4567C0 28.1051 0.284685 27.82 0.635863 27.82H1.54133C1.89251 27.82 2.17719 28.1051 2.17719 28.4567V29.3633C2.17719 29.715 1.89251 30 1.54133 30H0.635863C0.284686 30 0 29.715 0 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M3.11318 28.4567C3.11318 28.1051 3.39787 27.82 3.74905 27.82H4.65452C5.00569 27.82 5.29038 28.1051 5.29038 28.4567V29.3633C5.29038 29.715 5.00569 30 4.65452 30H3.74905C3.39787 30 3.11318 29.715 3.11318 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M6.22637 28.4567C6.22637 28.1051 6.51105 27.82 6.86223 27.82H7.7677C8.11888 27.82 8.40356 28.1051 8.40356 28.4567V29.3633C8.40356 29.715 8.11888 30 7.7677 30H6.86223C6.51105 30 6.22637 29.715 6.22637 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M9.33955 28.4567C9.33955 28.1051 9.62424 27.82 9.97541 27.82H10.8809C11.2321 27.82 11.5167 28.1051 11.5167 28.4567V29.3633C11.5167 29.715 11.2321 30 10.8809 30H9.97541C9.62424 30 9.33955 29.715 9.33955 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M12.4527 28.4567C12.4527 28.1051 12.7374 27.82 13.0886 27.82H13.9941C14.3452 27.82 14.6299 28.1051 14.6299 28.4567V29.3633C14.6299 29.715 14.3452 30 13.9941 30H13.0886C12.7374 30 12.4527 29.715 12.4527 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M15.5659 28.4567C15.5659 28.1051 15.8506 27.82 16.2018 27.82H17.1073C17.4584 27.82 17.7431 28.1051 17.7431 28.4567V29.3633C17.7431 29.715 17.4584 30 17.1073 30H16.2018C15.8506 30 15.5659 29.715 15.5659 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M18.6791 28.4567C18.6791 28.1051 18.9638 27.82 19.315 27.82H20.2204C20.5716 27.82 20.8563 28.1051 20.8563 28.4567V29.3633C20.8563 29.715 20.5716 30 20.2204 30H19.315C18.9638 30 18.6791 29.715 18.6791 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
<path
|
||||
d='M21.7923 28.4567C21.7923 28.1051 22.077 27.82 22.4282 27.82H23.3336C23.6848 27.82 23.9695 28.1051 23.9695 28.4567V29.3633C23.9695 29.715 23.6848 30 23.3336 30H22.4282C22.077 30 21.7923 29.715 21.7923 29.3633V28.4567Z'
|
||||
fill='#030712'
|
||||
fillOpacity='0.1'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,10 @@ export async function validateBlockType(
|
||||
blockType: string,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (blockType === 'start_trigger') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createPinnedUrl,
|
||||
validateAirtableId,
|
||||
validateAlphanumericId,
|
||||
validateEnum,
|
||||
validateExternalUrl,
|
||||
@@ -1112,3 +1113,82 @@ describe('validateGoogleCalendarId', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateAirtableId', () => {
|
||||
describe('valid base IDs (app prefix)', () => {
|
||||
it.concurrent('should accept valid base ID', () => {
|
||||
const result = validateAirtableId('appABCDEFGHIJKLMN', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.sanitized).toBe('appABCDEFGHIJKLMN')
|
||||
})
|
||||
|
||||
it.concurrent('should accept base ID with mixed case', () => {
|
||||
const result = validateAirtableId('appAbCdEfGhIjKlMn', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept base ID with numbers', () => {
|
||||
const result = validateAirtableId('app12345678901234', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valid table IDs (tbl prefix)', () => {
|
||||
it.concurrent('should accept valid table ID', () => {
|
||||
const result = validateAirtableId('tblABCDEFGHIJKLMN', 'tbl', 'tableId')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valid webhook IDs (ach prefix)', () => {
|
||||
it.concurrent('should accept valid webhook ID', () => {
|
||||
const result = validateAirtableId('achABCDEFGHIJKLMN', 'ach', 'webhookId')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid IDs', () => {
|
||||
it.concurrent('should reject null', () => {
|
||||
const result = validateAirtableId(null, 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('required')
|
||||
})
|
||||
|
||||
it.concurrent('should reject empty string', () => {
|
||||
const result = validateAirtableId('', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('required')
|
||||
})
|
||||
|
||||
it.concurrent('should reject wrong prefix', () => {
|
||||
const result = validateAirtableId('tblABCDEFGHIJKLMN', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('starting with "app"')
|
||||
})
|
||||
|
||||
it.concurrent('should reject too short ID (13 chars after prefix)', () => {
|
||||
const result = validateAirtableId('appABCDEFGHIJKLM', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject too long ID (15 chars after prefix)', () => {
|
||||
const result = validateAirtableId('appABCDEFGHIJKLMNO', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject special characters', () => {
|
||||
const result = validateAirtableId('appABCDEFGH/JKLMN', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject path traversal attempts', () => {
|
||||
const result = validateAirtableId('app../etc/passwd', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject lowercase prefix', () => {
|
||||
const result = validateAirtableId('AppABCDEFGHIJKLMN', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -896,6 +896,57 @@ export function createPinnedUrl(originalUrl: string, resolvedIP: string): string
|
||||
return `${parsed.protocol}//${resolvedIP}${port}${parsed.pathname}${parsed.search}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an Airtable ID (base, table, or webhook ID)
|
||||
*
|
||||
* Airtable IDs have specific prefixes:
|
||||
* - Base IDs: "app" + 14 alphanumeric characters (e.g., appXXXXXXXXXXXXXX)
|
||||
* - Table IDs: "tbl" + 14 alphanumeric characters
|
||||
* - Webhook IDs: "ach" + 14 alphanumeric characters
|
||||
*
|
||||
* @param value - The ID to validate
|
||||
* @param expectedPrefix - The expected prefix ('app', 'tbl', or 'ach')
|
||||
* @param paramName - Name of the parameter for error messages
|
||||
* @returns ValidationResult
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = validateAirtableId(baseId, 'app', 'baseId')
|
||||
* if (!result.isValid) {
|
||||
* throw new Error(result.error)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function validateAirtableId(
|
||||
value: string | null | undefined,
|
||||
expectedPrefix: 'app' | 'tbl' | 'ach',
|
||||
paramName = 'ID'
|
||||
): ValidationResult {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} is required`,
|
||||
}
|
||||
}
|
||||
|
||||
// Airtable IDs: prefix (3 chars) + 14 alphanumeric characters = 17 chars total
|
||||
const airtableIdPattern = new RegExp(`^${expectedPrefix}[a-zA-Z0-9]{14}$`)
|
||||
|
||||
if (!airtableIdPattern.test(value)) {
|
||||
logger.warn('Invalid Airtable ID format', {
|
||||
paramName,
|
||||
expectedPrefix,
|
||||
value: value.substring(0, 20),
|
||||
})
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} must be a valid Airtable ID starting with "${expectedPrefix}"`,
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, sanitized: value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Google Calendar ID
|
||||
*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { validateAirtableId, validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -358,6 +359,15 @@ export async function deleteAirtableWebhook(
|
||||
return
|
||||
}
|
||||
|
||||
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
|
||||
if (!baseIdValidation.isValid) {
|
||||
airtableLogger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, {
|
||||
webhookId: webhook.id,
|
||||
baseId: baseId.substring(0, 20),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const userIdForToken = workflow.userId
|
||||
const accessToken = await getOAuthToken(userIdForToken, 'airtable')
|
||||
if (!accessToken) {
|
||||
@@ -428,6 +438,15 @@ export async function deleteAirtableWebhook(
|
||||
return
|
||||
}
|
||||
|
||||
const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId')
|
||||
if (!webhookIdValidation.isValid) {
|
||||
airtableLogger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, {
|
||||
webhookId: webhook.id,
|
||||
externalId: resolvedExternalId.substring(0, 20),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}`
|
||||
const airtableResponse = await fetch(airtableDeleteUrl, {
|
||||
method: 'DELETE',
|
||||
@@ -732,6 +751,14 @@ export async function deleteLemlistWebhook(webhook: any, requestId: string): Pro
|
||||
const authString = Buffer.from(`:${apiKey}`).toString('base64')
|
||||
|
||||
const deleteById = async (id: string) => {
|
||||
const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50)
|
||||
if (!validation.isValid) {
|
||||
lemlistLogger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, {
|
||||
id: id.substring(0, 30),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}`
|
||||
const lemlistResponse = await fetch(lemlistApiUrl, {
|
||||
method: 'DELETE',
|
||||
@@ -823,6 +850,24 @@ export async function deleteWebflowWebhook(
|
||||
return
|
||||
}
|
||||
|
||||
const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100)
|
||||
if (!siteIdValidation.isValid) {
|
||||
webflowLogger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, {
|
||||
webhookId: webhook.id,
|
||||
siteId: siteId.substring(0, 30),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100)
|
||||
if (!webhookIdValidation.isValid) {
|
||||
webflowLogger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, {
|
||||
webhookId: webhook.id,
|
||||
externalId: externalId.substring(0, 30),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const accessToken = await getOAuthToken(workflow.userId, 'webflow')
|
||||
if (!accessToken) {
|
||||
webflowLogger.warn(
|
||||
@@ -1122,6 +1167,16 @@ export async function createAirtableWebhookSubscription(
|
||||
)
|
||||
}
|
||||
|
||||
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
|
||||
if (!baseIdValidation.isValid) {
|
||||
throw new Error(baseIdValidation.error)
|
||||
}
|
||||
|
||||
const tableIdValidation = validateAirtableId(tableId, 'tbl', 'tableId')
|
||||
if (!tableIdValidation.isValid) {
|
||||
throw new Error(tableIdValidation.error)
|
||||
}
|
||||
|
||||
const accessToken = await getOAuthToken(userId, 'airtable')
|
||||
if (!accessToken) {
|
||||
airtableLogger.warn(
|
||||
@@ -1354,6 +1409,11 @@ export async function createWebflowWebhookSubscription(
|
||||
throw new Error('Site ID is required to create Webflow webhook')
|
||||
}
|
||||
|
||||
const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100)
|
||||
if (!siteIdValidation.isValid) {
|
||||
throw new Error(siteIdValidation.error)
|
||||
}
|
||||
|
||||
if (!triggerId) {
|
||||
webflowLogger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, {
|
||||
webhookId: webhookData.id,
|
||||
|
||||
@@ -26,7 +26,6 @@ export const useChatStore = create<ChatState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// UI State
|
||||
isChatOpen: false,
|
||||
chatPosition: null,
|
||||
chatWidth: DEFAULT_WIDTH,
|
||||
@@ -51,7 +50,6 @@ export const useChatStore = create<ChatState>()(
|
||||
set({ chatPosition: null })
|
||||
},
|
||||
|
||||
// Message State
|
||||
messages: [],
|
||||
selectedWorkflowOutputs: {},
|
||||
conversationIds: {},
|
||||
@@ -60,12 +58,10 @@ export const useChatStore = create<ChatState>()(
|
||||
set((state) => {
|
||||
const newMessage: ChatMessage = {
|
||||
...message,
|
||||
// Preserve provided id and timestamp if they exist; otherwise generate new ones
|
||||
id: (message as any).id ?? crypto.randomUUID(),
|
||||
timestamp: (message as any).timestamp ?? new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Keep only the last MAX_MESSAGES
|
||||
const newMessages = [newMessage, ...state.messages].slice(0, MAX_MESSAGES)
|
||||
|
||||
return { messages: newMessages }
|
||||
@@ -80,7 +76,6 @@ export const useChatStore = create<ChatState>()(
|
||||
),
|
||||
}
|
||||
|
||||
// Generate a new conversationId when clearing chat for a specific workflow
|
||||
if (workflowId) {
|
||||
const newConversationIds = { ...state.conversationIds }
|
||||
newConversationIds[workflowId] = uuidv4()
|
||||
@@ -89,7 +84,6 @@ export const useChatStore = create<ChatState>()(
|
||||
conversationIds: newConversationIds,
|
||||
}
|
||||
}
|
||||
// When clearing all chats (workflowId is null), also clear all conversationIds
|
||||
return {
|
||||
...newState,
|
||||
conversationIds: {},
|
||||
@@ -131,15 +125,12 @@ export const useChatStore = create<ChatState>()(
|
||||
return stringValue
|
||||
}
|
||||
|
||||
// CSV Headers
|
||||
const headers = ['timestamp', 'type', 'content']
|
||||
|
||||
// Sort messages by timestamp (oldest first)
|
||||
const sortedMessages = messages.sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
)
|
||||
|
||||
// Generate CSV rows
|
||||
const csvRows = [
|
||||
headers.join(','),
|
||||
...sortedMessages.map((message) =>
|
||||
@@ -151,15 +142,12 @@ export const useChatStore = create<ChatState>()(
|
||||
),
|
||||
]
|
||||
|
||||
// Create CSV content
|
||||
const csvContent = csvRows.join('\n')
|
||||
|
||||
// Generate filename with timestamp
|
||||
const now = new Date()
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
const filename = `chat-${workflowId}-${timestamp}.csv`
|
||||
|
||||
// Create and trigger download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
|
||||
@@ -177,15 +165,11 @@ export const useChatStore = create<ChatState>()(
|
||||
|
||||
setSelectedWorkflowOutput: (workflowId, outputIds) => {
|
||||
set((state) => {
|
||||
// Create a new copy of the selections state
|
||||
const newSelections = { ...state.selectedWorkflowOutputs }
|
||||
|
||||
// If empty array, explicitly remove the key to prevent empty arrays from persisting
|
||||
if (outputIds.length === 0) {
|
||||
// Delete the key entirely instead of setting to empty array
|
||||
delete newSelections[workflowId]
|
||||
} else {
|
||||
// Ensure no duplicates in the selection by using Set
|
||||
newSelections[workflowId] = [...new Set(outputIds)]
|
||||
}
|
||||
|
||||
@@ -200,7 +184,6 @@ export const useChatStore = create<ChatState>()(
|
||||
getConversationId: (workflowId) => {
|
||||
const state = get()
|
||||
if (!state.conversationIds[workflowId]) {
|
||||
// Generate a new conversation ID if one doesn't exist
|
||||
return get().generateNewConversationId(workflowId)
|
||||
}
|
||||
return state.conversationIds[workflowId]
|
||||
@@ -270,6 +253,16 @@ export const useChatStore = create<ChatState>()(
|
||||
}),
|
||||
{
|
||||
name: 'chat-store',
|
||||
partialize: (state) => ({
|
||||
...state,
|
||||
messages: state.messages.map((msg) => ({
|
||||
...msg,
|
||||
attachments: msg.attachments?.map((att) => ({
|
||||
...att,
|
||||
dataUrl: '',
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ export const SIDEBAR_WIDTH = {
|
||||
|
||||
/** Right panel width constraints */
|
||||
export const PANEL_WIDTH = {
|
||||
DEFAULT: 290,
|
||||
DEFAULT: 320,
|
||||
MIN: 290,
|
||||
/** Maximum is 40% of viewport, enforced dynamically */
|
||||
MAX_PERCENTAGE: 0.4,
|
||||
|
||||
@@ -126,14 +126,6 @@ export default {
|
||||
strokeDashoffset: '-24',
|
||||
},
|
||||
},
|
||||
'code-shimmer': {
|
||||
'0%': {
|
||||
transform: 'translateX(-100%)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(100%)',
|
||||
},
|
||||
},
|
||||
'placeholder-pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: '0.5',
|
||||
@@ -156,7 +148,6 @@ export default {
|
||||
'slide-left': 'slide-left 80s linear infinite',
|
||||
'slide-right': 'slide-right 80s linear infinite',
|
||||
'dash-animation': 'dash-animation 1.5s linear infinite',
|
||||
'code-shimmer': 'code-shimmer 1.5s infinite',
|
||||
'placeholder-pulse': 'placeholder-pulse 1.5s ease-in-out infinite',
|
||||
'ring-pulse': 'ring-pulse 1.5s ease-in-out infinite',
|
||||
},
|
||||
|
||||
2
apps/sim/tools/pulse/index.ts
Normal file
2
apps/sim/tools/pulse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { pulseParserTool } from '@/tools/pulse/parser'
|
||||
export * from './types'
|
||||
272
apps/sim/tools/pulse/parser.ts
Normal file
272
apps/sim/tools/pulse/parser.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { PulseParserInput, PulseParserOutput } from '@/tools/pulse/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('PulseParserTool')
|
||||
|
||||
export const pulseParserTool: ToolConfig<PulseParserInput, PulseParserOutput> = {
|
||||
id: 'pulse_parser',
|
||||
name: 'Pulse Document Parser',
|
||||
description: 'Parse documents (PDF, images, Office docs) using Pulse OCR API',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'URL to a document to be processed',
|
||||
},
|
||||
fileUpload: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'File upload data from file-upload component',
|
||||
},
|
||||
pages: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Page range to process (1-indexed, e.g., "1-2,5")',
|
||||
},
|
||||
extractFigure: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Enable figure extraction from the document',
|
||||
},
|
||||
figureDescription: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Generate descriptions/captions for extracted figures',
|
||||
},
|
||||
returnHtml: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Include HTML in the response',
|
||||
},
|
||||
chunking: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Chunking strategies (comma-separated: semantic, header, page, recursive)',
|
||||
},
|
||||
chunkSize: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Maximum characters per chunk when chunking is enabled',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Pulse API key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/pulse/parse',
|
||||
method: 'POST',
|
||||
headers: () => {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
if (!params || typeof params !== 'object') {
|
||||
throw new Error('Invalid parameters: Parameters must be provided as an object')
|
||||
}
|
||||
|
||||
if (!params.apiKey || typeof params.apiKey !== 'string' || params.apiKey.trim() === '') {
|
||||
throw new Error('Missing or invalid API key: A valid Pulse API key is required')
|
||||
}
|
||||
|
||||
if (
|
||||
params.fileUpload &&
|
||||
(!params.filePath || params.filePath === 'null' || params.filePath === '')
|
||||
) {
|
||||
if (
|
||||
typeof params.fileUpload === 'object' &&
|
||||
params.fileUpload !== null &&
|
||||
(params.fileUpload.url || params.fileUpload.path)
|
||||
) {
|
||||
let uploadedFilePath: string = params.fileUpload.url ?? params.fileUpload.path ?? ''
|
||||
|
||||
if (!uploadedFilePath) {
|
||||
throw new Error('Invalid file upload: Upload data is missing or invalid')
|
||||
}
|
||||
|
||||
if (uploadedFilePath.startsWith('/')) {
|
||||
const baseUrl = getBaseUrl()
|
||||
if (!baseUrl) throw new Error('Failed to get base URL for file path conversion')
|
||||
uploadedFilePath = `${baseUrl}${uploadedFilePath}`
|
||||
}
|
||||
|
||||
params.filePath = uploadedFilePath
|
||||
logger.info('Using uploaded file:', uploadedFilePath)
|
||||
} else {
|
||||
throw new Error('Invalid file upload: Upload data is missing or invalid')
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!params.filePath ||
|
||||
typeof params.filePath !== 'string' ||
|
||||
params.filePath.trim() === ''
|
||||
) {
|
||||
throw new Error('Missing or invalid file path: Please provide a URL to a document')
|
||||
}
|
||||
|
||||
let filePathToValidate = params.filePath.trim()
|
||||
if (filePathToValidate.startsWith('/')) {
|
||||
const baseUrl = getBaseUrl()
|
||||
if (!baseUrl) throw new Error('Failed to get base URL for file path conversion')
|
||||
filePathToValidate = `${baseUrl}${filePathToValidate}`
|
||||
}
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(filePathToValidate)
|
||||
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a document`
|
||||
)
|
||||
}
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
apiKey: params.apiKey.trim(),
|
||||
filePath: url.toString(),
|
||||
}
|
||||
|
||||
if (params.fileUpload?.path?.startsWith('/api/files/serve/')) {
|
||||
requestBody.filePath = params.fileUpload.path
|
||||
}
|
||||
|
||||
if (params.pages && typeof params.pages === 'string' && params.pages.trim() !== '') {
|
||||
requestBody.pages = params.pages.trim()
|
||||
}
|
||||
|
||||
if (params.extractFigure !== undefined) {
|
||||
requestBody.extractFigure = params.extractFigure
|
||||
}
|
||||
|
||||
if (params.figureDescription !== undefined) {
|
||||
requestBody.figureDescription = params.figureDescription
|
||||
}
|
||||
|
||||
if (params.returnHtml !== undefined) {
|
||||
requestBody.returnHtml = params.returnHtml
|
||||
}
|
||||
|
||||
if (params.chunking && typeof params.chunking === 'string' && params.chunking.trim() !== '') {
|
||||
requestBody.chunking = params.chunking.trim()
|
||||
}
|
||||
|
||||
if (params.chunkSize !== undefined && params.chunkSize > 0) {
|
||||
requestBody.chunkSize = params.chunkSize
|
||||
}
|
||||
|
||||
return requestBody
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
let parseResult
|
||||
try {
|
||||
parseResult = await response.json()
|
||||
} catch (jsonError) {
|
||||
throw new Error(
|
||||
`Failed to parse Pulse response: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!parseResult || typeof parseResult !== 'object') {
|
||||
throw new Error('Invalid response format from Pulse API')
|
||||
}
|
||||
|
||||
const pulseData =
|
||||
parseResult.output && typeof parseResult.output === 'object'
|
||||
? parseResult.output
|
||||
: parseResult
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
markdown: pulseData.markdown ?? '',
|
||||
page_count: pulseData.page_count ?? 0,
|
||||
job_id: pulseData.job_id ?? '',
|
||||
'plan-info': pulseData['plan-info'] ?? { pages_used: 0, tier: 'unknown' },
|
||||
bounding_boxes: pulseData.bounding_boxes ?? null,
|
||||
extraction_url: pulseData.extraction_url ?? null,
|
||||
html: pulseData.html ?? null,
|
||||
structured_output: pulseData.structured_output ?? null,
|
||||
chunks: pulseData.chunks ?? null,
|
||||
figures: pulseData.figures ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
markdown: {
|
||||
type: 'string',
|
||||
description: 'Extracted content in markdown format',
|
||||
},
|
||||
page_count: {
|
||||
type: 'number',
|
||||
description: 'Number of pages in the document',
|
||||
},
|
||||
job_id: {
|
||||
type: 'string',
|
||||
description: 'Unique job identifier',
|
||||
},
|
||||
'plan-info': {
|
||||
type: 'object',
|
||||
description: 'Plan usage information',
|
||||
properties: {
|
||||
pages_used: { type: 'number', description: 'Number of pages used' },
|
||||
tier: { type: 'string', description: 'Plan tier' },
|
||||
note: { type: 'string', description: 'Optional note', optional: true },
|
||||
},
|
||||
},
|
||||
bounding_boxes: {
|
||||
type: 'json',
|
||||
description: 'Bounding box layout information',
|
||||
optional: true,
|
||||
},
|
||||
extraction_url: {
|
||||
type: 'string',
|
||||
description: 'URL for extraction results (for large documents)',
|
||||
optional: true,
|
||||
},
|
||||
html: {
|
||||
type: 'string',
|
||||
description: 'HTML content if requested',
|
||||
optional: true,
|
||||
},
|
||||
structured_output: {
|
||||
type: 'json',
|
||||
description: 'Structured output if schema was provided',
|
||||
optional: true,
|
||||
},
|
||||
chunks: {
|
||||
type: 'json',
|
||||
description: 'Chunked content if chunking was enabled',
|
||||
optional: true,
|
||||
},
|
||||
figures: {
|
||||
type: 'json',
|
||||
description: 'Extracted figures if figure extraction was enabled',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
93
apps/sim/tools/pulse/types.ts
Normal file
93
apps/sim/tools/pulse/types.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Input parameters for the Pulse parser tool
|
||||
*/
|
||||
export interface PulseParserInput {
|
||||
/** URL to a document to be processed */
|
||||
filePath: string
|
||||
|
||||
/** File upload data (from file-upload component) */
|
||||
fileUpload?: {
|
||||
url?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** Pulse API key for authentication */
|
||||
apiKey: string
|
||||
|
||||
/** Page range to process (1-indexed, e.g., "1-2,5") */
|
||||
pages?: string
|
||||
|
||||
/** Whether to extract figures from the document */
|
||||
extractFigure?: boolean
|
||||
|
||||
/** Whether to generate figure descriptions/captions */
|
||||
figureDescription?: boolean
|
||||
|
||||
/** Whether to include HTML in the response */
|
||||
returnHtml?: boolean
|
||||
|
||||
/** Chunking strategies (comma-separated: semantic, header, page, recursive) */
|
||||
chunking?: string
|
||||
|
||||
/** Maximum characters per chunk when chunking is enabled */
|
||||
chunkSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan info returned by the Pulse API
|
||||
*/
|
||||
export interface PulsePlanInfo {
|
||||
/** Number of pages used */
|
||||
pages_used: number
|
||||
|
||||
/** Plan tier */
|
||||
tier: string
|
||||
|
||||
/** Optional note */
|
||||
note?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Native output structure from the Pulse API
|
||||
*/
|
||||
export interface PulseParserOutputData {
|
||||
/** Extracted content in markdown format */
|
||||
markdown: string
|
||||
|
||||
/** Number of pages in the document */
|
||||
page_count: number
|
||||
|
||||
/** Unique job identifier */
|
||||
job_id: string
|
||||
|
||||
/** Plan usage information */
|
||||
'plan-info': PulsePlanInfo
|
||||
|
||||
/** Bounding box layout information */
|
||||
bounding_boxes?: Record<string, unknown>
|
||||
|
||||
/** URL for extraction results (for large documents) */
|
||||
extraction_url?: string
|
||||
|
||||
/** HTML content if requested */
|
||||
html?: string
|
||||
|
||||
/** Structured output if schema was provided */
|
||||
structured_output?: Record<string, unknown>
|
||||
|
||||
/** Chunked content if chunking was enabled */
|
||||
chunks?: unknown[]
|
||||
|
||||
/** Extracted figures if figure extraction was enabled */
|
||||
figures?: unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete response from the Pulse parser tool
|
||||
*/
|
||||
export interface PulseParserOutput extends ToolResponse {
|
||||
/** The native Pulse API output */
|
||||
output: PulseParserOutputData
|
||||
}
|
||||
3
apps/sim/tools/reducto/index.ts
Normal file
3
apps/sim/tools/reducto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { reductoParserTool } from '@/tools/reducto/parser'
|
||||
|
||||
export { reductoParserTool }
|
||||
192
apps/sim/tools/reducto/parser.ts
Normal file
192
apps/sim/tools/reducto/parser.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { ReductoParserInput, ReductoParserOutput } from '@/tools/reducto/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('ReductoParserTool')
|
||||
|
||||
export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutput> = {
|
||||
id: 'reducto_parser',
|
||||
name: 'Reducto PDF Parser',
|
||||
description: 'Parse PDF documents using Reducto OCR API',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'URL to a PDF document to be processed',
|
||||
},
|
||||
fileUpload: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'File upload data from file-upload component',
|
||||
},
|
||||
pages: {
|
||||
type: 'array',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Specific pages to process (1-indexed page numbers)',
|
||||
},
|
||||
tableOutputFormat: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Table output format (html or markdown). Defaults to markdown.',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Reducto API key (REDUCTO_API_KEY)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/reducto/parse',
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
if (!params || typeof params !== 'object') {
|
||||
throw new Error('Invalid parameters: Parameters must be provided as an object')
|
||||
}
|
||||
|
||||
if (!params.apiKey || typeof params.apiKey !== 'string' || params.apiKey.trim() === '') {
|
||||
throw new Error('Missing or invalid API key: A valid Reducto API key is required')
|
||||
}
|
||||
|
||||
if (
|
||||
params.fileUpload &&
|
||||
(!params.filePath || params.filePath === 'null' || params.filePath === '')
|
||||
) {
|
||||
if (
|
||||
typeof params.fileUpload === 'object' &&
|
||||
params.fileUpload !== null &&
|
||||
(params.fileUpload.url || params.fileUpload.path)
|
||||
) {
|
||||
let uploadedFilePath = (params.fileUpload.url || params.fileUpload.path) as string
|
||||
|
||||
if (uploadedFilePath.startsWith('/')) {
|
||||
const baseUrl = getBaseUrl()
|
||||
if (!baseUrl) throw new Error('Failed to get base URL for file path conversion')
|
||||
uploadedFilePath = `${baseUrl}${uploadedFilePath}`
|
||||
}
|
||||
|
||||
params.filePath = uploadedFilePath as string
|
||||
logger.info('Using uploaded file:', uploadedFilePath)
|
||||
} else {
|
||||
throw new Error('Invalid file upload: Upload data is missing or invalid')
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!params.filePath ||
|
||||
typeof params.filePath !== 'string' ||
|
||||
params.filePath.trim() === ''
|
||||
) {
|
||||
throw new Error('Missing or invalid file path: Please provide a URL to a PDF document')
|
||||
}
|
||||
|
||||
let filePathToValidate = params.filePath.trim()
|
||||
if (filePathToValidate.startsWith('/')) {
|
||||
const baseUrl = getBaseUrl()
|
||||
if (!baseUrl) throw new Error('Failed to get base URL for file path conversion')
|
||||
filePathToValidate = `${baseUrl}${filePathToValidate}`
|
||||
}
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(filePathToValidate)
|
||||
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a PDF document.`
|
||||
)
|
||||
}
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
apiKey: params.apiKey,
|
||||
filePath: url.toString(),
|
||||
}
|
||||
|
||||
if (params.fileUpload?.path?.startsWith('/api/files/serve/')) {
|
||||
requestBody.filePath = params.fileUpload.path
|
||||
}
|
||||
|
||||
if (params.tableOutputFormat && ['html', 'md'].includes(params.tableOutputFormat)) {
|
||||
requestBody.tableOutputFormat = params.tableOutputFormat
|
||||
}
|
||||
|
||||
if (params.pages !== undefined && params.pages !== null) {
|
||||
if (Array.isArray(params.pages) && params.pages.length > 0) {
|
||||
const validPages = params.pages.filter(
|
||||
(page) => typeof page === 'number' && Number.isInteger(page) && page >= 0
|
||||
)
|
||||
|
||||
if (validPages.length > 0) {
|
||||
requestBody.pages = validPages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return requestBody
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid response format from Reducto API')
|
||||
}
|
||||
|
||||
const reductoData = data.output ?? data
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
job_id: reductoData.job_id,
|
||||
duration: reductoData.duration,
|
||||
usage: reductoData.usage,
|
||||
result: reductoData.result,
|
||||
pdf_url: reductoData.pdf_url ?? null,
|
||||
studio_link: reductoData.studio_link ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
job_id: { type: 'string', description: 'Unique identifier for the processing job' },
|
||||
duration: { type: 'number', description: 'Processing time in seconds' },
|
||||
usage: {
|
||||
type: 'json',
|
||||
description: 'Resource consumption data',
|
||||
},
|
||||
result: {
|
||||
type: 'json',
|
||||
description: 'Parsed document content with chunks and blocks',
|
||||
},
|
||||
pdf_url: {
|
||||
type: 'string',
|
||||
description: 'Storage URL of converted PDF',
|
||||
optional: true,
|
||||
},
|
||||
studio_link: {
|
||||
type: 'string',
|
||||
description: 'Link to Reducto studio interface',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
160
apps/sim/tools/reducto/types.ts
Normal file
160
apps/sim/tools/reducto/types.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Input parameters for the Reducto parser tool
|
||||
*/
|
||||
export interface ReductoParserInput {
|
||||
/** URL to a document to be processed */
|
||||
filePath: string
|
||||
|
||||
/** File upload data (from file-upload component) */
|
||||
fileUpload?: {
|
||||
url?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** Reducto API key for authentication */
|
||||
apiKey: string
|
||||
|
||||
/** Specific pages to process (1-indexed) */
|
||||
pages?: number[]
|
||||
|
||||
/** Table output format (html or md) */
|
||||
tableOutputFormat?: 'html' | 'md'
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding box for spatial location data
|
||||
*/
|
||||
export interface ReductoBoundingBox {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
page: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Granular confidence scores
|
||||
*/
|
||||
export interface ReductoGranularConfidence {
|
||||
ocr: string | null
|
||||
layout: string | null
|
||||
order: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Block type classification
|
||||
*/
|
||||
export type ReductoBlockType =
|
||||
| 'Header'
|
||||
| 'Footer'
|
||||
| 'Title'
|
||||
| 'SectionHeader'
|
||||
| 'Text'
|
||||
| 'ListItem'
|
||||
| 'Table'
|
||||
| 'Figure'
|
||||
| 'Caption'
|
||||
| 'Equation'
|
||||
| 'Code'
|
||||
| 'PageNumber'
|
||||
| 'Watermark'
|
||||
| 'Handwriting'
|
||||
| 'Other'
|
||||
|
||||
/**
|
||||
* Parse block - structured content element
|
||||
*/
|
||||
export interface ReductoParseBlock {
|
||||
type: ReductoBlockType
|
||||
bbox: ReductoBoundingBox
|
||||
content: string
|
||||
image_url: string | null
|
||||
chart_data: string[] | null
|
||||
confidence: string | null
|
||||
granular_confidence: ReductoGranularConfidence | null
|
||||
extra: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse chunk - document segment
|
||||
*/
|
||||
export interface ReductoParseChunk {
|
||||
content: string
|
||||
embed: string
|
||||
enriched: string | null
|
||||
blocks: ReductoParseBlock[]
|
||||
enrichment_success: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR word data
|
||||
*/
|
||||
export interface ReductoOcrWord {
|
||||
text: string
|
||||
bbox: ReductoBoundingBox
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR line data
|
||||
*/
|
||||
export interface ReductoOcrLine {
|
||||
text: string
|
||||
bbox: ReductoBoundingBox
|
||||
words: ReductoOcrWord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR result data
|
||||
*/
|
||||
export interface ReductoOcrResult {
|
||||
lines: ReductoOcrLine[]
|
||||
words: ReductoOcrWord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Full result - when response fits in payload
|
||||
*/
|
||||
export interface ReductoFullResult {
|
||||
type: 'full'
|
||||
chunks: ReductoParseChunk[]
|
||||
ocr: ReductoOcrResult | null
|
||||
custom: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* URL result - when response exceeds size limits
|
||||
*/
|
||||
export interface ReductoUrlResult {
|
||||
type: 'url'
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage information returned by Reducto API
|
||||
*/
|
||||
export interface ReductoUsage {
|
||||
num_pages: number
|
||||
credits: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Native Reducto API response structure
|
||||
*/
|
||||
export interface ReductoParserOutputData {
|
||||
job_id: string
|
||||
duration: number
|
||||
usage: ReductoUsage
|
||||
result: ReductoFullResult | ReductoUrlResult
|
||||
pdf_url: string | null
|
||||
studio_link: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete response from the Reducto parser tool
|
||||
*/
|
||||
export interface ReductoParserOutput extends ToolResponse {
|
||||
output: ReductoParserOutputData
|
||||
}
|
||||
@@ -1032,6 +1032,7 @@ import {
|
||||
posthogUpdatePropertyDefinitionTool,
|
||||
posthogUpdateSurveyTool,
|
||||
} from '@/tools/posthog'
|
||||
import { pulseParserTool } from '@/tools/pulse'
|
||||
import { qdrantFetchTool, qdrantSearchTool, qdrantUpsertTool } from '@/tools/qdrant'
|
||||
import {
|
||||
rdsDeleteTool,
|
||||
@@ -1056,6 +1057,7 @@ import {
|
||||
redditUnsaveTool,
|
||||
redditVoteTool,
|
||||
} from '@/tools/reddit'
|
||||
import { reductoParserTool } from '@/tools/reducto'
|
||||
import { mailSendTool } from '@/tools/resend'
|
||||
import {
|
||||
s3CopyObjectTool,
|
||||
@@ -2126,6 +2128,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
google_slides_add_image: googleSlidesAddImageTool,
|
||||
perplexity_chat: perplexityChatTool,
|
||||
perplexity_search: perplexitySearchTool,
|
||||
pulse_parser: pulseParserTool,
|
||||
posthog_capture_event: posthogCaptureEventTool,
|
||||
posthog_batch_events: posthogBatchEventsTool,
|
||||
posthog_list_persons: posthogListPersonsTool,
|
||||
@@ -2248,6 +2251,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
apollo_task_search: apolloTaskSearchTool,
|
||||
apollo_email_accounts: apolloEmailAccountsTool,
|
||||
mistral_parser: mistralParserTool,
|
||||
reducto_parser: reductoParserTool,
|
||||
thinking_tool: thinkingTool,
|
||||
tinybird_events: tinybirdEventsTool,
|
||||
tinybird_query: tinybirdQueryTool,
|
||||
|
||||
Reference in New Issue
Block a user