mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 05:18:08 -05:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2be33d6b | ||
|
|
376f7cb571 | ||
|
|
42159c23b9 | ||
|
|
2f0f246002 | ||
|
|
900d3ef9ea | ||
|
|
f3fcc28f89 | ||
|
|
7cfdf46724 | ||
|
|
d681451297 | ||
|
|
5987a6d060 | ||
|
|
e2ccefb2f4 | ||
|
|
103b31a569 | ||
|
|
004e058353 | ||
|
|
5157f0bbb2 | ||
|
|
8bbcf31b83 | ||
|
|
9e814315dd | ||
|
|
0ea0256623 | ||
|
|
fb8868c854 | ||
|
|
ea4964052d | ||
|
|
268e2f114f |
@@ -2,10 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,8 +21,10 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('LoginForm')
|
||||
|
||||
@@ -105,8 +106,7 @@ export default function LoginPage({
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
@@ -114,7 +114,6 @@ export default function LoginPage({
|
||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
||||
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
||||
const [isResetButtonHovered, setIsResetButtonHovered] = useState(false)
|
||||
const [resetStatus, setResetStatus] = useState<{
|
||||
type: 'success' | 'error' | null
|
||||
message: string
|
||||
@@ -123,6 +122,7 @@ export default function LoginPage({
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -139,32 +139,12 @@ export default function LoginPage({
|
||||
|
||||
const inviteFlow = searchParams.get('invite_flow') === 'true'
|
||||
setIsInviteFlow(inviteFlow)
|
||||
}
|
||||
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
const resetSuccess = searchParams.get('resetSuccess') === 'true'
|
||||
if (resetSuccess) {
|
||||
setResetSuccessMessage('Password reset successful. Please sign in with your new password.')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -202,6 +182,13 @@ export default function LoginPage({
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const redirectToVerify = (emailToVerify: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', emailToVerify)
|
||||
}
|
||||
router.push('/verify')
|
||||
}
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const emailRaw = formData.get('email') as string
|
||||
const email = emailRaw.trim().toLowerCase()
|
||||
@@ -221,6 +208,7 @@ export default function LoginPage({
|
||||
|
||||
try {
|
||||
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
|
||||
let errorHandled = false
|
||||
|
||||
const result = await client.signIn.email(
|
||||
{
|
||||
@@ -231,11 +219,16 @@ export default function LoginPage({
|
||||
{
|
||||
onError: (ctx) => {
|
||||
logger.error('Login error:', ctx.error)
|
||||
const errorMessage: string[] = ['Invalid email or password']
|
||||
|
||||
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
errorHandled = true
|
||||
redirectToVerify(email)
|
||||
return
|
||||
}
|
||||
|
||||
errorHandled = true
|
||||
const errorMessage: string[] = ['Invalid email or password']
|
||||
|
||||
if (
|
||||
ctx.error.code?.includes('BAD_REQUEST') ||
|
||||
ctx.error.message?.includes('Email and password sign in is not enabled')
|
||||
@@ -271,6 +264,7 @@ export default function LoginPage({
|
||||
errorMessage.push('Too many requests. Please wait a moment before trying again.')
|
||||
}
|
||||
|
||||
setResetSuccessMessage(null)
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
},
|
||||
@@ -278,15 +272,25 @@ export default function LoginPage({
|
||||
)
|
||||
|
||||
if (!result || result.error) {
|
||||
// Show error if not already handled by onError callback
|
||||
if (!errorHandled) {
|
||||
setResetSuccessMessage(null)
|
||||
const errorMessage = result?.error?.message || 'Login failed. Please try again.'
|
||||
setPasswordErrors([errorMessage])
|
||||
setShowValidationError(true)
|
||||
}
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear reset success message on successful login
|
||||
setResetSuccessMessage(null)
|
||||
|
||||
// Explicit redirect fallback if better-auth doesn't redirect
|
||||
router.push(safeCallbackUrl)
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', email)
|
||||
}
|
||||
router.push('/verify')
|
||||
redirectToVerify(email)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -400,6 +404,13 @@ export default function LoginPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password reset success message */}
|
||||
{resetSuccessMessage && (
|
||||
<div className={`${inter.className} mt-1 space-y-1 text-[#4CAF50] text-xs`}>
|
||||
<p>{resetSuccessMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email/Password Form - show unless explicitly disabled */}
|
||||
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
|
||||
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
||||
@@ -482,24 +493,14 @@ export default function LoginPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
onMouseEnter={() => setIsButtonHovered(true)}
|
||||
onMouseLeave={() => setIsButtonHovered(false)}
|
||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
loadingText='Signing in'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isButtonHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
Sign in
|
||||
</BrandedButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -610,25 +611,15 @@ export default function LoginPage({
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
<BrandedButton
|
||||
type='button'
|
||||
onClick={handleForgotPassword}
|
||||
onMouseEnter={() => setIsResetButtonHovered(true)}
|
||||
onMouseLeave={() => setIsResetButtonHovered(false)}
|
||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||
disabled={isSubmittingReset}
|
||||
loading={isSubmittingReset}
|
||||
loadingText='Sending'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isResetButtonHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
|
||||
interface RequestResetFormProps {
|
||||
email: string
|
||||
@@ -27,36 +27,6 @@ export function RequestResetForm({
|
||||
statusMessage,
|
||||
className,
|
||||
}: RequestResetFormProps) {
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(email)
|
||||
@@ -94,24 +64,14 @@ export function RequestResetForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
onMouseEnter={() => setIsButtonHovered(true)}
|
||||
onMouseLeave={() => setIsButtonHovered(false)}
|
||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||
loading={isSubmitting}
|
||||
loadingText='Sending'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isButtonHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -138,35 +98,6 @@ export function SetNewPasswordForm({
|
||||
const [validationMessage, setValidationMessage] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -296,24 +227,14 @@ export function SetNewPasswordForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={isSubmitting || !token}
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
onMouseEnter={() => setIsButtonHovered(true)}
|
||||
onMouseLeave={() => setIsButtonHovered(false)}
|
||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||
disabled={isSubmitting || !token}
|
||||
loading={isSubmitting}
|
||||
loadingText='Resetting'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isButtonHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
Reset Password
|
||||
</BrandedButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
@@ -14,8 +13,10 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('SignupForm')
|
||||
|
||||
@@ -95,8 +96,7 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [redirectUrl, setRedirectUrl] = useState('')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||
@@ -126,31 +126,6 @@ function SignupFormContent({
|
||||
if (inviteFlowParam === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
@@ -500,24 +475,14 @@ function SignupFormContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
onMouseEnter={() => setIsButtonHovered(true)}
|
||||
onMouseLeave={() => setIsButtonHovered(false)}
|
||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
loadingText='Creating account'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{isLoading ? 'Creating account' : 'Create account'}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isButtonHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
Create account
|
||||
</BrandedButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('SSOForm')
|
||||
|
||||
@@ -57,7 +58,7 @@ export default function SSOForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,31 +91,6 @@ export default function SSOForm() {
|
||||
setShowEmailValidationError(true)
|
||||
}
|
||||
}
|
||||
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
interface VerifyContentProps {
|
||||
hasEmailService: boolean
|
||||
@@ -58,34 +59,7 @@ function VerificationForm({
|
||||
setCountdown(30)
|
||||
}
|
||||
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { X } from 'lucide-react'
|
||||
import { Textarea } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
@@ -18,6 +17,7 @@ import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
|
||||
@@ -493,18 +493,17 @@ export default function CareersPage() {
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className='flex justify-end pt-2'>
|
||||
<Button
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isSubmitting || submitStatus === 'success'}
|
||||
className='min-w-[200px] rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all duration-300 hover:opacity-90 disabled:opacity-50'
|
||||
size='lg'
|
||||
loading={isSubmitting}
|
||||
loadingText='Submitting'
|
||||
showArrow={false}
|
||||
fullWidth={false}
|
||||
className='min-w-[200px]'
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Submitting...'
|
||||
: submitStatus === 'success'
|
||||
? 'Submitted'
|
||||
: 'Submit Application'}
|
||||
</Button>
|
||||
{submitStatus === 'success' ? 'Submitted' : 'Submit Application'}
|
||||
</BrandedButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('nav')
|
||||
|
||||
@@ -20,11 +21,12 @@ interface NavProps {
|
||||
}
|
||||
|
||||
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
|
||||
const [githubStars, setGithubStars] = useState('25.1k')
|
||||
const [githubStars, setGithubStars] = useState('25.8k')
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
||||
const router = useRouter()
|
||||
const brand = useBrandConfig()
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'landing') return
|
||||
@@ -183,7 +185,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
href='/signup'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
|
||||
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
|
||||
aria-label='Get started with Sim - Sign up for free'
|
||||
prefetch={true}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,11 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
import {
|
||||
getMicrosoftRefreshTokenExpiry,
|
||||
isMicrosoftProvider,
|
||||
PROACTIVE_REFRESH_THRESHOLD_DAYS,
|
||||
} from '@/lib/oauth/microsoft'
|
||||
|
||||
const logger = createLogger('OAuthUtilsAPI')
|
||||
|
||||
@@ -205,15 +210,32 @@ export async function refreshAccessTokenIfNeeded(
|
||||
}
|
||||
|
||||
// Decide if we should refresh: token missing OR expired
|
||||
const expiresAt = credential.accessTokenExpiresAt
|
||||
const accessTokenExpiresAt = credential.accessTokenExpiresAt
|
||||
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
|
||||
const now = new Date()
|
||||
const shouldRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||
|
||||
// Check if access token needs refresh (missing or expired)
|
||||
const accessTokenNeedsRefresh =
|
||||
!!credential.refreshToken &&
|
||||
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
|
||||
|
||||
// Check if we should proactively refresh to prevent refresh token expiry
|
||||
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
|
||||
const proactiveRefreshThreshold = new Date(
|
||||
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
|
||||
)
|
||||
const refreshTokenNeedsProactiveRefresh =
|
||||
!!credential.refreshToken &&
|
||||
isMicrosoftProvider(credential.providerId) &&
|
||||
refreshTokenExpiresAt &&
|
||||
refreshTokenExpiresAt <= proactiveRefreshThreshold
|
||||
|
||||
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
|
||||
|
||||
const accessToken = credential.accessToken
|
||||
|
||||
if (shouldRefresh) {
|
||||
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
||||
logger.info(`[${requestId}] Refreshing token for credential`)
|
||||
try {
|
||||
const refreshedToken = await refreshOAuthToken(
|
||||
credential.providerId,
|
||||
@@ -227,11 +249,15 @@ export async function refreshAccessTokenIfNeeded(
|
||||
userId: credential.userId,
|
||||
hasRefreshToken: !!credential.refreshToken,
|
||||
})
|
||||
if (!accessTokenNeedsRefresh && accessToken) {
|
||||
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||
return accessToken
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {
|
||||
const updateData: Record<string, unknown> = {
|
||||
accessToken: refreshedToken.accessToken,
|
||||
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
|
||||
updatedAt: new Date(),
|
||||
@@ -243,6 +269,10 @@ export async function refreshAccessTokenIfNeeded(
|
||||
updateData.refreshToken = refreshedToken.refreshToken
|
||||
}
|
||||
|
||||
if (isMicrosoftProvider(credential.providerId)) {
|
||||
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||
}
|
||||
|
||||
// Update the token in the database
|
||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||
|
||||
@@ -256,6 +286,10 @@ export async function refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
userId: credential.userId,
|
||||
})
|
||||
if (!accessTokenNeedsRefresh && accessToken) {
|
||||
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||
return accessToken
|
||||
}
|
||||
return null
|
||||
}
|
||||
} else if (!accessToken) {
|
||||
@@ -277,10 +311,27 @@ export async function refreshTokenIfNeeded(
|
||||
credentialId: string
|
||||
): Promise<{ accessToken: string; refreshed: boolean }> {
|
||||
// Decide if we should refresh: token missing OR expired
|
||||
const expiresAt = credential.accessTokenExpiresAt
|
||||
const accessTokenExpiresAt = credential.accessTokenExpiresAt
|
||||
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
|
||||
const now = new Date()
|
||||
const shouldRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||
|
||||
// Check if access token needs refresh (missing or expired)
|
||||
const accessTokenNeedsRefresh =
|
||||
!!credential.refreshToken &&
|
||||
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
|
||||
|
||||
// Check if we should proactively refresh to prevent refresh token expiry
|
||||
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
|
||||
const proactiveRefreshThreshold = new Date(
|
||||
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
|
||||
)
|
||||
const refreshTokenNeedsProactiveRefresh =
|
||||
!!credential.refreshToken &&
|
||||
isMicrosoftProvider(credential.providerId) &&
|
||||
refreshTokenExpiresAt &&
|
||||
refreshTokenExpiresAt <= proactiveRefreshThreshold
|
||||
|
||||
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
|
||||
|
||||
// If token appears valid and present, return it directly
|
||||
if (!shouldRefresh) {
|
||||
@@ -293,13 +344,17 @@ export async function refreshTokenIfNeeded(
|
||||
|
||||
if (!refreshResult) {
|
||||
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
||||
if (!accessTokenNeedsRefresh && credential.accessToken) {
|
||||
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||
return { accessToken: credential.accessToken, refreshed: false }
|
||||
}
|
||||
throw new Error('Failed to refresh token')
|
||||
}
|
||||
|
||||
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {
|
||||
const updateData: Record<string, unknown> = {
|
||||
accessToken: refreshedToken,
|
||||
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
|
||||
updatedAt: new Date(),
|
||||
@@ -311,6 +366,10 @@ export async function refreshTokenIfNeeded(
|
||||
updateData.refreshToken = newRefreshToken
|
||||
}
|
||||
|
||||
if (isMicrosoftProvider(credential.providerId)) {
|
||||
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||
}
|
||||
|
||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||
|
||||
logger.info(`[${requestId}] Successfully refreshed access token`)
|
||||
@@ -331,6 +390,11 @@ export async function refreshTokenIfNeeded(
|
||||
}
|
||||
}
|
||||
|
||||
if (!accessTokenNeedsRefresh && credential.accessToken) {
|
||||
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||
return { accessToken: credential.accessToken, refreshed: false }
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ const resetPasswordSchema = z.object({
|
||||
.max(100, 'Password must not exceed 100 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@@ -276,8 +276,11 @@ describe('Function Execute API Route', () => {
|
||||
it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
params: {
|
||||
email: { id: '123', subject: 'Test Email' },
|
||||
blockData: {
|
||||
'block-123': { id: '123', subject: 'Test Email' },
|
||||
},
|
||||
blockNameMapping: {
|
||||
email: 'block-123',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -305,9 +308,13 @@ describe('Function Execute API Route', () => {
|
||||
it.concurrent('should only match valid variable names in angle brackets', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
|
||||
params: {
|
||||
validVar: 'hello',
|
||||
another_valid: 'world',
|
||||
blockData: {
|
||||
'block-1': 'hello',
|
||||
'block-2': 'world',
|
||||
},
|
||||
blockNameMapping: {
|
||||
validVar: 'block-1',
|
||||
another_valid: 'block-2',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -321,28 +328,22 @@ describe('Function Execute API Route', () => {
|
||||
it.concurrent(
|
||||
'should handle Gmail webhook data with email addresses containing angle brackets',
|
||||
async () => {
|
||||
const gmailData = {
|
||||
email: {
|
||||
id: '123',
|
||||
from: 'Waleed Latif <waleed@sim.ai>',
|
||||
to: 'User <user@example.com>',
|
||||
subject: 'Test Email',
|
||||
bodyText: 'Hello world',
|
||||
},
|
||||
rawEmail: {
|
||||
id: '123',
|
||||
payload: {
|
||||
headers: [
|
||||
{ name: 'From', value: 'Waleed Latif <waleed@sim.ai>' },
|
||||
{ name: 'To', value: 'User <user@example.com>' },
|
||||
],
|
||||
},
|
||||
},
|
||||
const emailData = {
|
||||
id: '123',
|
||||
from: 'Waleed Latif <waleed@sim.ai>',
|
||||
to: 'User <user@example.com>',
|
||||
subject: 'Test Email',
|
||||
bodyText: 'Hello world',
|
||||
}
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
params: gmailData,
|
||||
blockData: {
|
||||
'block-email': emailData,
|
||||
},
|
||||
blockNameMapping: {
|
||||
email: 'block-email',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
@@ -356,17 +357,20 @@ describe('Function Execute API Route', () => {
|
||||
it.concurrent(
|
||||
'should properly serialize complex email objects with special characters',
|
||||
async () => {
|
||||
const complexEmailData = {
|
||||
email: {
|
||||
from: 'Test User <test@example.com>',
|
||||
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
|
||||
bodyText: 'Text with\nnewlines\tand\ttabs',
|
||||
},
|
||||
const emailData = {
|
||||
from: 'Test User <test@example.com>',
|
||||
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
|
||||
bodyText: 'Text with\nnewlines\tand\ttabs',
|
||||
}
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
params: complexEmailData,
|
||||
blockData: {
|
||||
'block-email': emailData,
|
||||
},
|
||||
blockNameMapping: {
|
||||
email: 'block-email',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
@@ -519,18 +523,23 @@ describe('Function Execute API Route', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON serialization edge cases', async () => {
|
||||
const complexData = {
|
||||
special: 'chars"with\'quotes',
|
||||
unicode: '🎉 Unicode content',
|
||||
nested: {
|
||||
deep: {
|
||||
value: 'test',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <complexData>',
|
||||
params: {
|
||||
complexData: {
|
||||
special: 'chars"with\'quotes',
|
||||
unicode: '🎉 Unicode content',
|
||||
nested: {
|
||||
deep: {
|
||||
value: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
blockData: {
|
||||
'block-complex': complexData,
|
||||
},
|
||||
blockNameMapping: {
|
||||
complexData: 'block-complex',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
||||
import {
|
||||
createEnvVarPattern,
|
||||
createWorkflowVariablePattern,
|
||||
resolveEnvVarReferences,
|
||||
} from '@/executor/utils/reference-validation'
|
||||
import { navigatePath } from '@/executor/variables/resolvers/reference'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
@@ -18,8 +18,8 @@ export const MAX_DURATION = 210
|
||||
|
||||
const logger = createLogger('FunctionExecuteAPI')
|
||||
|
||||
const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
|
||||
const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
|
||||
const E2B_JS_WRAPPER_LINES = 3
|
||||
const E2B_PYTHON_WRAPPER_LINES = 1
|
||||
|
||||
type TypeScriptModule = typeof import('typescript')
|
||||
|
||||
@@ -134,33 +134,21 @@ function extractEnhancedError(
|
||||
if (error.stack) {
|
||||
enhanced.stack = error.stack
|
||||
|
||||
// Parse stack trace to extract line and column information
|
||||
// Handle both compilation errors and runtime errors
|
||||
const stackLines: string[] = error.stack.split('\n')
|
||||
|
||||
for (const line of stackLines) {
|
||||
// Pattern 1: Compilation errors - "user-function.js:6"
|
||||
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||
|
||||
// Pattern 2: Runtime errors - "at user-function.js:5:12"
|
||||
if (!match) {
|
||||
match = line.match(/at\s+user-function\.js:(\d+):(\d+)/)
|
||||
}
|
||||
|
||||
// Pattern 3: Generic patterns for any line containing our filename
|
||||
if (!match) {
|
||||
match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||
}
|
||||
|
||||
if (match) {
|
||||
const stackLine = Number.parseInt(match[1], 10)
|
||||
const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined
|
||||
|
||||
// Adjust line number to account for wrapper code
|
||||
// The user code starts at a specific line in our wrapper
|
||||
const adjustedLine = stackLine - userCodeStartLine + 1
|
||||
|
||||
// Check if this is a syntax error in wrapper code caused by incomplete user code
|
||||
const isWrapperSyntaxError =
|
||||
stackLine > userCodeStartLine &&
|
||||
error.name === 'SyntaxError' &&
|
||||
@@ -168,7 +156,6 @@ function extractEnhancedError(
|
||||
error.message.includes('Unexpected end of input'))
|
||||
|
||||
if (isWrapperSyntaxError && userCode) {
|
||||
// Map wrapper syntax errors to the last line of user code
|
||||
const codeLines = userCode.split('\n')
|
||||
const lastUserLine = codeLines.length
|
||||
enhanced.line = lastUserLine
|
||||
@@ -181,7 +168,6 @@ function extractEnhancedError(
|
||||
enhanced.line = adjustedLine
|
||||
enhanced.column = stackColumn
|
||||
|
||||
// Extract the actual line content from user code
|
||||
if (userCode) {
|
||||
const codeLines = userCode.split('\n')
|
||||
if (adjustedLine <= codeLines.length) {
|
||||
@@ -192,7 +178,6 @@ function extractEnhancedError(
|
||||
}
|
||||
|
||||
if (stackLine <= userCodeStartLine) {
|
||||
// Error is in wrapper code itself
|
||||
enhanced.line = stackLine
|
||||
enhanced.column = stackColumn
|
||||
break
|
||||
@@ -200,7 +185,6 @@ function extractEnhancedError(
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stack trace to show user-relevant information
|
||||
const cleanedStackLines: string[] = stackLines
|
||||
.filter(
|
||||
(line: string) =>
|
||||
@@ -214,9 +198,6 @@ function extractEnhancedError(
|
||||
}
|
||||
}
|
||||
|
||||
// Keep original message without adding error type prefix
|
||||
// The error type will be added later in createUserFriendlyErrorMessage
|
||||
|
||||
return enhanced
|
||||
}
|
||||
|
||||
@@ -231,7 +212,6 @@ function formatE2BError(
|
||||
userCode: string,
|
||||
prologueLineCount: number
|
||||
): { formattedError: string; cleanedOutput: string } {
|
||||
// Calculate line offset based on language and prologue
|
||||
const wrapperLines =
|
||||
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
|
||||
const totalOffset = prologueLineCount + wrapperLines
|
||||
@@ -241,27 +221,20 @@ function formatE2BError(
|
||||
let cleanErrorMsg = ''
|
||||
|
||||
if (language === CodeLanguage.Python) {
|
||||
// Python error format: "Cell In[X], line Y" followed by error details
|
||||
// Extract line number from the Cell reference
|
||||
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
|
||||
if (cellMatch) {
|
||||
const originalLine = Number.parseInt(cellMatch[1], 10)
|
||||
userLine = originalLine - totalOffset
|
||||
}
|
||||
|
||||
// Extract clean error message from the error string
|
||||
// Remove file references like "(detected at line X) (file.py, line Y)"
|
||||
cleanErrorMsg = errorMessage
|
||||
.replace(/\s*\(detected at line \d+\)/g, '')
|
||||
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
|
||||
.trim()
|
||||
} else if (language === CodeLanguage.JavaScript) {
|
||||
// JavaScript error format from E2B: "SyntaxError: /path/file.ts: Message. (line:col)\n\n 9 | ..."
|
||||
// First, extract the error type and message from the first line
|
||||
const firstLineEnd = errorMessage.indexOf('\n')
|
||||
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
|
||||
|
||||
// Parse: "SyntaxError: /home/user/index.ts: Missing semicolon. (11:9)"
|
||||
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
|
||||
if (jsErrorMatch) {
|
||||
cleanErrorType = jsErrorMatch[1]
|
||||
@@ -269,13 +242,11 @@ function formatE2BError(
|
||||
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
|
||||
userLine = originalLine - totalOffset
|
||||
} else {
|
||||
// Fallback: look for line number in the arrow pointer line (> 11 |)
|
||||
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
|
||||
if (arrowMatch) {
|
||||
const originalLine = Number.parseInt(arrowMatch[1], 10)
|
||||
userLine = originalLine - totalOffset
|
||||
}
|
||||
// Try to extract error type and message
|
||||
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
|
||||
if (errorMatch) {
|
||||
cleanErrorType = errorMatch[1]
|
||||
@@ -289,13 +260,11 @@ function formatE2BError(
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final clean error message
|
||||
const finalErrorMsg =
|
||||
cleanErrorType && cleanErrorMsg
|
||||
? `${cleanErrorType}: ${cleanErrorMsg}`
|
||||
: cleanErrorMsg || errorMessage
|
||||
|
||||
// Format with line number if available
|
||||
let formattedError = finalErrorMsg
|
||||
if (userLine && userLine > 0) {
|
||||
const codeLines = userCode.split('\n')
|
||||
@@ -311,7 +280,6 @@ function formatE2BError(
|
||||
}
|
||||
}
|
||||
|
||||
// For stdout, just return the clean error message without the full traceback
|
||||
const cleanedOutput = finalErrorMsg
|
||||
|
||||
return { formattedError, cleanedOutput }
|
||||
@@ -327,7 +295,6 @@ function createUserFriendlyErrorMessage(
|
||||
): string {
|
||||
let errorMessage = enhanced.message
|
||||
|
||||
// Add line information if available
|
||||
if (enhanced.line !== undefined) {
|
||||
let lineInfo = `Line ${enhanced.line}`
|
||||
|
||||
@@ -338,18 +305,14 @@ function createUserFriendlyErrorMessage(
|
||||
|
||||
errorMessage = `${lineInfo} - ${errorMessage}`
|
||||
} else {
|
||||
// If no line number, try to extract it from stack trace for display
|
||||
if (enhanced.stack) {
|
||||
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||
if (stackMatch) {
|
||||
const line = Number.parseInt(stackMatch[1], 10)
|
||||
let lineInfo = `Line ${line}`
|
||||
|
||||
// Try to get line content if we have userCode
|
||||
if (userCode) {
|
||||
const codeLines = userCode.split('\n')
|
||||
// Note: stackMatch gives us VM line number, need to adjust
|
||||
// This is a fallback case, so we might not have perfect line mapping
|
||||
if (line <= codeLines.length) {
|
||||
const lineContent = codeLines[line - 1]?.trim()
|
||||
if (lineContent) {
|
||||
@@ -363,7 +326,6 @@ function createUserFriendlyErrorMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Add error type prefix with consistent naming
|
||||
if (enhanced.name !== 'Error') {
|
||||
const errorTypePrefix =
|
||||
enhanced.name === 'SyntaxError'
|
||||
@@ -374,7 +336,6 @@ function createUserFriendlyErrorMessage(
|
||||
? 'Reference Error'
|
||||
: enhanced.name
|
||||
|
||||
// Only add prefix if not already present
|
||||
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
|
||||
errorMessage = `${errorTypePrefix}: ${errorMessage}`
|
||||
}
|
||||
@@ -383,9 +344,6 @@ function createUserFriendlyErrorMessage(
|
||||
return errorMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves workflow variables with <variable.name> syntax
|
||||
*/
|
||||
function resolveWorkflowVariables(
|
||||
code: string,
|
||||
workflowVariables: Record<string, any>,
|
||||
@@ -405,39 +363,35 @@ function resolveWorkflowVariables(
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
const variableName = match[1].trim()
|
||||
|
||||
// Find the variable by name (workflowVariables is indexed by ID, values are variable objects)
|
||||
const foundVariable = Object.entries(workflowVariables).find(
|
||||
([_, variable]) => normalizeName(variable.name || '') === variableName
|
||||
)
|
||||
|
||||
let variableValue: unknown = ''
|
||||
if (foundVariable) {
|
||||
const variable = foundVariable[1]
|
||||
variableValue = variable.value
|
||||
if (!foundVariable) {
|
||||
const availableVars = Object.values(workflowVariables)
|
||||
.map((v) => v.name)
|
||||
.filter(Boolean)
|
||||
throw new Error(
|
||||
`Variable "${variableName}" doesn't exist.` +
|
||||
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
|
||||
)
|
||||
}
|
||||
|
||||
if (variable.value !== undefined && variable.value !== null) {
|
||||
const variable = foundVariable[1]
|
||||
let variableValue: unknown = variable.value
|
||||
|
||||
if (variable.value !== undefined && variable.value !== null) {
|
||||
const type = variable.type === 'string' ? 'plain' : variable.type
|
||||
|
||||
if (type === 'number') {
|
||||
variableValue = Number(variableValue)
|
||||
} else if (type === 'boolean') {
|
||||
variableValue = variableValue === 'true' || variableValue === true
|
||||
} else if (type === 'json' && typeof variableValue === 'string') {
|
||||
try {
|
||||
// Handle 'string' type the same as 'plain' for backward compatibility
|
||||
const type = variable.type === 'string' ? 'plain' : variable.type
|
||||
|
||||
// For plain text, use exactly what's entered without modifications
|
||||
if (type === 'plain' && typeof variableValue === 'string') {
|
||||
// Use as-is for plain text
|
||||
} else if (type === 'number') {
|
||||
variableValue = Number(variableValue)
|
||||
} else if (type === 'boolean') {
|
||||
variableValue = variableValue === 'true' || variableValue === true
|
||||
} else if (type === 'json') {
|
||||
try {
|
||||
variableValue =
|
||||
typeof variableValue === 'string' ? JSON.parse(variableValue) : variableValue
|
||||
} catch {
|
||||
// Keep original value if JSON parsing fails
|
||||
}
|
||||
}
|
||||
variableValue = JSON.parse(variableValue)
|
||||
} catch {
|
||||
// Fallback to original value on error
|
||||
variableValue = variable.value
|
||||
// Keep as-is
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,11 +404,9 @@ function resolveWorkflowVariables(
|
||||
})
|
||||
}
|
||||
|
||||
// Process replacements in reverse order to maintain correct indices
|
||||
for (let i = replacements.length - 1; i >= 0; i--) {
|
||||
const { match: matchStr, index, variableName, variableValue } = replacements[i]
|
||||
|
||||
// Use variable reference approach
|
||||
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
contextVariables[safeVarName] = variableValue
|
||||
resolvedCode =
|
||||
@@ -464,9 +416,6 @@ function resolveWorkflowVariables(
|
||||
return resolvedCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves environment variables with {{var_name}} syntax
|
||||
*/
|
||||
function resolveEnvironmentVariables(
|
||||
code: string,
|
||||
params: Record<string, any>,
|
||||
@@ -482,32 +431,28 @@ function resolveEnvironmentVariables(
|
||||
|
||||
const resolverVars: Record<string, string> = {}
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
if (value !== undefined && value !== null) {
|
||||
resolverVars[key] = String(value)
|
||||
}
|
||||
})
|
||||
Object.entries(envVars).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
if (value !== undefined && value !== null) {
|
||||
resolverVars[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
const varName = match[1].trim()
|
||||
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
|
||||
allowEmbedded: true,
|
||||
resolveExactMatch: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'empty',
|
||||
deep: false,
|
||||
})
|
||||
const varValue =
|
||||
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
|
||||
|
||||
if (!(varName in resolverVars)) {
|
||||
continue
|
||||
}
|
||||
|
||||
replacements.push({
|
||||
match: match[0],
|
||||
index: match.index,
|
||||
varName,
|
||||
varValue: String(varValue),
|
||||
varValue: resolverVars[varName],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -523,12 +468,8 @@ function resolveEnvironmentVariables(
|
||||
return resolvedCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves tags with <tag_name> syntax (including nested paths like <block.response.data>)
|
||||
*/
|
||||
function resolveTagVariables(
|
||||
code: string,
|
||||
params: Record<string, any>,
|
||||
blockData: Record<string, any>,
|
||||
blockNameMapping: Record<string, string>,
|
||||
contextVariables: Record<string, any>
|
||||
@@ -543,27 +484,30 @@ function resolveTagVariables(
|
||||
|
||||
for (const match of tagMatches) {
|
||||
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
|
||||
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
||||
const blockName = pathParts[0]
|
||||
|
||||
// Handle nested paths like "getrecord.response.data" or "function1.response.result"
|
||||
// First try params, then blockData directly, then try with block name mapping
|
||||
let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || ''
|
||||
|
||||
// If not found and the path starts with a block name, try mapping the block name to ID
|
||||
if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) {
|
||||
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
||||
const normalizedBlockName = pathParts[0] // This should already be normalized like "function1"
|
||||
|
||||
// Direct lookup using normalized block name
|
||||
const blockId = blockNameMapping[normalizedBlockName] ?? null
|
||||
|
||||
if (blockId) {
|
||||
const remainingPath = pathParts.slice(1).join('.')
|
||||
const fullPath = `${blockId}.${remainingPath}`
|
||||
tagValue = getNestedValue(blockData, fullPath) || ''
|
||||
}
|
||||
const blockId = blockNameMapping[blockName]
|
||||
if (!blockId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const blockOutput = blockData[blockId]
|
||||
if (blockOutput === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
let tagValue: any
|
||||
if (pathParts.length === 1) {
|
||||
tagValue = blockOutput
|
||||
} else {
|
||||
tagValue = navigatePath(blockOutput, pathParts.slice(1))
|
||||
}
|
||||
|
||||
if (tagValue === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the value is a stringified JSON, parse it back to object
|
||||
if (
|
||||
typeof tagValue === 'string' &&
|
||||
tagValue.length > 100 &&
|
||||
@@ -571,16 +515,13 @@ function resolveTagVariables(
|
||||
) {
|
||||
try {
|
||||
tagValue = JSON.parse(tagValue)
|
||||
} catch (e) {
|
||||
// Keep as string if parsing fails
|
||||
} catch {
|
||||
// Keep as-is
|
||||
}
|
||||
}
|
||||
|
||||
// Instead of injecting large JSON directly, create a variable reference
|
||||
const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
contextVariables[safeVarName] = tagValue
|
||||
|
||||
// Replace the template with a variable reference
|
||||
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
|
||||
}
|
||||
|
||||
@@ -605,35 +546,13 @@ function resolveCodeVariables(
|
||||
let resolvedCode = code
|
||||
const contextVariables: Record<string, any> = {}
|
||||
|
||||
// Resolve workflow variables with <variable.name> syntax first
|
||||
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
|
||||
|
||||
// Resolve environment variables with {{var_name}} syntax
|
||||
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
|
||||
|
||||
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
|
||||
resolvedCode = resolveTagVariables(
|
||||
resolvedCode,
|
||||
params,
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
contextVariables
|
||||
)
|
||||
resolvedCode = resolveTagVariables(resolvedCode, blockData, blockNameMapping, contextVariables)
|
||||
|
||||
return { resolvedCode, contextVariables }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested value from object using dot notation path
|
||||
*/
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
if (!obj || !path) return undefined
|
||||
|
||||
return path.split('.').reduce((current, key) => {
|
||||
return current && typeof current === 'object' ? current[key] : undefined
|
||||
}, obj)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one trailing newline from stdout
|
||||
* This handles the common case where print() or console.log() adds a trailing \n
|
||||
@@ -671,7 +590,6 @@ export async function POST(req: NextRequest) {
|
||||
isCustomTool = false,
|
||||
} = body
|
||||
|
||||
// Extract internal parameters that shouldn't be passed to the execution context
|
||||
const executionParams = { ...params }
|
||||
executionParams._context = undefined
|
||||
|
||||
@@ -697,7 +615,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||
|
||||
// Extract imports once for JavaScript code (reuse later to avoid double extraction)
|
||||
let jsImports = ''
|
||||
let jsRemainingCode = resolvedCode
|
||||
let hasImports = false
|
||||
@@ -707,31 +624,22 @@ export async function POST(req: NextRequest) {
|
||||
jsImports = extractionResult.imports
|
||||
jsRemainingCode = extractionResult.remainingCode
|
||||
|
||||
// Check for ES6 imports or CommonJS require statements
|
||||
// ES6 imports are extracted by the TypeScript parser
|
||||
// Also check for require() calls which indicate external dependencies
|
||||
const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode)
|
||||
hasImports = jsImports.trim().length > 0 || hasRequireStatements
|
||||
}
|
||||
|
||||
// Python always requires E2B
|
||||
if (lang === CodeLanguage.Python && !isE2bEnabled) {
|
||||
throw new Error(
|
||||
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
|
||||
)
|
||||
}
|
||||
|
||||
// JavaScript with imports requires E2B
|
||||
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
|
||||
throw new Error(
|
||||
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
|
||||
)
|
||||
}
|
||||
|
||||
// Use E2B if:
|
||||
// - E2B is enabled AND
|
||||
// - Not a custom tool AND
|
||||
// - (Python OR JavaScript with imports)
|
||||
const useE2B =
|
||||
isE2bEnabled &&
|
||||
!isCustomTool &&
|
||||
@@ -744,13 +652,10 @@ export async function POST(req: NextRequest) {
|
||||
language: lang,
|
||||
})
|
||||
let prologue = ''
|
||||
const epilogue = ''
|
||||
|
||||
if (lang === CodeLanguage.JavaScript) {
|
||||
// Track prologue lines for error adjustment
|
||||
let prologueLineCount = 0
|
||||
|
||||
// Reuse the imports we already extracted earlier
|
||||
const imports = jsImports
|
||||
const remainingCode = jsRemainingCode
|
||||
|
||||
@@ -782,7 +687,7 @@ export async function POST(req: NextRequest) {
|
||||
' }',
|
||||
'})();',
|
||||
].join('\n')
|
||||
const codeForE2B = importSection + prologue + wrapped + epilogue
|
||||
const codeForE2B = importSection + prologue + wrapped
|
||||
|
||||
const execStart = Date.now()
|
||||
const {
|
||||
@@ -804,7 +709,6 @@ export async function POST(req: NextRequest) {
|
||||
error: e2bError,
|
||||
})
|
||||
|
||||
// If there was an execution error, format it properly
|
||||
if (e2bError) {
|
||||
const { formattedError, cleanedOutput } = formatE2BError(
|
||||
e2bError,
|
||||
@@ -828,7 +732,7 @@ export async function POST(req: NextRequest) {
|
||||
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
|
||||
})
|
||||
}
|
||||
// Track prologue lines for error adjustment
|
||||
|
||||
let prologueLineCount = 0
|
||||
prologue += 'import json\n'
|
||||
prologueLineCount++
|
||||
@@ -846,7 +750,7 @@ export async function POST(req: NextRequest) {
|
||||
'__sim_result__ = __sim_main__()',
|
||||
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
|
||||
].join('\n')
|
||||
const codeForE2B = prologue + wrapped + epilogue
|
||||
const codeForE2B = prologue + wrapped
|
||||
|
||||
const execStart = Date.now()
|
||||
const {
|
||||
@@ -868,7 +772,6 @@ export async function POST(req: NextRequest) {
|
||||
error: e2bError,
|
||||
})
|
||||
|
||||
// If there was an execution error, format it properly
|
||||
if (e2bError) {
|
||||
const { formattedError, cleanedOutput } = formatE2BError(
|
||||
e2bError,
|
||||
@@ -897,7 +800,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const wrapperLines = ['(async () => {', ' try {']
|
||||
if (isCustomTool) {
|
||||
wrapperLines.push(' // For custom tools, make parameters directly accessible')
|
||||
Object.keys(executionParams).forEach((key) => {
|
||||
wrapperLines.push(` const ${key} = params.${key};`)
|
||||
})
|
||||
@@ -931,12 +833,10 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
const ivmError = isolatedResult.error
|
||||
// Adjust line number for prepended param destructuring in custom tools
|
||||
let adjustedLine = ivmError.line
|
||||
let adjustedLineContent = ivmError.lineContent
|
||||
if (prependedLineCount > 0 && ivmError.line !== undefined) {
|
||||
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
|
||||
// Get line content from original user code, not the prepended code
|
||||
const codeLines = resolvedCode.split('\n')
|
||||
if (adjustedLine <= codeLines.length) {
|
||||
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()
|
||||
|
||||
27
apps/sim/app/changelog/components/branded-link.tsx
Normal file
27
apps/sim/app/changelog/components/branded-link.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
interface BrandedLinkProps {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
}
|
||||
|
||||
export function BrandedLink({ href, children, className = '', target, rel }: BrandedLinkProps) {
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all ${className}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BookOpen, Github, Rss } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedLink } from '@/app/changelog/components/branded-link'
|
||||
import ChangelogList from '@/app/changelog/components/timeline-list'
|
||||
|
||||
export interface ChangelogEntry {
|
||||
@@ -66,25 +67,24 @@ export default async function ChangelogContent() {
|
||||
<hr className='mt-6 border-border' />
|
||||
|
||||
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
|
||||
<Link
|
||||
<BrandedLink
|
||||
href='https://github.com/simstudioai/sim/releases'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
|
||||
>
|
||||
<Github className='h-4 w-4' />
|
||||
View on GitHub
|
||||
</Link>
|
||||
</BrandedLink>
|
||||
<Link
|
||||
href='https://docs.sim.ai'
|
||||
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
|
||||
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
|
||||
>
|
||||
<BookOpen className='h-4 w-4' />
|
||||
Documentation
|
||||
</Link>
|
||||
<Link
|
||||
href='/changelog.xml'
|
||||
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
|
||||
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
|
||||
>
|
||||
<Rss className='h-4 w-4' />
|
||||
RSS Feed
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [starCount, setStarCount] = useState('25.1k')
|
||||
const [starCount, setStarCount] = useState('25.8k')
|
||||
const [conversationId, setConversationId] = useState('')
|
||||
|
||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||
|
||||
@@ -207,7 +207,6 @@ function TemplateCardInner({
|
||||
isPannable={false}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
lightweight
|
||||
/>
|
||||
) : (
|
||||
<div className='h-full w-full bg-[var(--surface-4)]' />
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
BlockDetailsSidebar,
|
||||
getLeftmostBlockId,
|
||||
PreviewEditor,
|
||||
WorkflowPreview,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useExecutionSnapshot } from '@/hooks/queries/logs'
|
||||
@@ -248,11 +248,10 @@ export function ExecutionSnapshot({
|
||||
cursorStyle='pointer'
|
||||
executedBlocks={blockExecutions}
|
||||
selectedBlockId={pinnedBlockId}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
|
||||
<BlockDetailsSidebar
|
||||
<PreviewEditor
|
||||
block={workflowState.blocks[pinnedBlockId]}
|
||||
executionData={blockExecutions[pinnedBlockId]}
|
||||
allBlockExecutions={blockExecutions}
|
||||
|
||||
@@ -213,7 +213,6 @@ function TemplateCardInner({
|
||||
isPannable={false}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
lightweight
|
||||
cursorStyle='pointer'
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { getUniqueBlockName, prepareDuplicateBlockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
|
||||
@@ -48,29 +48,38 @@ export const ActionBar = memo(
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
} = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry()
|
||||
const { setPendingSelection } = useWorkflowRegistry()
|
||||
|
||||
const addNotification = useNotificationStore((s) => s.addNotification)
|
||||
|
||||
const handleDuplicateBlock = useCallback(() => {
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
const sourceBlock = blocks[blockId]
|
||||
if (!sourceBlock) return
|
||||
const { copyBlocks, preparePasteData, activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
const existingBlocks = useWorkflowStore.getState().blocks
|
||||
copyBlocks([blockId])
|
||||
|
||||
const newId = crypto.randomUUID()
|
||||
const newName = getUniqueBlockName(sourceBlock.name, blocks)
|
||||
const subBlockValues =
|
||||
useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {}
|
||||
const pasteData = preparePasteData(DEFAULT_DUPLICATE_OFFSET)
|
||||
if (!pasteData) return
|
||||
|
||||
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
|
||||
sourceBlock,
|
||||
newId,
|
||||
newName,
|
||||
positionOffset: DEFAULT_DUPLICATE_OFFSET,
|
||||
subBlockValues,
|
||||
})
|
||||
const blocks = Object.values(pasteData.blocks)
|
||||
const validation = validateTriggerPaste(blocks, existingBlocks, 'duplicate')
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setPendingSelection([newId])
|
||||
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
|
||||
}, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection])
|
||||
setPendingSelection(blocks.map((b) => b.id))
|
||||
collaborativeBatchAddBlocks(
|
||||
blocks,
|
||||
pasteData.edges,
|
||||
pasteData.loops,
|
||||
pasteData.parallels,
|
||||
pasteData.subBlockValues
|
||||
)
|
||||
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
|
||||
|
||||
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
|
||||
useCallback(
|
||||
|
||||
@@ -138,18 +138,24 @@ export const Notifications = memo(function Notifications() {
|
||||
}`}
|
||||
>
|
||||
<div className='flex h-full flex-col justify-between px-[8px] pt-[6px] pb-[8px]'>
|
||||
<div
|
||||
className={`font-medium text-[12px] leading-[16px] ${
|
||||
hasAction ? 'line-clamp-2' : 'line-clamp-4'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<div
|
||||
className={`min-w-0 flex-1 font-medium text-[12px] leading-[16px] ${
|
||||
hasAction ? 'line-clamp-2' : 'line-clamp-4'
|
||||
}`}
|
||||
>
|
||||
{notification.level === 'error' && (
|
||||
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
||||
)}
|
||||
{notification.message}
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
aria-label='Dismiss notification'
|
||||
className='!p-1.5 -m-1.5 float-right ml-[16px]'
|
||||
className='!p-1.5 -m-1.5 shrink-0'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
@@ -158,10 +164,6 @@ export const Notifications = memo(function Notifications() {
|
||||
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{notification.level === 'error' && (
|
||||
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
||||
)}
|
||||
{notification.message}
|
||||
</div>
|
||||
{hasAction && (
|
||||
<Button
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
BlockDetailsSidebar,
|
||||
getLeftmostBlockId,
|
||||
PreviewEditor,
|
||||
WorkflowPreview,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
|
||||
@@ -337,7 +337,7 @@ export function GeneralDeploy({
|
||||
/>
|
||||
</div>
|
||||
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
||||
<BlockDetailsSidebar
|
||||
<PreviewEditor
|
||||
block={workflowToShow.blocks[expandedSelectedBlockId]}
|
||||
workflowVariables={workflowToShow.variables}
|
||||
loops={workflowToShow.loops}
|
||||
|
||||
@@ -446,7 +446,6 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
isPannable={false}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -35,9 +35,9 @@ import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
|
||||
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 { GenerationType } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
|
||||
const logger = createLogger('Code')
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
@@ -102,7 +103,8 @@ export const ComboBox = memo(function ComboBox({
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
const dependencyValues = useSubBlockStore(
|
||||
const dependencyValues = useStoreWithEqualityFn(
|
||||
useSubBlockStore,
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
||||
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('ConditionInput')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
@@ -100,7 +101,8 @@ export const Dropdown = memo(function Dropdown({
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
const dependencyValues = useSubBlockStore(
|
||||
const dependencyValues = useStoreWithEqualityFn(
|
||||
useSubBlockStore,
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { splitReferenceSegment } from '@/lib/workflows/sanitization/references'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
import { normalizeName, REFERENCE } from '@/executor/constants'
|
||||
import { createCombinedPattern } from '@/executor/utils/reference-validation'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
|
||||
export interface HighlightContext {
|
||||
accessiblePrefixes?: Set<string>
|
||||
|
||||
@@ -34,3 +34,4 @@ export { Text } from './text/text'
|
||||
export { TimeInput } from './time-input/time-input'
|
||||
export { ToolInput } from './tool-input/tool-input'
|
||||
export { VariablesInput } from './variables-input/variables-input'
|
||||
export { WorkflowSelectorInput } from './workflow-selector/workflow-selector-input'
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useMemo, useRef, useState } from 'react'
|
||||
import { Badge, Input } from '@/components/emcn'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
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 { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useWorkflowInputFields } from '@/hooks/queries/workflows'
|
||||
import { useWorkflowState } from '@/hooks/queries/workflows'
|
||||
|
||||
/**
|
||||
* Props for the InputMappingField component
|
||||
@@ -70,7 +71,11 @@ export function InputMapping({
|
||||
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
|
||||
const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined
|
||||
const { data: childInputFields = [], isLoading } = useWorkflowInputFields(workflowId)
|
||||
const { data: workflowState, isLoading } = useWorkflowState(workflowId)
|
||||
const childInputFields = useMemo(
|
||||
() => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []),
|
||||
[workflowState?.blocks]
|
||||
)
|
||||
const [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({})
|
||||
|
||||
const valueObj: Record<string, string> = useMemo(() => {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
@@ -382,93 +390,138 @@ export function MessagesInput({
|
||||
textareaRefs.current[fieldId]?.focus()
|
||||
}, [])
|
||||
|
||||
const autoResizeTextarea = useCallback((fieldId: string) => {
|
||||
const syncOverlay = useCallback((fieldId: string) => {
|
||||
const textarea = textareaRefs.current[fieldId]
|
||||
if (!textarea) return
|
||||
const overlay = overlayRefs.current[fieldId]
|
||||
if (!textarea || !overlay) return
|
||||
|
||||
// If user has manually resized, respect their chosen height and only sync overlay.
|
||||
if (userResizedRef.current[fieldId]) {
|
||||
const currentHeight =
|
||||
textarea.offsetHeight || Number.parseFloat(textarea.style.height) || MIN_TEXTAREA_HEIGHT_PX
|
||||
const clampedHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, currentHeight)
|
||||
textarea.style.height = `${clampedHeight}px`
|
||||
if (overlay) {
|
||||
overlay.style.height = `${clampedHeight}px`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
const naturalHeight = textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
|
||||
const nextHeight = Math.min(
|
||||
MAX_TEXTAREA_HEIGHT_PX,
|
||||
Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight)
|
||||
)
|
||||
textarea.style.height = `${nextHeight}px`
|
||||
|
||||
if (overlay) {
|
||||
overlay.style.height = `${nextHeight}px`
|
||||
}
|
||||
overlay.style.width = `${textarea.clientWidth}px`
|
||||
overlay.scrollTop = textarea.scrollTop
|
||||
overlay.scrollLeft = textarea.scrollLeft
|
||||
}, [])
|
||||
|
||||
const handleResizeStart = useCallback((fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const autoResizeTextarea = useCallback(
|
||||
(fieldId: string) => {
|
||||
const textarea = textareaRefs.current[fieldId]
|
||||
const overlay = overlayRefs.current[fieldId]
|
||||
if (!textarea) return
|
||||
|
||||
const textarea = textareaRefs.current[fieldId]
|
||||
if (!textarea) return
|
||||
|
||||
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
|
||||
|
||||
isResizingRef.current = true
|
||||
resizeStateRef.current = {
|
||||
fieldId,
|
||||
startY: e.clientY,
|
||||
startHeight,
|
||||
}
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isResizingRef.current || !resizeStateRef.current) return
|
||||
|
||||
const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY)
|
||||
|
||||
const activeTextarea = textareaRefs.current[activeFieldId]
|
||||
if (activeTextarea) {
|
||||
activeTextarea.style.height = `${nextHeight}px`
|
||||
if (!textarea.value.trim()) {
|
||||
userResizedRef.current[fieldId] = false
|
||||
}
|
||||
|
||||
const overlay = overlayRefs.current[activeFieldId]
|
||||
if (userResizedRef.current[fieldId]) {
|
||||
if (overlay) {
|
||||
overlay.style.height = `${textarea.offsetHeight}px`
|
||||
}
|
||||
syncOverlay(fieldId)
|
||||
return
|
||||
}
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
const scrollHeight = textarea.scrollHeight
|
||||
const height = Math.min(
|
||||
MAX_TEXTAREA_HEIGHT_PX,
|
||||
Math.max(MIN_TEXTAREA_HEIGHT_PX, scrollHeight)
|
||||
)
|
||||
|
||||
textarea.style.height = `${height}px`
|
||||
if (overlay) {
|
||||
overlay.style.height = `${nextHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (resizeStateRef.current) {
|
||||
const { fieldId: activeFieldId } = resizeStateRef.current
|
||||
userResizedRef.current[activeFieldId] = true
|
||||
overlay.style.height = `${height}px`
|
||||
}
|
||||
|
||||
isResizingRef.current = false
|
||||
resizeStateRef.current = null
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
syncOverlay(fieldId)
|
||||
},
|
||||
[syncOverlay]
|
||||
)
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [])
|
||||
const handleResizeStart = useCallback(
|
||||
(fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRefs.current[fieldId]
|
||||
if (!textarea) return
|
||||
|
||||
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
|
||||
|
||||
isResizingRef.current = true
|
||||
resizeStateRef.current = {
|
||||
fieldId,
|
||||
startY: e.clientY,
|
||||
startHeight,
|
||||
}
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isResizingRef.current || !resizeStateRef.current) return
|
||||
|
||||
const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY)
|
||||
|
||||
const activeTextarea = textareaRefs.current[activeFieldId]
|
||||
const overlay = overlayRefs.current[activeFieldId]
|
||||
|
||||
if (activeTextarea) {
|
||||
activeTextarea.style.height = `${nextHeight}px`
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
overlay.style.height = `${nextHeight}px`
|
||||
if (activeTextarea) {
|
||||
overlay.scrollTop = activeTextarea.scrollTop
|
||||
overlay.scrollLeft = activeTextarea.scrollLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (resizeStateRef.current) {
|
||||
const { fieldId: activeFieldId } = resizeStateRef.current
|
||||
userResizedRef.current[activeFieldId] = true
|
||||
syncOverlay(activeFieldId)
|
||||
}
|
||||
|
||||
isResizingRef.current = false
|
||||
resizeStateRef.current = null
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
},
|
||||
[syncOverlay]
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
currentMessages.forEach((_, index) => {
|
||||
const fieldId = `message-${index}`
|
||||
autoResizeTextarea(fieldId)
|
||||
autoResizeTextarea(`message-${index}`)
|
||||
})
|
||||
}, [currentMessages, autoResizeTextarea])
|
||||
|
||||
useEffect(() => {
|
||||
const observers: ResizeObserver[] = []
|
||||
|
||||
for (let i = 0; i < currentMessages.length; i++) {
|
||||
const fieldId = `message-${i}`
|
||||
const textarea = textareaRefs.current[fieldId]
|
||||
const overlay = overlayRefs.current[fieldId]
|
||||
|
||||
if (textarea && overlay) {
|
||||
const observer = new ResizeObserver(() => {
|
||||
overlay.style.width = `${textarea.clientWidth}px`
|
||||
})
|
||||
observer.observe(textarea)
|
||||
observers.push(observer)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
observers.forEach((observer) => observer.disconnect())
|
||||
}
|
||||
}, [currentMessages.length])
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-[10px]'>
|
||||
{currentMessages.map((message, index) => (
|
||||
@@ -621,19 +674,15 @@ export function MessagesInput({
|
||||
</div>
|
||||
|
||||
{/* Content Input with overlay for variable highlighting */}
|
||||
<div className='relative w-full'>
|
||||
<div className='relative w-full overflow-hidden'>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRefs.current[fieldId] = el
|
||||
}}
|
||||
className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed'
|
||||
rows={3}
|
||||
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
|
||||
placeholder='Enter message content...'
|
||||
value={message.content}
|
||||
onChange={(e) => {
|
||||
fieldHandlers.onChange(e)
|
||||
autoResizeTextarea(fieldId)
|
||||
}}
|
||||
onChange={fieldHandlers.onChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Tab' && !isPreview && !disabled) {
|
||||
e.preventDefault()
|
||||
@@ -670,12 +719,13 @@ export function MessagesInput({
|
||||
ref={(el) => {
|
||||
overlayRefs.current[fieldId] = el
|
||||
}}
|
||||
className='scrollbar-none pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]'
|
||||
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
>
|
||||
{formatDisplayText(message.content, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
{message.content.endsWith('\n') && '\u200B'}
|
||||
</div>
|
||||
|
||||
{/* Env var dropdown for this message */}
|
||||
@@ -705,7 +755,7 @@ export function MessagesInput({
|
||||
|
||||
{!isPreview && !disabled && (
|
||||
<div
|
||||
className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
|
||||
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
|
||||
onMouseDown={(e) => handleResizeStart(fieldId, e)}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -35,11 +35,11 @@ import type {
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import type { Variable } from '@/stores/panel'
|
||||
import { useVariablesStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type OAuthProvider,
|
||||
type OAuthService,
|
||||
} from '@/lib/oauth'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
CheckboxList,
|
||||
@@ -65,7 +66,7 @@ import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hoo
|
||||
import {
|
||||
useChildDeploymentStatus,
|
||||
useDeployChildWorkflow,
|
||||
useWorkflowInputFields,
|
||||
useWorkflowState,
|
||||
useWorkflows,
|
||||
} from '@/hooks/queries/workflows'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
@@ -771,7 +772,11 @@ function WorkflowInputMapperSyncWrapper({
|
||||
disabled: boolean
|
||||
workflowId: string
|
||||
}) {
|
||||
const { data: inputFields = [], isLoading } = useWorkflowInputFields(workflowId)
|
||||
const { data: workflowState, isLoading } = useWorkflowState(workflowId)
|
||||
const inputFields = useMemo(
|
||||
() => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []),
|
||||
[workflowState?.blocks]
|
||||
)
|
||||
|
||||
const parsedValue = useMemo(() => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface WorkflowSelectorInputProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
}
|
||||
|
||||
export function WorkflowSelectorInput({
|
||||
blockId,
|
||||
subBlock,
|
||||
disabled = false,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: WorkflowSelectorInputProps) {
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
|
||||
const context: SelectorContext = useMemo(
|
||||
() => ({
|
||||
excludeWorkflowId: activeWorkflowId ?? undefined,
|
||||
}),
|
||||
[activeWorkflowId]
|
||||
)
|
||||
|
||||
return (
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey='sim.workflows'
|
||||
selectorContext={context}
|
||||
disabled={disabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
placeholder={subBlock.placeholder || 'Select workflow...'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
isNonEmptyValue,
|
||||
@@ -151,7 +152,7 @@ export function useDependsOnGate(
|
||||
|
||||
// Get values for all dependency fields (both all and any)
|
||||
// Use isEqual to prevent re-renders when dependency values haven't actually changed
|
||||
const dependencyValuesMap = useSubBlockStore(dependencySelector, isEqual)
|
||||
const dependencyValuesMap = useStoreWithEqualityFn(useSubBlockStore, dependencySelector, isEqual)
|
||||
|
||||
const depsSatisfied = useMemo(() => {
|
||||
// Check all fields (AND logic) - all must be satisfied
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
@@ -58,7 +59,8 @@ export function useSubBlockValue<T = any>(
|
||||
const streamingValueRef = useRef<T | null>(null)
|
||||
const wasStreamingRef = useRef<boolean>(false)
|
||||
|
||||
const storeValue = useSubBlockStore(
|
||||
const storeValue = useStoreWithEqualityFn(
|
||||
useSubBlockStore,
|
||||
useCallback(
|
||||
(state) => {
|
||||
// If the active workflow ID isn't available yet, return undefined so we can fall back to initialValue
|
||||
@@ -92,7 +94,8 @@ export function useSubBlockValue<T = any>(
|
||||
|
||||
// Always call this hook unconditionally - don't wrap it in a condition
|
||||
// Optimized: only re-render if model value actually changes
|
||||
const modelSubBlockValue = useSubBlockStore(
|
||||
const modelSubBlockValue = useStoreWithEqualityFn(
|
||||
useSubBlockStore,
|
||||
useCallback((state) => (blockId ? state.getValue(blockId, 'model') : null), [blockId]),
|
||||
(a, b) => a === b
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
TimeInput,
|
||||
ToolInput,
|
||||
VariablesInput,
|
||||
WorkflowSelectorInput,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
@@ -90,7 +91,6 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
|
||||
if (!config.required) return false
|
||||
if (typeof config.required === 'boolean') return config.required
|
||||
|
||||
// Helper function to evaluate a condition
|
||||
const evalCond = (
|
||||
cond: {
|
||||
field: string
|
||||
@@ -132,7 +132,6 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
|
||||
return match
|
||||
}
|
||||
|
||||
// If required is a condition object or function, evaluate it
|
||||
const condition = typeof config.required === 'function' ? config.required() : config.required
|
||||
return evalCond(condition, subBlockValues || {})
|
||||
}
|
||||
@@ -378,7 +377,6 @@ function SubBlockComponent({
|
||||
setIsValidJson(isValid)
|
||||
}
|
||||
|
||||
// Check if wand is enabled for this sub-block
|
||||
const isWandEnabled = config.wandConfig?.enabled ?? false
|
||||
|
||||
/**
|
||||
@@ -438,8 +436,6 @@ function SubBlockComponent({
|
||||
| null
|
||||
| undefined
|
||||
|
||||
// Use dependsOn gating to compute final disabled state
|
||||
// Only pass previewContextValues when in preview mode to avoid format mismatches
|
||||
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
|
||||
disabled,
|
||||
isPreview,
|
||||
@@ -869,6 +865,17 @@ function SubBlockComponent({
|
||||
/>
|
||||
)
|
||||
|
||||
case 'workflow-selector':
|
||||
return (
|
||||
<WorkflowSelectorInput
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as string | null}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'mcp-server-selector':
|
||||
return (
|
||||
<McpServerSelector
|
||||
|
||||
@@ -68,7 +68,7 @@ export function SubflowEditor({
|
||||
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
|
||||
{/* Subflow Editor Section */}
|
||||
<div 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-[5px] pb-[8px]'>
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[9px] pb-[8px]'>
|
||||
{/* Type Selection */}
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
|
||||
@@ -2,8 +2,18 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
@@ -28,8 +38,10 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockType } from '@/blocks/types'
|
||||
import { useWorkflowState } from '@/hooks/queries/workflows'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -84,6 +96,14 @@ export function Editor() {
|
||||
// Get subflow display properties from configs
|
||||
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
|
||||
|
||||
// Check if selected block is a workflow block
|
||||
const isWorkflowBlock =
|
||||
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
|
||||
|
||||
// Get workspace ID from params
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
// Refs for resize functionality
|
||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -99,7 +119,8 @@ export function Editor() {
|
||||
currentWorkflow.isSnapshotView
|
||||
)
|
||||
|
||||
const blockSubBlockValues = useSubBlockStore(
|
||||
const blockSubBlockValues = useStoreWithEqualityFn(
|
||||
useSubBlockStore,
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!activeWorkflowId || !currentBlockId) return EMPTY_SUBBLOCK_VALUES
|
||||
@@ -252,11 +273,11 @@ export function Editor() {
|
||||
|
||||
// Trigger rename mode when signaled from context menu
|
||||
useEffect(() => {
|
||||
if (shouldFocusRename && currentBlock && !isSubflow) {
|
||||
if (shouldFocusRename && currentBlock) {
|
||||
handleStartRename()
|
||||
setShouldFocusRename(false)
|
||||
}
|
||||
}, [shouldFocusRename, currentBlock, isSubflow, handleStartRename, setShouldFocusRename])
|
||||
}, [shouldFocusRename, currentBlock, handleStartRename, setShouldFocusRename])
|
||||
|
||||
/**
|
||||
* Handles opening documentation link in a new secure tab.
|
||||
@@ -268,6 +289,22 @@ export function Editor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get child workflow ID for workflow blocks
|
||||
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null
|
||||
|
||||
// Fetch child workflow state for preview (only for workflow blocks with a selected workflow)
|
||||
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } =
|
||||
useWorkflowState(childWorkflowId)
|
||||
|
||||
/**
|
||||
* Handles opening the child workflow in a new tab.
|
||||
*/
|
||||
const handleOpenChildWorkflow = useCallback(() => {
|
||||
if (childWorkflowId && workspaceId) {
|
||||
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}, [childWorkflowId, workspaceId])
|
||||
|
||||
// Determine if connections are at minimum height (collapsed state)
|
||||
const isConnectionsAtMinHeight = connectionsHeight <= 35
|
||||
|
||||
@@ -320,7 +357,7 @@ export function Editor() {
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||
{/* Rename button */}
|
||||
{currentBlock && !isSubflow && (
|
||||
{currentBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -406,7 +443,66 @@ export function Editor() {
|
||||
className='subblocks-section flex flex-1 flex-col overflow-hidden'
|
||||
>
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px] [overflow-anchor:none]'>
|
||||
{subBlocks.length === 0 ? (
|
||||
{/* Workflow Preview - only for workflow blocks with a selected child workflow */}
|
||||
{isWorkflowBlock && childWorkflowId && (
|
||||
<>
|
||||
<div className='subblock-content flex flex-col gap-[9.5px]'>
|
||||
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)] leading-none'>
|
||||
Workflow Preview
|
||||
</div>
|
||||
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
|
||||
{isLoadingChildWorkflow ? (
|
||||
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
|
||||
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
) : childWorkflowState ? (
|
||||
<>
|
||||
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
|
||||
<WorkflowPreview
|
||||
workflowState={childWorkflowState}
|
||||
height={160}
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
defaultZoom={0.6}
|
||||
fitPadding={0.15}
|
||||
cursorStyle='grab'
|
||||
/>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={handleOpenChildWorkflow}
|
||||
className='absolute right-[6px] bottom-[6px] z-10 h-[24px] w-[24px] cursor-pointer border border-[var(--border)] bg-[var(--surface-2)] p-0 hover:bg-[var(--surface-4)]'
|
||||
>
|
||||
<ExternalLink className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Open workflow</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
|
||||
<span className='text-[13px] text-[var(--text-tertiary)]'>
|
||||
Unable to load preview
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{subBlocks.length === 0 && !isWorkflowBlock ? (
|
||||
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
|
||||
This block has no subblocks
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -13,35 +12,26 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
*/
|
||||
export function useEditorBlockProperties(blockId: string | null, isSnapshotView: boolean) {
|
||||
const normalBlockProps = useWorkflowStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!blockId) return { advancedMode: false, triggerMode: false }
|
||||
const block = state.blocks?.[blockId]
|
||||
return {
|
||||
advancedMode: block?.advancedMode ?? false,
|
||||
triggerMode: block?.triggerMode ?? false,
|
||||
}
|
||||
},
|
||||
[blockId]
|
||||
),
|
||||
shallow
|
||||
useShallow((state) => {
|
||||
if (!blockId) return { advancedMode: false, triggerMode: false }
|
||||
const block = state.blocks?.[blockId]
|
||||
return {
|
||||
advancedMode: block?.advancedMode ?? false,
|
||||
triggerMode: block?.triggerMode ?? false,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const baselineBlockProps = useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!blockId) return { advancedMode: false, triggerMode: false }
|
||||
const block = state.baselineWorkflow?.blocks?.[blockId]
|
||||
return {
|
||||
advancedMode: block?.advancedMode ?? false,
|
||||
triggerMode: block?.triggerMode ?? false,
|
||||
}
|
||||
},
|
||||
[blockId]
|
||||
),
|
||||
shallow
|
||||
useShallow((state) => {
|
||||
if (!blockId) return { advancedMode: false, triggerMode: false }
|
||||
const block = state.baselineWorkflow?.blocks?.[blockId]
|
||||
return {
|
||||
advancedMode: block?.advancedMode ?? false,
|
||||
triggerMode: block?.triggerMode ?? false,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Use the appropriate props based on view mode
|
||||
return isSnapshotView ? baselineBlockProps : normalBlockProps
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { Badge, Tooltip } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -526,7 +527,8 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
* Subscribe only to variables for this workflow to avoid re-renders from other workflows.
|
||||
* Uses isEqual for deep comparison since Object.fromEntries creates a new object each time.
|
||||
*/
|
||||
const workflowVariables = useVariablesStore(
|
||||
const workflowVariables = useStoreWithEqualityFn(
|
||||
useVariablesStore,
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!workflowId) return {}
|
||||
@@ -729,7 +731,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
const isStarterBlock = type === 'starter'
|
||||
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
|
||||
|
||||
const blockSubBlockValues = useSubBlockStore(
|
||||
const blockSubBlockValues = useStoreWithEqualityFn(
|
||||
useSubBlockStore,
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!activeWorkflowId) return EMPTY_SUBBLOCK_VALUES
|
||||
|
||||
@@ -21,11 +21,9 @@ interface WorkflowPreviewBlockData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight block component for workflow previews.
|
||||
* Renders block header, dummy subblocks skeleton, and handles.
|
||||
* Respects horizontalHandles and enabled state from workflow.
|
||||
* No heavy hooks, store subscriptions, or interactive features.
|
||||
* Used in template cards and other preview contexts for performance.
|
||||
* Preview block component for workflow visualization.
|
||||
* Renders block header, subblocks skeleton, and handles without
|
||||
* hooks, store subscriptions, or interactive features.
|
||||
*/
|
||||
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
|
||||
const {
|
||||
|
||||
@@ -685,7 +685,7 @@ interface WorkflowVariable {
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface BlockDetailsSidebarProps {
|
||||
interface PreviewEditorProps {
|
||||
block: BlockState
|
||||
executionData?: ExecutionData
|
||||
/** All block execution data for resolving variable references */
|
||||
@@ -722,7 +722,7 @@ const DEFAULT_CONNECTIONS_HEIGHT = 150
|
||||
/**
|
||||
* Readonly sidebar panel showing block configuration using SubBlock components.
|
||||
*/
|
||||
function BlockDetailsSidebarContent({
|
||||
function PreviewEditorContent({
|
||||
block,
|
||||
executionData,
|
||||
allBlockExecutions,
|
||||
@@ -732,7 +732,7 @@ function BlockDetailsSidebarContent({
|
||||
parallels,
|
||||
isExecutionMode = false,
|
||||
onClose,
|
||||
}: BlockDetailsSidebarProps) {
|
||||
}: PreviewEditorProps) {
|
||||
// Convert Record<string, Variable> to Array<Variable> for iteration
|
||||
const normalizedWorkflowVariables = useMemo(() => {
|
||||
if (!workflowVariables) return []
|
||||
@@ -998,6 +998,22 @@ function BlockDetailsSidebarContent({
|
||||
})
|
||||
}, [extractedRefs.envVars])
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
|
||||
if (entry && typeof entry === 'object' && 'value' in entry) {
|
||||
acc[key] = (entry as { value: unknown }).value
|
||||
} else {
|
||||
acc[key] = entry
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}, [subBlockValues])
|
||||
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
|
||||
// Check if this is a subflow block (loop or parallel)
|
||||
const isSubflow = block.type === 'loop' || block.type === 'parallel'
|
||||
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
|
||||
@@ -1079,21 +1095,6 @@ function BlockDetailsSidebarContent({
|
||||
)
|
||||
}
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
|
||||
if (entry && typeof entry === 'object' && 'value' in entry) {
|
||||
acc[key] = (entry as { value: unknown }).value
|
||||
} else {
|
||||
acc[key] = entry
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}, [subBlockValues])
|
||||
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig.subBlocks),
|
||||
[blockConfig.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = block.data?.canonicalModes
|
||||
const effectiveAdvanced =
|
||||
(block.advancedMode ?? false) ||
|
||||
@@ -1371,12 +1372,12 @@ function BlockDetailsSidebarContent({
|
||||
}
|
||||
|
||||
/**
|
||||
* Block details sidebar wrapped in ReactFlowProvider for hook compatibility.
|
||||
* Preview editor wrapped in ReactFlowProvider for hook compatibility.
|
||||
*/
|
||||
export function BlockDetailsSidebar(props: BlockDetailsSidebarProps) {
|
||||
export function PreviewEditor(props: PreviewEditorProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<BlockDetailsSidebarContent {...props} />
|
||||
<PreviewEditorContent {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
@@ -15,10 +15,9 @@ interface WorkflowPreviewSubflowData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight subflow component for workflow previews.
|
||||
* Matches the styling of the actual SubflowNodeComponent but without
|
||||
* hooks, store subscriptions, or interactive features.
|
||||
* Used in template cards and other preview contexts for performance.
|
||||
* Preview subflow component for workflow visualization.
|
||||
* Renders loop/parallel containers without hooks, store subscriptions,
|
||||
* or interactive features.
|
||||
*/
|
||||
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
|
||||
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { BlockDetailsSidebar } from './components/block-details-sidebar'
|
||||
export { PreviewEditor } from './components/preview-editor'
|
||||
export { getLeftmostBlockId, WorkflowPreview } from './preview'
|
||||
|
||||
@@ -15,14 +15,10 @@ import 'reactflow/dist/style.css'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
|
||||
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowPreview')
|
||||
@@ -134,8 +130,6 @@ interface WorkflowPreviewProps {
|
||||
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
||||
/** Callback when the canvas (empty area) is clicked */
|
||||
onPaneClick?: () => void
|
||||
/** Use lightweight blocks for better performance in template cards */
|
||||
lightweight?: boolean
|
||||
/** Cursor style to show when hovering the canvas */
|
||||
cursorStyle?: 'default' | 'pointer' | 'grab'
|
||||
/** Map of executed block IDs to their status for highlighting the execution path */
|
||||
@@ -145,19 +139,10 @@ interface WorkflowPreviewProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Full node types with interactive WorkflowBlock for detailed previews
|
||||
* Preview node types using minimal components without hooks or store subscriptions.
|
||||
* This prevents interaction issues while allowing canvas panning and node clicking.
|
||||
*/
|
||||
const fullNodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowBlock,
|
||||
noteBlock: NoteBlock,
|
||||
subflowNode: SubflowNodeComponent,
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight node types for template cards and other high-volume previews.
|
||||
* Uses minimal components without hooks or store subscriptions.
|
||||
*/
|
||||
const lightweightNodeTypes: NodeTypes = {
|
||||
const previewNodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowPreviewBlock,
|
||||
noteBlock: WorkflowPreviewBlock,
|
||||
subflowNode: WorkflowPreviewSubflow,
|
||||
@@ -172,17 +157,19 @@ const edgeTypes: EdgeTypes = {
|
||||
interface FitViewOnChangeProps {
|
||||
nodeIds: string
|
||||
fitPadding: number
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper component that calls fitView when the set of nodes changes.
|
||||
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
|
||||
* Only triggers on actual node additions/removals, not on selection changes.
|
||||
* Must be rendered inside ReactFlowProvider.
|
||||
*/
|
||||
function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
|
||||
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
|
||||
const { fitView } = useReactFlow()
|
||||
const lastNodeIdsRef = useRef<string | null>(null)
|
||||
|
||||
// Fit view when nodes change
|
||||
useEffect(() => {
|
||||
if (!nodeIds.length) return
|
||||
const shouldFit = lastNodeIdsRef.current !== nodeIds
|
||||
@@ -195,6 +182,27 @@ function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [nodeIds, fitPadding, fitView])
|
||||
|
||||
// Fit view when container resizes (debounced to avoid excessive calls during drag)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => {
|
||||
fitView({ padding: fitPadding, duration: 150 })
|
||||
}, 100)
|
||||
})
|
||||
|
||||
resizeObserver.observe(container)
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [containerRef, fitPadding, fitView])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -210,12 +218,12 @@ export function WorkflowPreview({
|
||||
onNodeClick,
|
||||
onNodeContextMenu,
|
||||
onPaneClick,
|
||||
lightweight = false,
|
||||
cursorStyle = 'grab',
|
||||
executedBlocks,
|
||||
selectedBlockId,
|
||||
}: WorkflowPreviewProps) {
|
||||
const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const nodeTypes = previewNodeTypes
|
||||
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
||||
|
||||
const blocksStructure = useMemo(() => {
|
||||
@@ -285,119 +293,28 @@ export function WorkflowPreview({
|
||||
|
||||
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
|
||||
|
||||
if (lightweight) {
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const isSelected = selectedBlockId === blockId
|
||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
data: {
|
||||
name: block.name,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
kind: block.type as 'loop' | 'parallel',
|
||||
isPreviewSelected: isSelected,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const isSelected = selectedBlockId === blockId
|
||||
|
||||
let lightweightExecutionStatus: ExecutionStatus | undefined
|
||||
if (executedBlocks) {
|
||||
const blockExecution = executedBlocks[blockId]
|
||||
if (blockExecution) {
|
||||
if (blockExecution.status === 'error') {
|
||||
lightweightExecutionStatus = 'error'
|
||||
} else if (blockExecution.status === 'success') {
|
||||
lightweightExecutionStatus = 'success'
|
||||
} else {
|
||||
lightweightExecutionStatus = 'not-executed'
|
||||
}
|
||||
} else {
|
||||
lightweightExecutionStatus = 'not-executed'
|
||||
}
|
||||
}
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'workflowBlock',
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
// Blocks inside subflows need higher z-index to appear above the container
|
||||
zIndex: block.data?.parentId ? 10 : undefined,
|
||||
data: {
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
isTrigger: block.triggerMode === true,
|
||||
horizontalHandles: block.horizontalHandles ?? false,
|
||||
enabled: block.enabled ?? true,
|
||||
isPreviewSelected: isSelected,
|
||||
executionStatus: lightweightExecutionStatus,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (block.type === 'loop') {
|
||||
// Handle loop/parallel containers
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const isSelected = selectedBlockId === blockId
|
||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
position: absolutePosition,
|
||||
parentId: block.data?.parentId,
|
||||
extent: block.data?.extent || undefined,
|
||||
draggable: false,
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
state: 'valid',
|
||||
isPreview: true,
|
||||
kind: block.type as 'loop' | 'parallel',
|
||||
isPreviewSelected: isSelected,
|
||||
kind: 'loop',
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (block.type === 'parallel') {
|
||||
const isSelected = selectedBlockId === blockId
|
||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
position: absolutePosition,
|
||||
parentId: block.data?.parentId,
|
||||
extent: block.data?.extent || undefined,
|
||||
draggable: false,
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
state: 'valid',
|
||||
isPreview: true,
|
||||
isPreviewSelected: isSelected,
|
||||
kind: 'parallel',
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(block.type)
|
||||
if (!blockConfig) {
|
||||
logger.error(`No configuration found for block type: ${block.type}`, { blockId })
|
||||
return
|
||||
}
|
||||
|
||||
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||
// Handle regular blocks
|
||||
const isSelected = selectedBlockId === blockId
|
||||
|
||||
let executionStatus: ExecutionStatus | undefined
|
||||
if (executedBlocks) {
|
||||
@@ -415,24 +332,20 @@ export function WorkflowPreview({
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = selectedBlockId === blockId
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: nodeType,
|
||||
type: 'workflowBlock',
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
// Blocks inside subflows need higher z-index to appear above the container
|
||||
zIndex: block.data?.parentId ? 10 : undefined,
|
||||
data: {
|
||||
type: block.type,
|
||||
config: blockConfig,
|
||||
name: block.name,
|
||||
blockState: block,
|
||||
canEdit: false,
|
||||
isPreview: true,
|
||||
isTrigger: block.triggerMode === true,
|
||||
horizontalHandles: block.horizontalHandles ?? false,
|
||||
enabled: block.enabled ?? true,
|
||||
isPreviewSelected: isSelected,
|
||||
subBlockValues: block.subBlocks ?? {},
|
||||
executionStatus,
|
||||
},
|
||||
})
|
||||
@@ -445,7 +358,6 @@ export function WorkflowPreview({
|
||||
parallelsStructure,
|
||||
workflowState.blocks,
|
||||
isValidWorkflowState,
|
||||
lightweight,
|
||||
executedBlocks,
|
||||
selectedBlockId,
|
||||
])
|
||||
@@ -503,6 +415,7 @@ export function WorkflowPreview({
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ height, width, backgroundColor: 'var(--bg)' }}
|
||||
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
|
||||
>
|
||||
@@ -513,6 +426,20 @@ export function WorkflowPreview({
|
||||
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
|
||||
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
|
||||
|
||||
/* Active/grabbing cursor when dragging */
|
||||
${
|
||||
cursorStyle === 'grab'
|
||||
? `
|
||||
.preview-mode .react-flow:active { cursor: grabbing; }
|
||||
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
|
||||
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
|
||||
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
|
||||
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
|
||||
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
/* Node cursor - pointer on nodes when onNodeClick is provided */
|
||||
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
|
||||
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
|
||||
@@ -560,7 +487,11 @@ export function WorkflowPreview({
|
||||
}
|
||||
onPaneClick={onPaneClick}
|
||||
/>
|
||||
<FitViewOnChange nodeIds={blocksStructure.ids} fitPadding={fitPadding} />
|
||||
<FitViewOnChange
|
||||
nodeIds={blocksStructure.ids}
|
||||
fitPadding={fitPadding}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</div>
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { WorkflowIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('WorkflowBlock')
|
||||
|
||||
// Helper function to get available workflows for the dropdown
|
||||
const getAvailableWorkflows = (): Array<{ label: string; id: string }> => {
|
||||
try {
|
||||
const { workflows, activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
|
||||
// Filter out the current workflow to prevent recursion
|
||||
const availableWorkflows = Object.entries(workflows)
|
||||
.filter(([id]) => id !== activeWorkflowId)
|
||||
.map(([id, workflow]) => ({
|
||||
label: workflow.name || `Workflow ${id.slice(0, 8)}`,
|
||||
id: id,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
|
||||
return availableWorkflows
|
||||
} catch (error) {
|
||||
logger.error('Error getting available workflows:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const WorkflowBlock: BlockConfig = {
|
||||
type: 'workflow',
|
||||
@@ -38,8 +13,7 @@ export const WorkflowBlock: BlockConfig = {
|
||||
{
|
||||
id: 'workflowId',
|
||||
title: 'Select Workflow',
|
||||
type: 'combobox',
|
||||
options: getAvailableWorkflows,
|
||||
type: 'workflow-selector',
|
||||
placeholder: 'Search workflows...',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import { WorkflowIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const getAvailableWorkflows = (): Array<{ label: string; id: string }> => {
|
||||
try {
|
||||
const { workflows, activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
return Object.entries(workflows)
|
||||
.filter(([id]) => id !== activeWorkflowId)
|
||||
.map(([id, w]) => ({ label: w.name || `Workflow ${id.slice(0, 8)}`, id }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const WorkflowInputBlock: BlockConfig = {
|
||||
type: 'workflow_input',
|
||||
@@ -25,21 +12,19 @@ export const WorkflowInputBlock: BlockConfig = {
|
||||
`,
|
||||
category: 'blocks',
|
||||
docsLink: 'https://docs.sim.ai/blocks/workflow',
|
||||
bgColor: '#6366F1', // Indigo - modern and professional
|
||||
bgColor: '#6366F1',
|
||||
icon: WorkflowIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'workflowId',
|
||||
title: 'Select Workflow',
|
||||
type: 'combobox',
|
||||
options: getAvailableWorkflows,
|
||||
type: 'workflow-selector',
|
||||
placeholder: 'Search workflows...',
|
||||
required: true,
|
||||
},
|
||||
// Renders dynamic mapping UI based on selected child workflow's Start trigger inputFormat
|
||||
{
|
||||
id: 'inputMapping',
|
||||
title: 'Input Mapping',
|
||||
title: 'Inputs',
|
||||
type: 'input-mapping',
|
||||
description:
|
||||
"Map fields defined in the child workflow's Start block to variables/values in this workflow.",
|
||||
|
||||
@@ -347,8 +347,8 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
): Promise<{ schema: any; code: string; title: string } | null> {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const { useCustomToolsStore } = await import('@/stores/custom-tools')
|
||||
const tool = useCustomToolsStore.getState().getTool(customToolId)
|
||||
const { getCustomTool } = await import('@/hooks/queries/custom-tools')
|
||||
const tool = getCustomTool(customToolId, ctx.workspaceId)
|
||||
if (tool) {
|
||||
return {
|
||||
schema: tool.schema,
|
||||
@@ -356,9 +356,9 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
title: tool.title,
|
||||
}
|
||||
}
|
||||
logger.warn(`Custom tool not found in store: ${customToolId}`)
|
||||
logger.warn(`Custom tool not found in cache: ${customToolId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error accessing custom tools store:', { error })
|
||||
logger.error('Error accessing custom tools cache:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/executor/human-in-the-loop/utils'
|
||||
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
|
||||
import { collectBlockData } from '@/executor/utils/block-data'
|
||||
import { parseObjectStrings } from '@/executor/utils/json'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
@@ -265,7 +266,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
||||
|
||||
if (dataMode === 'structured' && inputs.builderData) {
|
||||
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
|
||||
return this.parseObjectStrings(convertedData)
|
||||
return parseObjectStrings(convertedData)
|
||||
}
|
||||
|
||||
return inputs.data || {}
|
||||
@@ -485,29 +486,6 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
||||
)
|
||||
}
|
||||
|
||||
private parseObjectStrings(data: any): any {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return this.parseObjectStrings(parsed)
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
} else if (Array.isArray(data)) {
|
||||
return data.map((item) => this.parseObjectStrings(item))
|
||||
} else if (typeof data === 'object' && data !== null) {
|
||||
const result: any = {}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
result[key] = this.parseObjectStrings(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private parseStatus(status?: string): number {
|
||||
if (!status) return HTTP.STATUS.OK
|
||||
const parsed = Number(status)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { BlockType, HTTP, REFERENCE } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||
import { parseObjectStrings } from '@/executor/utils/json'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('ResponseBlockHandler')
|
||||
@@ -73,7 +74,7 @@ export class ResponseBlockHandler implements BlockHandler {
|
||||
|
||||
if (dataMode === 'structured' && inputs.builderData) {
|
||||
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
|
||||
return this.parseObjectStrings(convertedData)
|
||||
return parseObjectStrings(convertedData)
|
||||
}
|
||||
|
||||
return inputs.data || {}
|
||||
@@ -222,29 +223,6 @@ export class ResponseBlockHandler implements BlockHandler {
|
||||
)
|
||||
}
|
||||
|
||||
private parseObjectStrings(data: any): any {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return this.parseObjectStrings(parsed)
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
} else if (Array.isArray(data)) {
|
||||
return data.map((item) => this.parseObjectStrings(item))
|
||||
} else if (typeof data === 'object' && data !== null) {
|
||||
const result: any = {}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
result[key] = this.parseObjectStrings(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private parseStatus(status?: string): number {
|
||||
if (!status) return HTTP.STATUS.OK
|
||||
const parsed = Number(status)
|
||||
|
||||
@@ -40,3 +40,30 @@ export function isJSONString(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.startsWith('{') || trimmed.startsWith('[')
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively parses JSON strings within an object or array.
|
||||
* Useful for normalizing data that may contain stringified JSON at various levels.
|
||||
*/
|
||||
export function parseObjectStrings(data: unknown): unknown {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return parseObjectStrings(parsed)
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
} else if (Array.isArray(data)) {
|
||||
return data.map((item) => parseObjectStrings(item))
|
||||
} else if (typeof data === 'object' && data !== null) {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
result[key] = parseObjectStrings(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { ResolutionContext } from './reference'
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@/lib/workflows/blocks/block-outputs', () => ({
|
||||
getBlockOutputs: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
function createTestWorkflow(
|
||||
blocks: Array<{
|
||||
id: string
|
||||
@@ -140,16 +144,21 @@ describe('BlockResolver', () => {
|
||||
expect(resolver.resolve('<source.nonexistent>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should throw error for path not in output schema', () => {
|
||||
it.concurrent('should throw error for path not in output schema', async () => {
|
||||
const { getBlockOutputs } = await import('@/lib/workflows/blocks/block-outputs')
|
||||
const mockGetBlockOutputs = vi.mocked(getBlockOutputs)
|
||||
const customOutputs = {
|
||||
validField: { type: 'string', description: 'A valid field' },
|
||||
nested: {
|
||||
child: { type: 'number', description: 'Nested child' },
|
||||
},
|
||||
}
|
||||
mockGetBlockOutputs.mockReturnValue(customOutputs as any)
|
||||
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'source',
|
||||
outputs: {
|
||||
validField: { type: 'string', description: 'A valid field' },
|
||||
nested: {
|
||||
child: { type: 'number', description: 'Nested child' },
|
||||
},
|
||||
},
|
||||
outputs: customOutputs,
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
@@ -161,6 +170,8 @@ describe('BlockResolver', () => {
|
||||
/"invalidField" doesn't exist on block "source"/
|
||||
)
|
||||
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/)
|
||||
|
||||
mockGetBlockOutputs.mockReturnValue({})
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for path in schema but missing in data', () => {
|
||||
@@ -298,45 +309,6 @@ describe('BlockResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tryParseJSON', () => {
|
||||
it.concurrent('should parse valid JSON object string', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.tryParseJSON('{"key": "value"}')).toEqual({ key: 'value' })
|
||||
})
|
||||
|
||||
it.concurrent('should parse valid JSON array string', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.tryParseJSON('[1, 2, 3]')).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it.concurrent('should return original value for non-string input', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
const obj = { key: 'value' }
|
||||
expect(resolver.tryParseJSON(obj)).toBe(obj)
|
||||
expect(resolver.tryParseJSON(123)).toBe(123)
|
||||
expect(resolver.tryParseJSON(null)).toBe(null)
|
||||
})
|
||||
|
||||
it.concurrent('should return original string for non-JSON strings', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.tryParseJSON('plain text')).toBe('plain text')
|
||||
expect(resolver.tryParseJSON('123')).toBe('123')
|
||||
expect(resolver.tryParseJSON('')).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should return original string for invalid JSON', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.tryParseJSON('{invalid json}')).toBe('{invalid json}')
|
||||
expect(resolver.tryParseJSON('[1, 2,')).toBe('[1, 2,')
|
||||
})
|
||||
|
||||
it.concurrent('should handle whitespace around JSON', () => {
|
||||
const resolver = new BlockResolver(createTestWorkflow())
|
||||
expect(resolver.tryParseJSON(' {"key": "value"} ')).toEqual({ key: 'value' })
|
||||
expect(resolver.tryParseJSON('\n[1, 2]\n')).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Response block backwards compatibility', () => {
|
||||
it.concurrent('should resolve new format: <responseBlock.data>', () => {
|
||||
const workflow = createTestWorkflow([
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types'
|
||||
import {
|
||||
isReference,
|
||||
@@ -229,9 +230,15 @@ export class BlockResolver implements Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
const blockType = block?.metadata?.id
|
||||
const params = block?.config?.params as Record<string, unknown> | undefined
|
||||
const subBlocks = params
|
||||
? Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { value: v }]))
|
||||
: undefined
|
||||
const toolId = block?.config?.tool
|
||||
const toolConfig = toolId ? getTool(toolId) : undefined
|
||||
const outputSchema = toolConfig?.outputs ?? block?.outputs
|
||||
const outputSchema =
|
||||
toolConfig?.outputs ?? (blockType ? getBlockOutputs(blockType, subBlocks) : block?.outputs)
|
||||
const schemaFields = getSchemaFieldNames(outputSchema)
|
||||
if (schemaFields.length > 0 && !isPathInOutputSchema(outputSchema, pathParts)) {
|
||||
throw new Error(
|
||||
@@ -336,21 +343,4 @@ export class BlockResolver implements Resolver {
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
tryParseJSON(value: any): any {
|
||||
if (typeof value !== 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.length > 0 && (trimmed.startsWith('{') || trimmed.startsWith('['))) {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { CustomToolDefinition, CustomToolSchema } from '@/stores/custom-tools'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools'
|
||||
import { getQueryClient } from '@/app/_shell/providers/query-provider'
|
||||
|
||||
const logger = createLogger('CustomToolsQueries')
|
||||
const API_ENDPOINT = '/api/tools/custom'
|
||||
|
||||
export interface CustomToolSchema {
|
||||
type: string
|
||||
function: {
|
||||
name: string
|
||||
description?: string
|
||||
parameters: {
|
||||
type: string
|
||||
properties: Record<string, unknown>
|
||||
required?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CustomToolDefinition {
|
||||
id: string
|
||||
workspaceId: string | null
|
||||
userId: string | null
|
||||
title: string
|
||||
schema: CustomToolSchema
|
||||
code: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key factories for custom tools queries
|
||||
*/
|
||||
@@ -64,8 +87,39 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo
|
||||
}
|
||||
}
|
||||
|
||||
function syncCustomToolsToStore(tools: CustomToolDefinition[]) {
|
||||
useCustomToolsStore.getState().setTools(tools)
|
||||
/**
|
||||
* Extract workspaceId from the current URL path
|
||||
* Expected format: /workspace/{workspaceId}/...
|
||||
*/
|
||||
function getWorkspaceIdFromUrl(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const match = window.location.pathname.match(/^\/workspace\/([^/]+)/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all custom tools from the query cache (for non-React code)
|
||||
* If workspaceId is not provided, extracts it from the current URL
|
||||
*/
|
||||
export function getCustomTools(workspaceId?: string): CustomToolDefinition[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
const wsId = workspaceId ?? getWorkspaceIdFromUrl()
|
||||
if (!wsId) return []
|
||||
const queryClient = getQueryClient()
|
||||
return queryClient.getQueryData<CustomToolDefinition[]>(customToolsKeys.list(wsId)) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific custom tool from the query cache by ID or title (for non-React code)
|
||||
* Custom tools are referenced by title in the system (custom_${title}), so title lookup is required.
|
||||
* If workspaceId is not provided, extracts it from the current URL
|
||||
*/
|
||||
export function getCustomTool(
|
||||
identifier: string,
|
||||
workspaceId?: string
|
||||
): CustomToolDefinition | undefined {
|
||||
const tools = getCustomTools(workspaceId)
|
||||
return tools.find((tool) => tool.id === identifier || tool.title === identifier)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,19 +188,13 @@ async function fetchCustomTools(workspaceId: string): Promise<CustomToolDefiniti
|
||||
* Hook to fetch custom tools
|
||||
*/
|
||||
export function useCustomTools(workspaceId: string) {
|
||||
const query = useQuery<CustomToolDefinition[]>({
|
||||
return useQuery<CustomToolDefinition[]>({
|
||||
queryKey: customToolsKeys.list(workspaceId),
|
||||
queryFn: () => fetchCustomTools(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 60 * 1000, // 1 minute - tools don't change frequently
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
if (query.data) {
|
||||
syncCustomToolsToStore(query.data)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format'
|
||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||
import {
|
||||
createOptimisticMutationHandlers,
|
||||
@@ -26,34 +25,35 @@ export const workflowKeys = {
|
||||
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
|
||||
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
|
||||
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
|
||||
inputFields: (workflowId: string | undefined) =>
|
||||
[...workflowKeys.all, 'inputFields', workflowId ?? ''] as const,
|
||||
state: (workflowId: string | undefined) =>
|
||||
[...workflowKeys.all, 'state', workflowId ?? ''] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches workflow input fields from the workflow state.
|
||||
* Fetches workflow state from the API.
|
||||
* Used as the base query for both state preview and input fields extraction.
|
||||
*/
|
||||
async function fetchWorkflowInputFields(workflowId: string): Promise<WorkflowInputField[]> {
|
||||
async function fetchWorkflowState(workflowId: string): Promise<WorkflowState | null> {
|
||||
const response = await fetch(`/api/workflows/${workflowId}`)
|
||||
if (!response.ok) throw new Error('Failed to fetch workflow')
|
||||
const { data } = await response.json()
|
||||
return extractInputFieldsFromBlocks(data?.state?.blocks)
|
||||
return data?.state ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workflow input fields for configuration.
|
||||
* Uses React Query for caching and deduplication.
|
||||
* Hook to fetch workflow state.
|
||||
* Used by workflow blocks to show a preview of the child workflow
|
||||
* and as a base query for input fields extraction.
|
||||
*
|
||||
* @param workflowId - The workflow ID to fetch input fields for
|
||||
* @returns Query result with input fields array
|
||||
* @param workflowId - The workflow ID to fetch state for
|
||||
* @returns Query result with workflow state
|
||||
*/
|
||||
export function useWorkflowInputFields(workflowId: string | undefined) {
|
||||
export function useWorkflowState(workflowId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: workflowKeys.inputFields(workflowId),
|
||||
queryFn: () => fetchWorkflowInputFields(workflowId!),
|
||||
queryKey: workflowKeys.state(workflowId),
|
||||
queryFn: () => fetchWorkflowState(workflowId!),
|
||||
enabled: Boolean(workflowId),
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
@@ -532,13 +532,19 @@ export interface ChildDeploymentStatus {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches deployment status for a child workflow
|
||||
* Fetches deployment status for a child workflow.
|
||||
* Uses Promise.all to fetch status and deployments in parallel for better performance.
|
||||
*/
|
||||
async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDeploymentStatus> {
|
||||
const statusRes = await fetch(`/api/workflows/${workflowId}/status`, {
|
||||
cache: 'no-store',
|
||||
const fetchOptions = {
|
||||
cache: 'no-store' as const,
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
}
|
||||
|
||||
const [statusRes, deploymentsRes] = await Promise.all([
|
||||
fetch(`/api/workflows/${workflowId}/status`, fetchOptions),
|
||||
fetch(`/api/workflows/${workflowId}/deployments`, fetchOptions),
|
||||
])
|
||||
|
||||
if (!statusRes.ok) {
|
||||
throw new Error('Failed to fetch workflow status')
|
||||
@@ -546,11 +552,6 @@ async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDepl
|
||||
|
||||
const statusData = await statusRes.json()
|
||||
|
||||
const deploymentsRes = await fetch(`/api/workflows/${workflowId}/deployments`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
|
||||
let activeVersion: number | null = null
|
||||
if (deploymentsRes.ok) {
|
||||
const deploymentsJson = await deploymentsRes.json()
|
||||
@@ -580,7 +581,7 @@ export function useChildDeploymentStatus(workflowId: string | undefined) {
|
||||
queryKey: workflowKeys.deploymentStatus(workflowId),
|
||||
queryFn: () => fetchChildDeploymentStatus(workflowId!),
|
||||
enabled: Boolean(workflowId),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SelectorOption,
|
||||
SelectorQueryArgs,
|
||||
} from '@/hooks/selectors/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const SELECTOR_STALE = 60 * 1000
|
||||
|
||||
@@ -853,6 +854,36 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
}))
|
||||
},
|
||||
},
|
||||
'sim.workflows': {
|
||||
key: 'sim.workflows',
|
||||
staleTime: 0, // Always fetch fresh from store
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'sim.workflows',
|
||||
context.excludeWorkflowId ?? 'none',
|
||||
],
|
||||
enabled: () => true,
|
||||
fetchList: async ({ context }: SelectorQueryArgs): Promise<SelectorOption[]> => {
|
||||
const { workflows } = useWorkflowRegistry.getState()
|
||||
return Object.entries(workflows)
|
||||
.filter(([id]) => id !== context.excludeWorkflowId)
|
||||
.map(([id, workflow]) => ({
|
||||
id,
|
||||
label: workflow.name || `Workflow ${id.slice(0, 8)}`,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
fetchById: async ({ detailId }: SelectorQueryArgs): Promise<SelectorOption | null> => {
|
||||
if (!detailId) return null
|
||||
const { workflows } = useWorkflowRegistry.getState()
|
||||
const workflow = workflows[detailId]
|
||||
if (!workflow) return null
|
||||
return {
|
||||
id: detailId,
|
||||
label: workflow.name || `Workflow ${detailId.slice(0, 8)}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
|
||||
|
||||
@@ -29,6 +29,7 @@ export type SelectorKey =
|
||||
| 'webflow.sites'
|
||||
| 'webflow.collections'
|
||||
| 'webflow.items'
|
||||
| 'sim.workflows'
|
||||
|
||||
export interface SelectorOption {
|
||||
id: string
|
||||
@@ -52,6 +53,7 @@ export interface SelectorContext {
|
||||
siteId?: string
|
||||
collectionId?: string
|
||||
spreadsheetId?: string
|
||||
excludeWorkflowId?: string
|
||||
}
|
||||
|
||||
export interface SelectorQueryArgs {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useUndoRedo } from '@/hooks/use-undo-redo'
|
||||
import {
|
||||
BLOCK_OPERATIONS,
|
||||
@@ -23,7 +24,7 @@ import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { filterNewEdges, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
|
||||
import { filterNewEdges, mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -112,9 +113,6 @@ export function useCollaborativeWorkflow() {
|
||||
const {
|
||||
isConnected,
|
||||
currentWorkflowId,
|
||||
presenceUsers,
|
||||
joinWorkflow,
|
||||
leaveWorkflow,
|
||||
emitWorkflowOperation,
|
||||
emitSubblockUpdate,
|
||||
emitVariableUpdate,
|
||||
@@ -142,13 +140,7 @@ export function useCollaborativeWorkflow() {
|
||||
// Track if we're applying remote changes to avoid infinite loops
|
||||
const isApplyingRemoteChange = useRef(false)
|
||||
|
||||
// Track last applied position timestamps to prevent out-of-order updates
|
||||
const lastPositionTimestamps = useRef<Map<string, number>>(new Map())
|
||||
|
||||
// Operation queue
|
||||
const {
|
||||
queue,
|
||||
hasOperationError,
|
||||
addToQueue,
|
||||
confirmOperation,
|
||||
failOperation,
|
||||
@@ -160,22 +152,6 @@ export function useCollaborativeWorkflow() {
|
||||
return !!currentWorkflowId && activeWorkflowId === currentWorkflowId
|
||||
}, [currentWorkflowId, activeWorkflowId])
|
||||
|
||||
// Clear position timestamps when switching workflows
|
||||
// Note: Workflow joining is now handled automatically by socket connect event based on URL
|
||||
useEffect(() => {
|
||||
if (activeWorkflowId && currentWorkflowId !== activeWorkflowId) {
|
||||
logger.info(`Active workflow changed to: ${activeWorkflowId}`, {
|
||||
isConnected,
|
||||
currentWorkflowId,
|
||||
activeWorkflowId,
|
||||
presenceUsers: presenceUsers.length,
|
||||
})
|
||||
|
||||
// Clear position timestamps when switching workflows
|
||||
lastPositionTimestamps.current.clear()
|
||||
}
|
||||
}, [activeWorkflowId, isConnected, currentWorkflowId])
|
||||
|
||||
// Register emit functions with operation queue store
|
||||
useEffect(() => {
|
||||
registerEmitFunctions(
|
||||
@@ -1620,15 +1596,8 @@ export function useCollaborativeWorkflow() {
|
||||
)
|
||||
|
||||
return {
|
||||
// Connection status
|
||||
isConnected,
|
||||
currentWorkflowId,
|
||||
presenceUsers,
|
||||
hasOperationError,
|
||||
|
||||
// Workflow management
|
||||
joinWorkflow,
|
||||
leaveWorkflow,
|
||||
|
||||
// Collaborative operations
|
||||
collaborativeBatchUpdatePositions,
|
||||
|
||||
@@ -64,6 +64,8 @@ import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
|
||||
|
||||
const logger = createLogger('Auth')
|
||||
|
||||
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
|
||||
|
||||
const validStripeKey = env.STRIPE_SECRET_KEY
|
||||
|
||||
let stripeClient = null
|
||||
@@ -187,6 +189,10 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
const refreshTokenExpiresAt = isMicrosoftProvider(account.providerId)
|
||||
? getMicrosoftRefreshTokenExpiry()
|
||||
: account.refreshTokenExpiresAt
|
||||
|
||||
await db
|
||||
.update(schema.account)
|
||||
.set({
|
||||
@@ -195,7 +201,7 @@ export const auth = betterAuth({
|
||||
refreshToken: account.refreshToken,
|
||||
idToken: account.idToken,
|
||||
accessTokenExpiresAt: account.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
|
||||
refreshTokenExpiresAt,
|
||||
scope: scopeToStore,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -292,6 +298,13 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
if (isMicrosoftProvider(account.providerId)) {
|
||||
await db
|
||||
.update(schema.account)
|
||||
.set({ refreshTokenExpiresAt: getMicrosoftRefreshTokenExpiry() })
|
||||
.where(eq(schema.account.id, account.id))
|
||||
}
|
||||
|
||||
// Sync webhooks for credential sets after connecting a new credential
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const userMemberships = await db
|
||||
@@ -387,7 +400,6 @@ export const auth = betterAuth({
|
||||
enabled: true,
|
||||
allowDifferentEmails: true,
|
||||
trustedProviders: [
|
||||
// Standard OAuth providers
|
||||
'google',
|
||||
'github',
|
||||
'email-password',
|
||||
@@ -403,8 +415,32 @@ export const auth = betterAuth({
|
||||
'hubspot',
|
||||
'linkedin',
|
||||
'spotify',
|
||||
|
||||
// Common SSO provider patterns
|
||||
'google-email',
|
||||
'google-calendar',
|
||||
'google-drive',
|
||||
'google-docs',
|
||||
'google-sheets',
|
||||
'google-forms',
|
||||
'google-vault',
|
||||
'google-groups',
|
||||
'vertex-ai',
|
||||
'github-repo',
|
||||
'microsoft-teams',
|
||||
'microsoft-excel',
|
||||
'microsoft-planner',
|
||||
'outlook',
|
||||
'onedrive',
|
||||
'sharepoint',
|
||||
'jira',
|
||||
'airtable',
|
||||
'dropbox',
|
||||
'salesforce',
|
||||
'wealthbox',
|
||||
'zoom',
|
||||
'wordpress',
|
||||
'linear',
|
||||
'shopify',
|
||||
'trello',
|
||||
...SSO_TRUSTED_PROVIDERS,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools'
|
||||
import { getCustomTool } from '@/hooks/queries/custom-tools'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -83,17 +83,15 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
getDynamicText: (params, state) => {
|
||||
const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
|
||||
|
||||
// Return undefined if no operation yet - use static defaults
|
||||
if (!operation) return undefined
|
||||
|
||||
// Get tool name from schema, or look it up from the store by toolId
|
||||
let toolName = params?.schema?.function?.name
|
||||
if (!toolName && params?.toolId) {
|
||||
try {
|
||||
const tool = useCustomToolsStore.getState().getTool(params.toolId)
|
||||
const tool = getCustomTool(params.toolId)
|
||||
toolName = tool?.schema?.function?.name
|
||||
} catch {
|
||||
// Ignore errors accessing store
|
||||
// Ignore errors accessing cache
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +166,6 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
* Add operations execute directly without confirmation.
|
||||
*/
|
||||
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
|
||||
// Try currentArgs first, then fall back to store (for when called before execute())
|
||||
const args = this.currentArgs || this.getArgsFromStore()
|
||||
const operation = args?.operation
|
||||
if (operation === 'edit' || operation === 'delete') {
|
||||
@@ -199,12 +196,9 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
|
||||
async execute(args?: ManageCustomToolArgs): Promise<void> {
|
||||
this.currentArgs = args
|
||||
// For add and list operations, execute directly without confirmation
|
||||
// For edit/delete, the copilot store will check hasInterrupt() and wait for confirmation
|
||||
if (args?.operation === 'add' || args?.operation === 'list') {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
// edit/delete will wait for user confirmation via handleAccept
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,7 +216,6 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
|
||||
const { operation, toolId, schema, code } = args
|
||||
|
||||
// Get workspace ID from the workflow registry
|
||||
const { hydration } = useWorkflowRegistry.getState()
|
||||
const workspaceId = hydration.workspaceId
|
||||
if (!workspaceId) {
|
||||
@@ -247,7 +240,6 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
await this.deleteCustomTool({ toolId, workspaceId }, logger)
|
||||
break
|
||||
case 'list':
|
||||
// List operation is read-only, just mark as complete
|
||||
await this.markToolComplete(200, 'Listed custom tools')
|
||||
break
|
||||
default:
|
||||
@@ -326,13 +318,10 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
throw new Error('Tool ID is required for editing a custom tool')
|
||||
}
|
||||
|
||||
// At least one of schema or code must be provided
|
||||
if (!schema && !code) {
|
||||
throw new Error('At least one of schema or code must be provided for editing')
|
||||
}
|
||||
|
||||
// We need to send the full tool data to the API for updates
|
||||
// First, fetch the existing tool to merge with updates
|
||||
const existingResponse = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`)
|
||||
const existingData = await existingResponse.json()
|
||||
|
||||
@@ -345,7 +334,6 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
throw new Error(`Tool with ID ${toolId} not found`)
|
||||
}
|
||||
|
||||
// Merge updates with existing tool - use function name as title
|
||||
const mergedSchema = schema ?? existingTool.schema
|
||||
const updatedTool = {
|
||||
id: toolId,
|
||||
|
||||
@@ -123,6 +123,8 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
|
||||
interface OutputFieldSchema {
|
||||
type: string
|
||||
description?: string
|
||||
properties?: Record<string, OutputFieldSchema>
|
||||
items?: { type: string }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,6 +258,42 @@ function mapSubBlockTypeToSchemaType(type: string): string {
|
||||
return typeMap[type] || 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a single output field schema, including nested properties
|
||||
*/
|
||||
function extractOutputField(def: any): OutputFieldSchema {
|
||||
if (typeof def === 'string') {
|
||||
return { type: def }
|
||||
}
|
||||
|
||||
if (typeof def !== 'object' || def === null) {
|
||||
return { type: 'any' }
|
||||
}
|
||||
|
||||
const field: OutputFieldSchema = {
|
||||
type: def.type || 'any',
|
||||
}
|
||||
|
||||
if (def.description) {
|
||||
field.description = def.description
|
||||
}
|
||||
|
||||
// Include nested properties if present
|
||||
if (def.properties && typeof def.properties === 'object') {
|
||||
field.properties = {}
|
||||
for (const [propKey, propDef] of Object.entries(def.properties)) {
|
||||
field.properties[propKey] = extractOutputField(propDef)
|
||||
}
|
||||
}
|
||||
|
||||
// Include items schema for arrays
|
||||
if (def.items && typeof def.items === 'object') {
|
||||
field.items = { type: def.items.type || 'any' }
|
||||
}
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts trigger outputs from the first available trigger
|
||||
*/
|
||||
@@ -272,15 +310,7 @@ function extractTriggerOutputs(blockConfig: any): Record<string, OutputFieldSche
|
||||
const trigger = getTrigger(triggerId)
|
||||
if (trigger.outputs) {
|
||||
for (const [key, def] of Object.entries(trigger.outputs)) {
|
||||
if (typeof def === 'string') {
|
||||
outputs[key] = { type: def }
|
||||
} else if (typeof def === 'object' && def !== null) {
|
||||
const typedDef = def as { type?: string; description?: string }
|
||||
outputs[key] = {
|
||||
type: typedDef.type || 'any',
|
||||
description: typedDef.description,
|
||||
}
|
||||
}
|
||||
outputs[key] = extractOutputField(def)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,11 +342,7 @@ function extractOutputs(
|
||||
const tool = toolsRegistry[toolId]
|
||||
if (tool?.outputs) {
|
||||
for (const [key, def] of Object.entries(tool.outputs)) {
|
||||
const typedDef = def as { type: string; description?: string }
|
||||
outputs[key] = {
|
||||
type: typedDef.type || 'any',
|
||||
description: typedDef.description,
|
||||
}
|
||||
outputs[key] = extractOutputField(def)
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
@@ -329,15 +355,7 @@ function extractOutputs(
|
||||
// Use block-level outputs
|
||||
if (blockConfig.outputs) {
|
||||
for (const [key, def] of Object.entries(blockConfig.outputs)) {
|
||||
if (typeof def === 'string') {
|
||||
outputs[key] = { type: def }
|
||||
} else if (typeof def === 'object' && def !== null) {
|
||||
const typedDef = def as { type?: string; description?: string }
|
||||
outputs[key] = {
|
||||
type: typedDef.type || 'any',
|
||||
description: typedDef.description,
|
||||
}
|
||||
}
|
||||
outputs[key] = extractOutputField(def)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './microsoft'
|
||||
export * from './oauth'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
|
||||
19
apps/sim/lib/oauth/microsoft.ts
Normal file
19
apps/sim/lib/oauth/microsoft.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
|
||||
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
|
||||
|
||||
export const MICROSOFT_PROVIDERS = new Set([
|
||||
'microsoft-excel',
|
||||
'microsoft-planner',
|
||||
'microsoft-teams',
|
||||
'outlook',
|
||||
'onedrive',
|
||||
'sharepoint',
|
||||
])
|
||||
|
||||
export function isMicrosoftProvider(providerId: string): boolean {
|
||||
return MICROSOFT_PROVIDERS.has(providerId)
|
||||
}
|
||||
|
||||
export function getMicrosoftRefreshTokenExpiry(): Date {
|
||||
return new Date(Date.now() + MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
@@ -835,6 +835,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
||||
clientId,
|
||||
clientSecret,
|
||||
useBasicAuth: true,
|
||||
supportsRefreshTokenRotation: true,
|
||||
}
|
||||
}
|
||||
case 'confluence': {
|
||||
@@ -883,6 +884,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
||||
clientId,
|
||||
clientSecret,
|
||||
useBasicAuth: false,
|
||||
supportsRefreshTokenRotation: true,
|
||||
}
|
||||
}
|
||||
case 'microsoft':
|
||||
@@ -910,6 +912,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
||||
clientId,
|
||||
clientSecret,
|
||||
useBasicAuth: true,
|
||||
supportsRefreshTokenRotation: true,
|
||||
}
|
||||
}
|
||||
case 'dropbox': {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
} from '@/lib/core/utils/response-format'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import {
|
||||
classifyStartBlockType,
|
||||
@@ -305,6 +309,26 @@ export function getBlockOutputs(
|
||||
return getLegacyStarterOutputs(subBlocks)
|
||||
}
|
||||
|
||||
if (blockType === 'agent') {
|
||||
const responseFormatValue = subBlocks?.responseFormat?.value
|
||||
if (responseFormatValue) {
|
||||
const parsed = parseResponseFormatSafely(responseFormatValue, 'agent')
|
||||
if (parsed) {
|
||||
const fields = extractFieldsFromSchema(parsed)
|
||||
if (fields.length > 0) {
|
||||
const outputs: OutputDefinition = {}
|
||||
for (const field of fields) {
|
||||
outputs[field.name] = {
|
||||
type: (field.type || 'any') as any,
|
||||
description: field.description || `Field from Agent: ${field.name}`,
|
||||
}
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseOutputs = { ...(blockConfig.outputs || {}) }
|
||||
const filteredOutputs = filterOutputsByCondition(baseOutputs, subBlocks)
|
||||
return applyInputFormatToOutputs(blockType, blockConfig, subBlocks, filteredOutputs)
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getAllProviderIds,
|
||||
getApiKey,
|
||||
getBaseModelProviders,
|
||||
getCustomTools,
|
||||
getHostedModels,
|
||||
getMaxTemperature,
|
||||
getProvider,
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
shouldBillModelUsage,
|
||||
supportsTemperature,
|
||||
supportsToolUsageControl,
|
||||
transformCustomTool,
|
||||
updateOllamaProviderModels,
|
||||
} from '@/providers/utils'
|
||||
|
||||
@@ -837,51 +835,6 @@ describe('JSON and Structured Output', () => {
|
||||
})
|
||||
|
||||
describe('Tool Management', () => {
|
||||
describe('transformCustomTool', () => {
|
||||
it.concurrent('should transform valid custom tool schema', () => {
|
||||
const customTool = {
|
||||
id: 'test-tool',
|
||||
schema: {
|
||||
function: {
|
||||
name: 'testFunction',
|
||||
description: 'A test function',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: { type: 'string', description: 'Input parameter' },
|
||||
},
|
||||
required: ['input'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = transformCustomTool(customTool)
|
||||
|
||||
expect(result.id).toBe('custom_test-tool')
|
||||
expect(result.name).toBe('testFunction')
|
||||
expect(result.description).toBe('A test function')
|
||||
expect(result.parameters.type).toBe('object')
|
||||
expect(result.parameters.properties).toBeDefined()
|
||||
expect(result.parameters.required).toEqual(['input'])
|
||||
})
|
||||
|
||||
it.concurrent('should throw error for invalid schema', () => {
|
||||
const invalidTool = { id: 'test', schema: null }
|
||||
expect(() => transformCustomTool(invalidTool)).toThrow('Invalid custom tool schema')
|
||||
|
||||
const noFunction = { id: 'test', schema: {} }
|
||||
expect(() => transformCustomTool(noFunction)).toThrow('Invalid custom tool schema')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCustomTools', () => {
|
||||
it.concurrent('should return array of transformed custom tools', () => {
|
||||
const result = getCustomTools()
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareToolsWithUsageControl', () => {
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
updateOllamaModels as updateOllamaModelsInDefinitions,
|
||||
} from '@/providers/models'
|
||||
import type { ProviderId, ProviderToolConfig } from '@/providers/types'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools/store'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
import { mergeToolParameters } from '@/tools/params'
|
||||
|
||||
@@ -419,38 +418,6 @@ export function extractAndParseJSON(content: string): any {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a custom tool schema into a provider tool config
|
||||
*/
|
||||
export function transformCustomTool(customTool: any): ProviderToolConfig {
|
||||
const schema = customTool.schema
|
||||
|
||||
if (!schema || !schema.function) {
|
||||
throw new Error('Invalid custom tool schema')
|
||||
}
|
||||
|
||||
return {
|
||||
id: `custom_${customTool.id}`,
|
||||
name: schema.function.name,
|
||||
description: schema.function.description || '',
|
||||
params: {},
|
||||
parameters: {
|
||||
type: schema.function.parameters.type,
|
||||
properties: schema.function.parameters.properties,
|
||||
required: schema.function.parameters.required || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all available custom tools as provider tool configs
|
||||
*/
|
||||
export function getCustomTools(): ProviderToolConfig[] {
|
||||
const customTools = useCustomToolsStore.getState().getAllTools()
|
||||
|
||||
return customTools.map(transformCustomTool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a block tool into a provider tool config with operation selection
|
||||
*
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export { useCustomToolsStore } from './store'
|
||||
export type {
|
||||
CustomToolDefinition,
|
||||
CustomToolSchema,
|
||||
CustomToolsActions,
|
||||
CustomToolsState,
|
||||
CustomToolsStore,
|
||||
} from './types'
|
||||
@@ -1,36 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import type { CustomToolsState, CustomToolsStore } from './types'
|
||||
|
||||
const logger = createLogger('CustomToolsStore')
|
||||
|
||||
const initialState: CustomToolsState = {
|
||||
tools: [],
|
||||
}
|
||||
|
||||
export const useCustomToolsStore = create<CustomToolsStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setTools: (tools) => {
|
||||
logger.info(`Synced ${tools.length} custom tools`)
|
||||
set({ tools })
|
||||
},
|
||||
|
||||
getTool: (id: string) => {
|
||||
return get().tools.find((tool) => tool.id === id)
|
||||
},
|
||||
|
||||
getAllTools: () => {
|
||||
return get().tools
|
||||
},
|
||||
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: 'custom-tools-store',
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -1,36 +0,0 @@
|
||||
export interface CustomToolSchema {
|
||||
type: string
|
||||
function: {
|
||||
name: string
|
||||
description?: string
|
||||
parameters: {
|
||||
type: string
|
||||
properties: Record<string, any>
|
||||
required?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CustomToolDefinition {
|
||||
id: string
|
||||
workspaceId: string | null
|
||||
userId: string | null
|
||||
title: string
|
||||
schema: CustomToolSchema
|
||||
code: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface CustomToolsState {
|
||||
tools: CustomToolDefinition[]
|
||||
}
|
||||
|
||||
export interface CustomToolsActions {
|
||||
setTools: (tools: CustomToolDefinition[]) => void
|
||||
getTool: (id: string) => CustomToolDefinition | undefined
|
||||
getAllTools: () => CustomToolDefinition[]
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export interface CustomToolsStore extends CustomToolsState, CustomToolsActions {}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useCopilotStore, useVariablesStore } from '@/stores/panel'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
@@ -195,7 +194,6 @@ export {
|
||||
useExecutionStore,
|
||||
useTerminalConsoleStore,
|
||||
useCopilotStore,
|
||||
useCustomToolsStore,
|
||||
useVariablesStore,
|
||||
useSubBlockStore,
|
||||
}
|
||||
@@ -222,7 +220,7 @@ export const resetAllStores = () => {
|
||||
useExecutionStore.getState().reset()
|
||||
useTerminalConsoleStore.setState({ entries: [], isOpen: false })
|
||||
useCopilotStore.setState({ messages: [], isSendingMessage: false, error: null })
|
||||
useCustomToolsStore.getState().reset()
|
||||
// Custom tools are managed by React Query cache, not a Zustand store
|
||||
// Variables store has no tracking to reset; registry hydrates
|
||||
}
|
||||
|
||||
@@ -235,7 +233,6 @@ export const logAllStores = () => {
|
||||
execution: useExecutionStore.getState(),
|
||||
console: useTerminalConsoleStore.getState(),
|
||||
copilot: useCopilotStore.getState(),
|
||||
customTools: useCustomToolsStore.getState(),
|
||||
subBlock: useSubBlockStore.getState(),
|
||||
variables: useVariablesStore.getState(),
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
import type { Variable, VariablesStore } from '@/stores/panel/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
|
||||
const logger = createLogger('VariablesStore')
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import type {
|
||||
Variable,
|
||||
VariablesDimensions,
|
||||
@@ -11,7 +12,6 @@ import type {
|
||||
} from '@/stores/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
|
||||
const logger = createLogger('VariablesModalStore')
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
createStarterBlock,
|
||||
} from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getUniqueBlockName, normalizeName } from './utils'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { getUniqueBlockName } from './utils'
|
||||
|
||||
describe('normalizeName', () => {
|
||||
it.concurrent('should convert to lowercase', () => {
|
||||
|
||||
@@ -17,8 +17,6 @@ import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
|
||||
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
|
||||
|
||||
export { normalizeName }
|
||||
|
||||
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
|
||||
return edgesToAdd.filter((edge) => {
|
||||
if (edge.source === edge.target) return false
|
||||
@@ -139,7 +137,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
|
||||
}
|
||||
} else if (subBlock.defaultValue !== undefined) {
|
||||
initialValue = subBlock.defaultValue
|
||||
} else if (subBlock.type === 'input-format') {
|
||||
} else if (subBlock.type === 'input-format' || subBlock.type === 'response-format') {
|
||||
initialValue = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
|
||||
@@ -7,14 +7,10 @@ import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import {
|
||||
filterNewEdges,
|
||||
getUniqueBlockName,
|
||||
mergeSubblockState,
|
||||
normalizeName,
|
||||
} from '@/stores/workflows/utils'
|
||||
import { filterNewEdges, getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import type {
|
||||
Position,
|
||||
SubBlockState,
|
||||
|
||||
@@ -14,6 +14,54 @@ import {
|
||||
type MockFetchResponse,
|
||||
} from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock custom tools query - must be hoisted before imports
|
||||
vi.mock('@/hooks/queries/custom-tools', () => ({
|
||||
getCustomTool: (toolId: string) => {
|
||||
if (toolId === 'custom-tool-123') {
|
||||
return {
|
||||
id: 'custom-tool-123',
|
||||
title: 'Custom Weather Tool',
|
||||
code: 'return { result: "Weather data" }',
|
||||
schema: {
|
||||
function: {
|
||||
description: 'Get weather information',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string', description: 'City name' },
|
||||
unit: { type: 'string', description: 'Unit (metric/imperial)' },
|
||||
},
|
||||
required: ['location'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
getCustomTools: () => [
|
||||
{
|
||||
id: 'custom-tool-123',
|
||||
title: 'Custom Weather Tool',
|
||||
code: 'return { result: "Weather data" }',
|
||||
schema: {
|
||||
function: {
|
||||
description: 'Get weather information',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string', description: 'City name' },
|
||||
unit: { type: 'string', description: 'Unit (metric/imperial)' },
|
||||
},
|
||||
required: ['location'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
import { executeTool } from '@/tools/index'
|
||||
import { tools } from '@/tools/registry'
|
||||
import { getTool } from '@/tools/utils'
|
||||
@@ -94,73 +142,6 @@ describe('Tools Registry', () => {
|
||||
})
|
||||
|
||||
describe('Custom Tools', () => {
|
||||
beforeEach(() => {
|
||||
vi.mock('@/stores/custom-tools', () => ({
|
||||
useCustomToolsStore: {
|
||||
getState: () => ({
|
||||
getTool: (id: string) => {
|
||||
if (id === 'custom-tool-123') {
|
||||
return {
|
||||
id: 'custom-tool-123',
|
||||
title: 'Custom Weather Tool',
|
||||
code: 'return { result: "Weather data" }',
|
||||
schema: {
|
||||
function: {
|
||||
description: 'Get weather information',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string', description: 'City name' },
|
||||
unit: { type: 'string', description: 'Unit (metric/imperial)' },
|
||||
},
|
||||
required: ['location'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
getAllTools: () => [
|
||||
{
|
||||
id: 'custom-tool-123',
|
||||
title: 'Custom Weather Tool',
|
||||
code: 'return { result: "Weather data" }',
|
||||
schema: {
|
||||
function: {
|
||||
description: 'Get weather information',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string', description: 'City name' },
|
||||
unit: { type: 'string', description: 'Unit (metric/imperial)' },
|
||||
},
|
||||
required: ['location'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/settings/environment', () => ({
|
||||
useEnvironmentStore: {
|
||||
getState: () => ({
|
||||
getAllVariables: () => ({
|
||||
API_KEY: { value: 'test-api-key' },
|
||||
BASE_URL: { value: 'https://test-base-url.com' },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('should get custom tool by ID', () => {
|
||||
const customTool = getTool('custom_custom-tool-123')
|
||||
expect(customTool).toBeDefined()
|
||||
|
||||
@@ -643,13 +643,22 @@ async function executeToolRequest(
|
||||
})
|
||||
|
||||
const responseHeaders = new Headers(secureResponse.headers.toRecord())
|
||||
const bodyBuffer = await secureResponse.arrayBuffer()
|
||||
const nullBodyStatuses = new Set([101, 204, 205, 304])
|
||||
|
||||
response = new Response(bodyBuffer, {
|
||||
status: secureResponse.status,
|
||||
statusText: secureResponse.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
if (nullBodyStatuses.has(secureResponse.status)) {
|
||||
response = new Response(null, {
|
||||
status: secureResponse.status,
|
||||
statusText: secureResponse.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
} else {
|
||||
const bodyBuffer = await secureResponse.arrayBuffer()
|
||||
response = new Response(bodyBuffer, {
|
||||
status: secureResponse.status,
|
||||
statusText: secureResponse.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// For non-OK responses, attempt JSON first; if parsing fails, fall back to text
|
||||
@@ -693,11 +702,9 @@ async function executeToolRequest(
|
||||
throw errorToTransform
|
||||
}
|
||||
|
||||
// Parse response data once with guard for empty 202 bodies
|
||||
let responseData
|
||||
const status = response.status
|
||||
if (status === 202) {
|
||||
// Many APIs (e.g., Microsoft Graph) return 202 with empty body
|
||||
if (status === 202 || status === 204 || status === 205) {
|
||||
responseData = { status }
|
||||
} else {
|
||||
if (tool.transformResponse) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { AGENT, isCustomTool } from '@/executor/constants'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools'
|
||||
import { getCustomTool } from '@/hooks/queries/custom-tools'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { extractErrorMessage } from '@/tools/error-extractors'
|
||||
import { tools } from '@/tools/registry'
|
||||
@@ -335,17 +335,10 @@ export function getTool(toolId: string): ToolConfig | undefined {
|
||||
// Check if it's a custom tool
|
||||
if (isCustomTool(toolId) && typeof window !== 'undefined') {
|
||||
// Only try to use the sync version on the client
|
||||
const customToolsStore = useCustomToolsStore.getState()
|
||||
const identifier = toolId.slice(AGENT.CUSTOM_TOOL_PREFIX.length)
|
||||
|
||||
// Try to find the tool directly by ID first
|
||||
let customTool = customToolsStore.getTool(identifier)
|
||||
|
||||
// If not found by ID, try to find by title (for backward compatibility)
|
||||
if (!customTool) {
|
||||
const allTools = customToolsStore.getAllTools()
|
||||
customTool = allTools.find((tool) => tool.title === identifier)
|
||||
}
|
||||
// Try to find the tool from query cache (extracts workspaceId from URL)
|
||||
const customTool = getCustomTool(identifier)
|
||||
|
||||
if (customTool) {
|
||||
return createToolConfig(customTool, toolId)
|
||||
@@ -367,7 +360,7 @@ export async function getToolAsync(
|
||||
|
||||
// Check if it's a custom tool
|
||||
if (isCustomTool(toolId)) {
|
||||
return getCustomTool(toolId, workflowId)
|
||||
return fetchCustomToolFromAPI(toolId, workflowId)
|
||||
}
|
||||
|
||||
return undefined
|
||||
@@ -411,8 +404,8 @@ function createToolConfig(customTool: any, customToolId: string): ToolConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a tool config from a custom tool definition
|
||||
async function getCustomTool(
|
||||
// Create a tool config from a custom tool definition by fetching from API
|
||||
async function fetchCustomToolFromAPI(
|
||||
customToolId: string,
|
||||
workflowId?: string
|
||||
): Promise<ToolConfig | undefined> {
|
||||
|
||||
@@ -77,7 +77,10 @@ app:
|
||||
# Node environment
|
||||
NODE_ENV: "production"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
|
||||
|
||||
# Telemetry & Monitoring
|
||||
TELEMETRY_ENDPOINT: "" # OTLP endpoint for traces/logs (e.g., "https://otlp-collector:4318/v1/traces")
|
||||
|
||||
# Authentication and encryption secrets (REQUIRED for production)
|
||||
# Generate secure 32-character secrets using: openssl rand -hex 32
|
||||
BETTER_AUTH_SECRET: "" # REQUIRED - set via --set flag or external secret manager
|
||||
@@ -101,8 +104,12 @@ app:
|
||||
# OAuth Integration Credentials (leave empty if not using)
|
||||
GOOGLE_CLIENT_ID: "" # Google OAuth client ID
|
||||
GOOGLE_CLIENT_SECRET: "" # Google OAuth client secret
|
||||
GITHUB_CLIENT_ID: "" # GitHub OAuth client ID
|
||||
GITHUB_CLIENT_ID: "" # GitHub OAuth client ID
|
||||
GITHUB_CLIENT_SECRET: "" # GitHub OAuth client secret
|
||||
|
||||
# Google Vertex AI Configuration
|
||||
VERTEX_PROJECT: "" # Google Cloud project ID for Vertex AI
|
||||
VERTEX_LOCATION: "us-central1" # Google Cloud region for Vertex AI (e.g., "us-central1")
|
||||
|
||||
# AI Provider API Keys (leave empty if not using)
|
||||
OPENAI_API_KEY: "" # Primary OpenAI API key
|
||||
@@ -143,6 +150,15 @@ app:
|
||||
ALLOWED_LOGIN_EMAILS: "" # Comma-separated list of allowed email addresses for login
|
||||
ALLOWED_LOGIN_DOMAINS: "" # Comma-separated list of allowed email domains for login
|
||||
|
||||
# Admin API Configuration
|
||||
ADMIN_API_KEY: "" # Admin API key for organization/user management (generate with: openssl rand -hex 32)
|
||||
|
||||
# Organizations & Permission Groups
|
||||
ACCESS_CONTROL_ENABLED: "false" # Enable permission groups feature ("true" to enable)
|
||||
ORGANIZATIONS_ENABLED: "false" # Enable organizations feature ("true" to enable)
|
||||
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: "false" # Show permission groups UI ("true" to enable)
|
||||
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: "false" # Show organizations UI ("true" to enable)
|
||||
|
||||
# LLM Provider/Model Restrictions (leave empty if not restricting)
|
||||
BLACKLISTED_PROVIDERS: "" # Comma-separated provider IDs to hide from UI (e.g., "openai,anthropic,google")
|
||||
BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
|
||||
|
||||
@@ -33,22 +33,20 @@ export interface LoggerConfig {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variable value
|
||||
* Works in any JavaScript runtime (Node.js, Bun, etc.)
|
||||
*/
|
||||
const getEnvVar = (key: string): string | undefined => {
|
||||
const getNodeEnv = (): string => {
|
||||
if (typeof process !== 'undefined' && process.env) {
|
||||
return process.env[key]
|
||||
return process.env.NODE_ENV || 'development'
|
||||
}
|
||||
return 'development'
|
||||
}
|
||||
|
||||
const getLogLevel = (): string | undefined => {
|
||||
if (typeof process !== 'undefined' && process.env) {
|
||||
return process.env.LOG_LEVEL
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current environment (development, production, test)
|
||||
*/
|
||||
const getNodeEnv = (): string => getEnvVar('NODE_ENV') || 'development'
|
||||
|
||||
/**
|
||||
* Get the minimum log level from environment variable or use defaults
|
||||
* - Development: DEBUG (show all logs)
|
||||
@@ -56,7 +54,7 @@ const getNodeEnv = (): string => getEnvVar('NODE_ENV') || 'development'
|
||||
* - Test: ERROR (only show errors in tests)
|
||||
*/
|
||||
const getMinLogLevel = (): LogLevel => {
|
||||
const logLevelEnv = getEnvVar('LOG_LEVEL')
|
||||
const logLevelEnv = getLogLevel()
|
||||
if (logLevelEnv && Object.values(LogLevel).includes(logLevelEnv as LogLevel)) {
|
||||
return logLevelEnv as LogLevel
|
||||
}
|
||||
@@ -120,7 +118,6 @@ const formatObject = (obj: unknown, isDev: boolean): string => {
|
||||
stack: isDev ? obj.stack : undefined,
|
||||
name: obj.name,
|
||||
}
|
||||
// Copy any additional enumerable properties from the error
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!(key in errorObj)) {
|
||||
errorObj[key] = (obj as unknown as Record<string, unknown>)[key]
|
||||
@@ -181,7 +178,6 @@ export class Logger {
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
if (!this.config.enabled) return false
|
||||
|
||||
// In production, only log on server-side (where window is undefined)
|
||||
if (getNodeEnv() === 'production' && typeof window !== 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user