mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d75cc1ed84 | ||
|
|
5a8a703ecb | ||
|
|
6f64188b8d | ||
|
|
60a9a25553 | ||
|
|
52fa388f81 | ||
|
|
5c56cbd558 | ||
|
|
dc19525a6f | ||
|
|
3873f44875 | ||
|
|
09b95f41ea | ||
|
|
af60ccd188 | ||
|
|
eb75afd115 | ||
|
|
fdb8256468 | ||
|
|
570c07bf2a | ||
|
|
5c16e7d390 | ||
|
|
bd38062705 | ||
|
|
d7fd4a9618 | ||
|
|
d972bab206 | ||
|
|
f254d70624 | ||
|
|
8748e1d5f9 | ||
|
|
133a32e6d3 | ||
|
|
97b6bcc43d | ||
|
|
42917ce641 | ||
|
|
5f6d219223 | ||
|
|
bab74307f4 | ||
|
|
16aaa37dad | ||
|
|
c6166a9483 | ||
|
|
0258a1b4ce | ||
|
|
4d4aefa346 | ||
|
|
a0cf003abf | ||
|
|
2e027dd77d | ||
|
|
6133db53d0 | ||
|
|
03bb437e09 | ||
|
|
9f02f88bf5 | ||
|
|
7a1711282e | ||
|
|
58613888b0 | ||
|
|
f1fe2f52cc | ||
|
|
7d05999a70 | ||
|
|
bf07240cfa | ||
|
|
0c7a8efc8d | ||
|
|
f081f5a73c | ||
|
|
72c07e8ad2 |
@@ -9,7 +9,7 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
|
||||
const brand = useBrandConfig()
|
||||
|
||||
return (
|
||||
<main className='relative flex min-h-screen flex-col bg-[#0C0C0C] font-geist-sans text-white'>
|
||||
<main className='relative flex min-h-screen flex-col bg-[var(--brand-background-hex)] font-geist-sans text-white'>
|
||||
{/* Background pattern */}
|
||||
<GridPattern
|
||||
x={-5}
|
||||
|
||||
@@ -456,7 +456,7 @@ export default function LoginPage({
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='flex h-11 w-full items-center justify-center gap-2 bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
|
||||
className='flex h-11 w-full items-center justify-center gap-2 bg-brand-primary font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-brand-primary-hover'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
@@ -468,7 +468,7 @@ export default function LoginPage({
|
||||
<span className='text-neutral-400'>Don't have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
|
||||
className='font-medium text-[#9D54FF] underline-offset-4 transition hover:text-[#a66fff] hover:underline'
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
@@ -497,7 +497,7 @@ export default function LoginPage({
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
className='border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[#802FFF]/70 focus:ring-[#802FFF]/20'
|
||||
className='border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20'
|
||||
/>
|
||||
</div>
|
||||
{resetStatus.type && (
|
||||
@@ -512,7 +512,7 @@ export default function LoginPage({
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleForgotPassword}
|
||||
className='h-11 w-full bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
|
||||
className='h-11 w-full bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
disabled={isSubmittingReset}
|
||||
>
|
||||
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
|
||||
|
||||
@@ -488,7 +488,7 @@ function SignupFormContent({
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='flex h-11 w-full items-center justify-center gap-2 bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
|
||||
className='flex h-11 w-full items-center justify-center gap-2 bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create Account'}
|
||||
@@ -500,7 +500,7 @@ function SignupFormContent({
|
||||
<span className='text-neutral-400'>Already have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
|
||||
className='font-medium text-[#9D54FF] underline-offset-4 transition hover:text-[#a66fff] hover:underline'
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
|
||||
@@ -124,7 +124,7 @@ function VerificationForm({
|
||||
|
||||
<Button
|
||||
onClick={verifyCode}
|
||||
className='h-11 w-full bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
|
||||
className='h-11 w-full bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
disabled={!isOtpComplete || isLoading}
|
||||
>
|
||||
{isLoading ? 'Verifying...' : 'Verify Email'}
|
||||
@@ -140,7 +140,7 @@ function VerificationForm({
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className='font-medium text-[#9D54FF] underline-offset-4 transition hover:text-[#a66fff] hover:underline'
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
onClick={handleResend}
|
||||
disabled={isLoading || isResendDisabled}
|
||||
>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const BlogCard = ({
|
||||
}: BlogCardProps) => {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<div className='flex flex-col rounded-3xl border border-[#606060]/40 bg-[#101010] p-8 transition-all duration-500 hover:bg-[#202020]'>
|
||||
<div className='flex flex-col rounded-3xl border border-[#606060]/40 bg-[#101010] p-8 transition-all duration-500 hover:bg-[var(--surface-elevated)]'>
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
|
||||
@@ -245,7 +245,7 @@ export default function NavClient({
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Button className='h-[43px] bg-[#701ffc] px-6 py-2 font-geist-sans font-medium text-base text-neutral-100 transition-colors duration-200 hover:bg-[#802FFF]'>
|
||||
<Button className='h-[43px] bg-[var(--brand-primary-hex)] px-6 py-2 font-geist-sans font-medium text-base text-neutral-100 transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'>
|
||||
Contact
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -277,7 +277,7 @@ export default function NavClient({
|
||||
>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className='flex h-full w-[280px] flex-col border-[#181818] border-l bg-[#0C0C0C] p-6 pt-6 text-white shadow-xl sm:w-[320px] [&>button]:hidden'
|
||||
className='flex h-full w-[280px] flex-col border-[#181818] border-l bg-[var(--brand-background-hex)] p-6 pt-6 text-white shadow-xl sm:w-[320px] [&>button]:hidden'
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
@@ -311,7 +311,7 @@ export default function NavClient({
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Button className='w-full bg-[#701ffc] py-6 font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'>
|
||||
<Button className='w-full bg-[var(--brand-primary-hex)] py-6 font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'>
|
||||
Contact
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -62,7 +62,7 @@ function Hero() {
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
onClick={handleNavigate}
|
||||
className='animate-fade-in items-center bg-[#701ffc] px-7 py-6 font-[420] font-geist-sans text-lg text-neutral-100 tracking-normal shadow-[#701ffc]/30 shadow-lg hover:bg-[#802FFF]'
|
||||
className='animate-fade-in items-center bg-[var(--brand-primary-hex)] px-7 py-6 font-[420] font-geist-sans text-lg text-neutral-100 tracking-normal shadow-[var(--brand-primary-hex)]/30 shadow-lg hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
aria-label='Start using the platform'
|
||||
>
|
||||
<div className='text-[1.15rem]'>Start now</div>
|
||||
@@ -104,7 +104,7 @@ function Hero() {
|
||||
className='aspect-[5/3] h-auto md:aspect-auto'
|
||||
>
|
||||
<g filter='url(#filter0_b_0_1)'>
|
||||
<ellipse cx='300' cy='240' rx='290' ry='220' fill='#0C0C0C' />
|
||||
<ellipse cx='300' cy='240' rx='290' ry='220' fill='var(--brand-background-hex)' />
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
|
||||
@@ -151,7 +151,7 @@ export default function ContributorsPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<main className='relative min-h-screen bg-[#0C0C0C] font-geist-sans text-white'>
|
||||
<main className='relative min-h-screen bg-[var(--brand-background-hex)] font-geist-sans text-white'>
|
||||
{/* Grid pattern background */}
|
||||
<div className='absolute inset-0 bottom-[400px] z-0'>
|
||||
<GridPattern
|
||||
@@ -239,7 +239,7 @@ export default function ContributorsPage() {
|
||||
<div className='mb-6 grid grid-cols-1 gap-3 sm:mb-8 sm:grid-cols-2 sm:gap-4 lg:grid-cols-5'>
|
||||
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
|
||||
<div className='mb-1 flex items-center justify-center sm:mb-2'>
|
||||
<Star className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
|
||||
<Star className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
|
||||
</div>
|
||||
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.stars}</div>
|
||||
<div className='text-neutral-400 text-xs'>Stars</div>
|
||||
@@ -247,7 +247,7 @@ export default function ContributorsPage() {
|
||||
|
||||
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
|
||||
<div className='mb-1 flex items-center justify-center sm:mb-2'>
|
||||
<GitFork className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
|
||||
<GitFork className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
|
||||
</div>
|
||||
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.forks}</div>
|
||||
<div className='text-neutral-400 text-xs'>Forks</div>
|
||||
@@ -255,7 +255,7 @@ export default function ContributorsPage() {
|
||||
|
||||
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
|
||||
<div className='mb-1 flex items-center justify-center sm:mb-2'>
|
||||
<GitGraph className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
|
||||
<GitGraph className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
|
||||
</div>
|
||||
<div className='font-bold text-lg text-white sm:text-xl'>
|
||||
{filteredContributors?.length || 0}
|
||||
@@ -265,7 +265,7 @@ export default function ContributorsPage() {
|
||||
|
||||
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
|
||||
<div className='mb-1 flex items-center justify-center sm:mb-2'>
|
||||
<MessageCircle className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
|
||||
<MessageCircle className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
|
||||
</div>
|
||||
<div className='font-bold text-lg text-white sm:text-xl'>
|
||||
{repoStats.openIssues}
|
||||
@@ -275,7 +275,7 @@ export default function ContributorsPage() {
|
||||
|
||||
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
|
||||
<div className='mb-1 flex items-center justify-center sm:mb-2'>
|
||||
<GitPullRequest className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
|
||||
<GitPullRequest className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
|
||||
</div>
|
||||
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.openPRs}</div>
|
||||
<div className='text-neutral-400 text-xs'>Pull Requests</div>
|
||||
@@ -291,8 +291,8 @@ export default function ContributorsPage() {
|
||||
<AreaChart data={timelineData} className='-mx-2 sm:-mx-5 mt-1 sm:mt-2'>
|
||||
<defs>
|
||||
<linearGradient id='commits' x1='0' y1='0' x2='0' y2='1'>
|
||||
<stop offset='5%' stopColor='#701ffc' stopOpacity={0.3} />
|
||||
<stop offset='95%' stopColor='#701ffc' stopOpacity={0} />
|
||||
<stop offset='5%' stopColor='var(--brand-primary-hex)' stopOpacity={0.3} />
|
||||
<stop offset='95%' stopColor='var(--brand-primary-hex)' stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
@@ -320,7 +320,7 @@ export default function ContributorsPage() {
|
||||
<div className='rounded-lg border border-[#606060]/30 bg-[#0f0f0f] p-2 shadow-lg backdrop-blur-sm sm:p-3'>
|
||||
<div className='grid gap-1 sm:gap-2'>
|
||||
<div className='flex items-center gap-1 sm:gap-2'>
|
||||
<GitGraph className='h-3 w-3 text-[#701ffc] sm:h-4 sm:w-4' />
|
||||
<GitGraph className='h-3 w-3 text-[var(--brand-primary-hex)] sm:h-4 sm:w-4' />
|
||||
<span className='text-neutral-400 text-xs sm:text-sm'>
|
||||
Commits:
|
||||
</span>
|
||||
@@ -338,7 +338,7 @@ export default function ContributorsPage() {
|
||||
<Area
|
||||
type='monotone'
|
||||
dataKey='commits'
|
||||
stroke='#701ffc'
|
||||
stroke='var(--brand-primary-hex)'
|
||||
strokeWidth={2}
|
||||
fill='url(#commits)'
|
||||
/>
|
||||
@@ -393,7 +393,7 @@ export default function ContributorsPage() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<Avatar className='h-12 w-12 ring-2 ring-[#606060]/30 transition-transform group-hover:scale-105 group-hover:ring-[#701ffc]/60 sm:h-16 sm:w-16'>
|
||||
<Avatar className='h-12 w-12 ring-2 ring-[#606060]/30 transition-transform group-hover:scale-105 group-hover:ring-[var(--brand-primary-hex)]/60 sm:h-16 sm:w-16'>
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={contributor.login}
|
||||
@@ -405,13 +405,13 @@ export default function ContributorsPage() {
|
||||
</Avatar>
|
||||
|
||||
<div className='mt-2 text-center sm:mt-3'>
|
||||
<span className='block font-medium text-white text-xs transition-colors group-hover:text-[#701ffc] sm:text-sm'>
|
||||
<span className='block font-medium text-white text-xs transition-colors group-hover:text-[var(--brand-primary-hex)] sm:text-sm'>
|
||||
{contributor.login.length > 12
|
||||
? `${contributor.login.slice(0, 12)}...`
|
||||
: contributor.login}
|
||||
</span>
|
||||
<div className='mt-1 flex items-center justify-center gap-1 sm:mt-2'>
|
||||
<GitGraph className='h-2 w-2 text-neutral-400 transition-colors group-hover:text-[#701ffc] sm:h-3 sm:w-3' />
|
||||
<GitGraph className='h-2 w-2 text-neutral-400 transition-colors group-hover:text-[var(--brand-primary-hex)] sm:h-3 sm:w-3' />
|
||||
<span className='font-medium text-neutral-300 text-xs transition-colors group-hover:text-white sm:text-sm'>
|
||||
{contributor.contributions}
|
||||
</span>
|
||||
@@ -508,7 +508,7 @@ export default function ContributorsPage() {
|
||||
/>
|
||||
<Bar
|
||||
dataKey='contributions'
|
||||
className='fill-[#701ffc]'
|
||||
className='fill-[var(--brand-primary-hex)]'
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
@@ -532,7 +532,7 @@ export default function ContributorsPage() {
|
||||
>
|
||||
<div className='relative p-6 sm:p-8 md:p-12 lg:p-16'>
|
||||
<div className='text-center'>
|
||||
<div className='mb-4 inline-flex items-center rounded-full border border-[#701ffc]/20 bg-[#701ffc]/10 px-3 py-1 font-medium text-[#701ffc] text-xs sm:mb-6 sm:px-4 sm:py-2 sm:text-sm'>
|
||||
<div className='mb-4 inline-flex items-center rounded-full border border-[var(--brand-primary-hex)]/20 bg-[var(--brand-primary-hex)]/10 px-3 py-1 font-medium text-[var(--brand-primary-hex)] text-xs sm:mb-6 sm:px-4 sm:py-2 sm:text-sm'>
|
||||
<Github className='mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4' />
|
||||
Apache-2.0 Licensed
|
||||
</div>
|
||||
@@ -550,7 +550,7 @@ export default function ContributorsPage() {
|
||||
<Button
|
||||
asChild
|
||||
size='lg'
|
||||
className='bg-[#701ffc] text-white transition-colors duration-500 hover:bg-[#802FFF]'
|
||||
className='bg-[var(--brand-primary-hex)] text-white transition-colors duration-500 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim/blob/main/.github/CONTRIBUTING.md'
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function Landing() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='relative min-h-screen bg-[#0C0C0C] font-geist-sans'>
|
||||
<main className='relative min-h-screen bg-[var(--brand-background-hex)] font-geist-sans'>
|
||||
<NavWrapper onOpenTypeformLink={handleOpenTypeformLink} />
|
||||
|
||||
<Hero />
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function PrivacyPolicy() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='relative min-h-screen overflow-hidden bg-[#0C0C0C] text-white'>
|
||||
<main className='relative min-h-screen overflow-hidden bg-[var(--brand-background-hex)] text-white'>
|
||||
{/* Grid pattern background - only covers content area */}
|
||||
<div className='absolute inset-0 bottom-[400px] z-0 overflow-hidden'>
|
||||
<GridPattern
|
||||
@@ -42,7 +42,7 @@ export default function PrivacyPolicy() {
|
||||
className='h-full w-full'
|
||||
>
|
||||
<g filter='url(#filter0_b_privacy)'>
|
||||
<rect width='600' height='1600' rx='0' fill='#0C0C0C' />
|
||||
<rect width='600' height='1600' rx='0' fill='var(--brand-background-hex)' />
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
@@ -391,7 +391,7 @@ export default function PrivacyPolicy() {
|
||||
Privacy & Terms web page:{' '}
|
||||
<Link
|
||||
href='https://policies.google.com/privacy?hl=en'
|
||||
className='text-[#B5A1D4] hover:text-[#701ffc]'
|
||||
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
@@ -569,7 +569,7 @@ export default function PrivacyPolicy() {
|
||||
Please note that we may ask you to verify your identity before responding to such
|
||||
requests.
|
||||
</p>
|
||||
<p className='mb-4 border-[#701ffc] border-l-4 bg-[#701ffc]/10 p-3'>
|
||||
<p className='mb-4 border-[var(--brand-primary-hex)] border-l-4 bg-[var(--brand-primary-hex)]/10 p-3'>
|
||||
You have the right to complain to a Data Protection Authority about our collection
|
||||
and use of your Personal Information. For more information, please contact your
|
||||
local data protection authority in the European Economic Area (EEA).
|
||||
@@ -661,7 +661,7 @@ export default function PrivacyPolicy() {
|
||||
policy (if any). Before beginning your inquiry, email us at{' '}
|
||||
<Link
|
||||
href='mailto:security@sim.ai'
|
||||
className='text-[#B5A1D4] hover:text-[#701ffc]'
|
||||
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
|
||||
>
|
||||
security@sim.ai
|
||||
</Link>{' '}
|
||||
@@ -686,7 +686,7 @@ export default function PrivacyPolicy() {
|
||||
To report any security flaws, send an email to{' '}
|
||||
<Link
|
||||
href='mailto:security@sim.ai'
|
||||
className='text-[#B5A1D4] hover:text-[#701ffc]'
|
||||
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
|
||||
>
|
||||
security@sim.ai
|
||||
</Link>
|
||||
@@ -726,7 +726,7 @@ export default function PrivacyPolicy() {
|
||||
If you have any questions about this Privacy Policy, please contact us at:{' '}
|
||||
<Link
|
||||
href='mailto:privacy@sim.ai'
|
||||
className='text-[#B5A1D4] hover:text-[#701ffc]'
|
||||
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
|
||||
>
|
||||
privacy@sim.ai
|
||||
</Link>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function TermsOfService() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='relative min-h-screen overflow-hidden bg-[#0C0C0C] text-white'>
|
||||
<main className='relative min-h-screen overflow-hidden bg-[var(--brand-background-hex)] text-white'>
|
||||
{/* Grid pattern background */}
|
||||
<div className='absolute inset-0 bottom-[400px] z-0 overflow-hidden'>
|
||||
<GridPattern
|
||||
@@ -42,7 +42,7 @@ export default function TermsOfService() {
|
||||
className='h-full w-full'
|
||||
>
|
||||
<g filter='url(#filter0_b_terms)'>
|
||||
<rect width='600' height='1600' rx='0' fill='#0C0C0C' />
|
||||
<rect width='600' height='1600' rx='0' fill='var(--brand-background-hex)' />
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
@@ -268,7 +268,7 @@ export default function TermsOfService() {
|
||||
Arbitration Agreement. The arbitration will be conducted by JAMS, an established
|
||||
alternative dispute resolution provider.
|
||||
</p>
|
||||
<p className='mb-4 border-[#701ffc] border-l-4 bg-[#701ffc]/10 p-3'>
|
||||
<p className='mb-4 border-[var(--brand-primary-hex)] border-l-4 bg-[var(--brand-primary-hex)]/10 p-3'>
|
||||
YOU AND COMPANY AGREE THAT EACH OF US MAY BRING CLAIMS AGAINST THE OTHER ONLY ON
|
||||
AN INDIVIDUAL BASIS AND NOT ON A CLASS, REPRESENTATIVE, OR COLLECTIVE BASIS. ONLY
|
||||
INDIVIDUAL RELIEF IS AVAILABLE, AND DISPUTES OF MORE THAN ONE CUSTOMER OR USER
|
||||
@@ -277,7 +277,10 @@ export default function TermsOfService() {
|
||||
<p className='mb-4'>
|
||||
You have the right to opt out of the provisions of this Arbitration Agreement by
|
||||
sending a timely written notice of your decision to opt out to:{' '}
|
||||
<Link href='mailto:legal@sim.ai' className='text-[#B5A1D4] hover:text-[#701ffc]'>
|
||||
<Link
|
||||
href='mailto:legal@sim.ai'
|
||||
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
|
||||
>
|
||||
legal@sim.ai{' '}
|
||||
</Link>
|
||||
within 30 days after first becoming subject to this Arbitration Agreement.
|
||||
@@ -330,7 +333,7 @@ export default function TermsOfService() {
|
||||
Our Copyright Agent can be reached at:{' '}
|
||||
<Link
|
||||
href='mailto:copyright@sim.ai'
|
||||
className='text-[#B5A1D4] hover:text-[#701ffc]'
|
||||
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
|
||||
>
|
||||
copyright@sim.ai
|
||||
</Link>
|
||||
@@ -341,7 +344,10 @@ export default function TermsOfService() {
|
||||
<h2 className='mb-4 font-semibold text-2xl text-white'>12. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about these Terms, please contact us at:{' '}
|
||||
<Link href='mailto:legal@sim.ai' className='text-[#B5A1D4] hover:text-[#701ffc]'>
|
||||
<Link
|
||||
href='mailto:legal@sim.ai'
|
||||
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
|
||||
>
|
||||
legal@sim.ai
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -28,7 +28,12 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
|
||||
// We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId,
|
||||
requireWorkflowIdForInternal: false,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import crypto from 'crypto'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { userStats } from '@/db/schema'
|
||||
@@ -11,34 +11,14 @@ import { calculateCost } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('billing-update-cost')
|
||||
|
||||
// Schema for the request body
|
||||
const UpdateCostSchema = z.object({
|
||||
userId: z.string().min(1, 'User ID is required'),
|
||||
input: z.number().min(0, 'Input tokens must be a non-negative number'),
|
||||
output: z.number().min(0, 'Output tokens must be a non-negative number'),
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
multiplier: z.number().min(0),
|
||||
})
|
||||
|
||||
// Authentication function (reused from copilot/methods route)
|
||||
function checkInternalApiKey(req: NextRequest) {
|
||||
const apiKey = req.headers.get('x-api-key')
|
||||
const expectedApiKey = env.INTERNAL_API_SECRET
|
||||
|
||||
if (!expectedApiKey) {
|
||||
return { success: false, error: 'Internal API key not configured' }
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
return { success: false, error: 'Invalid API key' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/billing/update-cost
|
||||
* Update user cost based on token usage with internal API key auth
|
||||
@@ -50,6 +30,19 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
logger.info(`[${requestId}] Update cost request started`)
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
logger.debug(`[${requestId}] Billing is disabled, skipping cost update`)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Billing disabled, cost update skipped',
|
||||
data: {
|
||||
billingEnabled: false,
|
||||
processedAt: new Date().toISOString(),
|
||||
requestId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Check authentication (internal API key)
|
||||
const authResult = checkInternalApiKey(req)
|
||||
if (!authResult.success) {
|
||||
@@ -82,27 +75,27 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, input, output, model } = validation.data
|
||||
const { userId, input, output, model, multiplier } = validation.data
|
||||
|
||||
logger.info(`[${requestId}] Processing cost update`, {
|
||||
userId,
|
||||
input,
|
||||
output,
|
||||
model,
|
||||
multiplier,
|
||||
})
|
||||
|
||||
const finalPromptTokens = input
|
||||
const finalCompletionTokens = output
|
||||
const totalTokens = input + output
|
||||
|
||||
// Calculate cost using COPILOT_COST_MULTIPLIER (only in production, like normal executions)
|
||||
const copilotMultiplier = isProd ? env.COPILOT_COST_MULTIPLIER || 1 : 1
|
||||
// Calculate cost using provided multiplier (required)
|
||||
const costResult = calculateCost(
|
||||
model,
|
||||
finalPromptTokens,
|
||||
finalCompletionTokens,
|
||||
false,
|
||||
copilotMultiplier
|
||||
multiplier
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Cost calculation result`, {
|
||||
@@ -111,7 +104,7 @@ export async function POST(req: NextRequest) {
|
||||
promptTokens: finalPromptTokens,
|
||||
completionTokens: finalCompletionTokens,
|
||||
totalTokens: totalTokens,
|
||||
copilotMultiplier,
|
||||
multiplier,
|
||||
costResult,
|
||||
})
|
||||
|
||||
@@ -134,6 +127,10 @@ export async function POST(req: NextRequest) {
|
||||
totalTokensUsed: totalTokens,
|
||||
totalCost: costToStore.toString(),
|
||||
currentPeriodCost: costToStore.toString(),
|
||||
// Copilot usage tracking
|
||||
totalCopilotCost: costToStore.toString(),
|
||||
totalCopilotTokens: totalTokens,
|
||||
totalCopilotCalls: 1,
|
||||
lastActive: new Date(),
|
||||
})
|
||||
|
||||
@@ -148,6 +145,10 @@ export async function POST(req: NextRequest) {
|
||||
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
|
||||
// Copilot usage tracking increments
|
||||
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
|
||||
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
|
||||
totalCopilotCalls: sql`total_copilot_calls + 1`,
|
||||
totalApiCalls: sql`total_api_calls`,
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
@@ -246,7 +246,10 @@ describe('Chat API Route', () => {
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
|
||||
typeof value === 'string'
|
||||
? value.toLowerCase() === 'true' || value === '1'
|
||||
: Boolean(value),
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
|
||||
const validData = {
|
||||
@@ -291,6 +294,7 @@ describe('Chat API Route', () => {
|
||||
},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
|
||||
const validData = {
|
||||
|
||||
@@ -14,8 +14,6 @@ import { chat } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('ChatAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const chatSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
subdomain: z
|
||||
@@ -150,7 +148,7 @@ export async function POST(request: NextRequest) {
|
||||
// Merge customizations with the additional fields
|
||||
const mergedCustomizations = {
|
||||
...(customizations || {}),
|
||||
primaryColor: customizations?.primaryColor || '#802FFF',
|
||||
primaryColor: customizations?.primaryColor || 'var(--brand-primary-hover-hex)',
|
||||
welcomeMessage: customizations?.welcomeMessage || 'Hi there! How can I help you today?',
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@ import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { chat } from '@/db/schema'
|
||||
|
||||
70
apps/sim/app/api/copilot/api-keys/generate/route.ts
Normal file
70
apps/sim/app/api/copilot/api-keys/generate/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateApiKey } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { copilotApiKeys } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotApiKeysGenerate')
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
|
||||
function encryptRandomIv(plaintext: string, keyString: string): string {
|
||||
const key = deriveKey(keyString)
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
const authTag = cipher.getAuthTag().toString('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}:${authTag}`
|
||||
}
|
||||
|
||||
function computeLookup(plaintext: string, keyString: string): string {
|
||||
// Deterministic, constant-time comparable MAC: HMAC-SHA256(DB_KEY, plaintext)
|
||||
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
|
||||
.update(plaintext, 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
|
||||
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
|
||||
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Generate and prefix the key (strip the generic sim_ prefix from the random part)
|
||||
const rawKey = generateApiKey().replace(/^sim_/, '')
|
||||
const plaintextKey = `sk-sim-copilot-${rawKey}`
|
||||
|
||||
// Encrypt with random IV for confidentiality
|
||||
const dbEncrypted = encryptRandomIv(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
|
||||
|
||||
// Compute deterministic lookup value for O(1) search
|
||||
const lookup = computeLookup(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(copilotApiKeys)
|
||||
.values({ userId, apiKeyEncrypted: dbEncrypted, apiKeyLookup: lookup })
|
||||
.returning({ id: copilotApiKeys.id })
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, key: { id: inserted.id, apiKey: plaintextKey } },
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate copilot API key', { error })
|
||||
return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
85
apps/sim/app/api/copilot/api-keys/route.ts
Normal file
85
apps/sim/app/api/copilot/api-keys/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createDecipheriv, createHash } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { copilotApiKeys } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotApiKeys')
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
|
||||
function decryptWithKey(encryptedValue: string, keyString: string): string {
|
||||
const parts = encryptedValue.split(':')
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted value format')
|
||||
}
|
||||
const [ivHex, encryptedHex, authTagHex] = parts
|
||||
const key = deriveKey(keyString)
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
|
||||
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
|
||||
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const rows = await db
|
||||
.select({ id: copilotApiKeys.id, apiKeyEncrypted: copilotApiKeys.apiKeyEncrypted })
|
||||
.from(copilotApiKeys)
|
||||
.where(eq(copilotApiKeys.userId, userId))
|
||||
|
||||
const keys = rows.map((row) => ({
|
||||
id: row.id,
|
||||
apiKey: decryptWithKey(row.apiKeyEncrypted, env.AGENT_API_DB_ENCRYPTION_KEY as string),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ keys }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to get copilot API keys', { error })
|
||||
return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('id')
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(copilotApiKeys)
|
||||
.where(and(eq(copilotApiKeys.userId, userId), eq(copilotApiKeys.id, id)))
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete copilot API key', { error })
|
||||
return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
79
apps/sim/app/api/copilot/api-keys/validate/route.ts
Normal file
79
apps/sim/app/api/copilot/api-keys/validate/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { copilotApiKeys, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotApiKeysValidate')
|
||||
|
||||
function computeLookup(plaintext: string, keyString: string): string {
|
||||
// Deterministic MAC: HMAC-SHA256(DB_KEY, plaintext)
|
||||
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
|
||||
.update(plaintext, 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
|
||||
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
|
||||
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey : undefined
|
||||
|
||||
if (!apiKey) {
|
||||
return new NextResponse(null, { status: 401 })
|
||||
}
|
||||
|
||||
const lookup = computeLookup(apiKey, env.AGENT_API_DB_ENCRYPTION_KEY)
|
||||
|
||||
// Find matching API key and its user
|
||||
const rows = await db
|
||||
.select({ id: copilotApiKeys.id, userId: copilotApiKeys.userId })
|
||||
.from(copilotApiKeys)
|
||||
.where(eq(copilotApiKeys.apiKeyLookup, lookup))
|
||||
.limit(1)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return new NextResponse(null, { status: 401 })
|
||||
}
|
||||
|
||||
const { userId } = rows[0]
|
||||
|
||||
// Check usage for the associated user
|
||||
const usage = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
totalCost: userStats.totalCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (usage.length > 0) {
|
||||
const currentUsage = Number.parseFloat(
|
||||
(usage[0].currentPeriodCost?.toString() as string) ||
|
||||
(usage[0].totalCost as unknown as string) ||
|
||||
'0'
|
||||
)
|
||||
const limit = Number.parseFloat((usage[0].currentUsageLimit as unknown as string) || '0')
|
||||
|
||||
if (!Number.isNaN(limit) && limit > 0 && currentUsage >= limit) {
|
||||
// Usage exceeded
|
||||
logger.info('[API VALIDATION] Usage exceeded', { userId, currentUsage, limit })
|
||||
return new NextResponse(null, { status: 402 })
|
||||
}
|
||||
}
|
||||
|
||||
// Valid and within usage limits
|
||||
return new NextResponse(null, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Error validating copilot API key', { error })
|
||||
return NextResponse.json({ error: 'Failed to validate key' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ describe('Copilot Chat API Route', () => {
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
SIM_AGENT_API_URL: 'http://localhost:8000',
|
||||
SIM_AGENT_API_KEY: 'test-sim-agent-key',
|
||||
COPILOT_API_KEY: 'test-sim-agent-key',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -223,6 +223,8 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -284,6 +286,8 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -337,6 +341,8 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -430,6 +436,8 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'ask',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
@@ -13,6 +14,7 @@ import { getCopilotModel } from '@/lib/copilot/config'
|
||||
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { downloadFile } from '@/lib/uploads'
|
||||
import { downloadFromS3WithConfig } from '@/lib/uploads/s3/s3-client'
|
||||
import { S3_COPILOT_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
|
||||
@@ -23,6 +25,37 @@ import { createAnthropicFileContent, isSupportedFileType } from './file-utils'
|
||||
|
||||
const logger = createLogger('CopilotChatAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
|
||||
function decryptWithKey(encryptedValue: string, keyString: string): string {
|
||||
const [ivHex, encryptedHex, authTagHex] = encryptedValue.split(':')
|
||||
if (!ivHex || !encryptedHex || !authTagHex) {
|
||||
throw new Error('Invalid encrypted format')
|
||||
}
|
||||
const key = deriveKey(keyString)
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
|
||||
function encryptWithKey(plaintext: string, keyString: string): string {
|
||||
const key = deriveKey(keyString)
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
const authTag = cipher.getAuthTag().toString('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}:${authTag}`
|
||||
}
|
||||
|
||||
// Schema for file attachments
|
||||
const FileAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -39,16 +72,15 @@ const ChatMessageSchema = z.object({
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
mode: z.enum(['ask', 'agent']).optional().default('agent'),
|
||||
depth: z.number().int().min(0).max(3).optional().default(0),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
stream: z.boolean().optional().default(true),
|
||||
implicitFeedback: z.string().optional(),
|
||||
fileAttachments: z.array(FileAttachmentSchema).optional(),
|
||||
provider: z.string().optional().default('openai'),
|
||||
conversationId: z.string().optional(),
|
||||
})
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = env.SIM_AGENT_API_KEY
|
||||
|
||||
/**
|
||||
* Generate a chat title using LLM
|
||||
*/
|
||||
@@ -156,10 +188,13 @@ export async function POST(req: NextRequest) {
|
||||
chatId,
|
||||
workflowId,
|
||||
mode,
|
||||
depth,
|
||||
createNewChat,
|
||||
stream,
|
||||
implicitFeedback,
|
||||
fileAttachments,
|
||||
provider,
|
||||
conversationId,
|
||||
} = ChatMessageSchema.parse(body)
|
||||
|
||||
logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
|
||||
@@ -171,6 +206,9 @@ export async function POST(req: NextRequest) {
|
||||
createNewChat,
|
||||
messageLength: message.length,
|
||||
hasImplicitFeedback: !!implicitFeedback,
|
||||
provider: provider || 'openai',
|
||||
hasConversationId: !!conversationId,
|
||||
depth,
|
||||
})
|
||||
|
||||
// Handle chat context
|
||||
@@ -252,7 +290,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Build messages array for sim agent with conversation history
|
||||
const messages = []
|
||||
const messages: any[] = []
|
||||
|
||||
// Add conversation history (need to rebuild these with file support if they had attachments)
|
||||
for (const msg of conversationHistory) {
|
||||
@@ -327,40 +365,70 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// Start title generation in parallel if this is a new chat with first message
|
||||
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
|
||||
logger.info(`[${tracker.requestId}] Will start parallel title generation inside stream`)
|
||||
// Determine provider and conversationId to use for this request
|
||||
const providerToUse = provider || 'openai'
|
||||
const effectiveConversationId =
|
||||
(currentChat?.conversationId as string | undefined) || conversationId
|
||||
|
||||
// If we have a conversationId, only send the most recent user message; else send full history
|
||||
const latestUserMessage =
|
||||
[...messages].reverse().find((m) => m?.role === 'user') || messages[messages.length - 1]
|
||||
const messagesForAgent = effectiveConversationId ? [latestUserMessage] : messages
|
||||
|
||||
const requestPayload = {
|
||||
messages: messagesForAgent,
|
||||
workflowId,
|
||||
userId: authenticatedUserId,
|
||||
stream: stream,
|
||||
streamToolCalls: true,
|
||||
mode: mode,
|
||||
provider: providerToUse,
|
||||
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
|
||||
...(typeof depth === 'number' ? { depth } : {}),
|
||||
...(session?.user?.name && { userName: session.user.name }),
|
||||
}
|
||||
|
||||
// Forward to sim agent API
|
||||
logger.info(`[${tracker.requestId}] Sending request to sim agent API`, {
|
||||
messageCount: messages.length,
|
||||
endpoint: `${SIM_AGENT_API_URL}/api/chat-completion-streaming`,
|
||||
})
|
||||
// Log the payload being sent to the streaming endpoint
|
||||
try {
|
||||
logger.info(`[${tracker.requestId}] Sending payload to sim agent streaming endpoint`, {
|
||||
url: `${SIM_AGENT_API_URL}/api/chat-completion-streaming`,
|
||||
provider: providerToUse,
|
||||
mode,
|
||||
stream,
|
||||
workflowId,
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
depth: typeof depth === 'number' ? depth : undefined,
|
||||
messagesCount: requestPayload.messages.length,
|
||||
})
|
||||
// Full payload as JSON string
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Full streaming payload: ${JSON.stringify(requestPayload)}`
|
||||
)
|
||||
} catch (e) {
|
||||
logger.warn(`[${tracker.requestId}] Failed to log payload preview for streaming endpoint`, e)
|
||||
}
|
||||
|
||||
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion-streaming`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
workflowId,
|
||||
userId: authenticatedUserId,
|
||||
stream: stream,
|
||||
streamToolCalls: true,
|
||||
mode: mode,
|
||||
...(session?.user?.name && { userName: session.user.name }),
|
||||
}),
|
||||
body: JSON.stringify(requestPayload),
|
||||
})
|
||||
|
||||
if (!simAgentResponse.ok) {
|
||||
const errorText = await simAgentResponse.text()
|
||||
if (simAgentResponse.status === 401 || simAgentResponse.status === 402) {
|
||||
// Rethrow status only; client will render appropriate assistant message
|
||||
return new NextResponse(null, { status: simAgentResponse.status })
|
||||
}
|
||||
|
||||
const errorText = await simAgentResponse.text().catch(() => '')
|
||||
logger.error(`[${tracker.requestId}] Sim agent API error:`, {
|
||||
status: simAgentResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Sim agent API error: ${simAgentResponse.statusText}` },
|
||||
{ status: simAgentResponse.status }
|
||||
@@ -388,6 +456,8 @@ export async function POST(req: NextRequest) {
|
||||
const toolCalls: any[] = []
|
||||
let buffer = ''
|
||||
let isFirstDone = true
|
||||
let responseIdFromStart: string | undefined
|
||||
let responseIdFromDone: string | undefined
|
||||
|
||||
// Send chatId as first event
|
||||
if (actualChatId) {
|
||||
@@ -486,6 +556,13 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
break
|
||||
|
||||
case 'reasoning':
|
||||
// Treat like thinking: do not add to assistantContent to avoid leaking
|
||||
logger.debug(
|
||||
`[${tracker.requestId}] Reasoning chunk received (${(event.data || event.content || '').length} chars)`
|
||||
)
|
||||
break
|
||||
|
||||
case 'tool_call':
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Tool call ${event.data?.partial ? '(partial)' : '(complete)'}:`,
|
||||
@@ -528,7 +605,22 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
break
|
||||
|
||||
case 'start':
|
||||
if (event.data?.responseId) {
|
||||
responseIdFromStart = event.data.responseId
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Received start event with responseId: ${responseIdFromStart}`
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
case 'done':
|
||||
if (event.data?.responseId) {
|
||||
responseIdFromDone = event.data.responseId
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}`
|
||||
)
|
||||
}
|
||||
if (isFirstDone) {
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Initial AI response complete, tool count: ${toolCalls.length}`
|
||||
@@ -622,12 +714,15 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const responseId = responseIdFromDone
|
||||
|
||||
// Update chat in database immediately (without title)
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
messages: updatedMessages,
|
||||
updatedAt: new Date(),
|
||||
...(responseId ? { conversationId: responseId } : {}),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
|
||||
@@ -635,6 +730,7 @@ export async function POST(req: NextRequest) {
|
||||
messageCount: updatedMessages.length,
|
||||
savedUserMessage: true,
|
||||
savedAssistantMessage: assistantContent.trim().length > 0,
|
||||
updatedConversationId: responseId || null,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -51,12 +51,6 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { chatId, messages } = UpdateMessagesSchema.parse(body)
|
||||
|
||||
logger.info(`[${tracker.requestId}] Updating chat messages`, {
|
||||
userId,
|
||||
chatId,
|
||||
messageCount: messages.length,
|
||||
})
|
||||
|
||||
// Verify that the chat belongs to the user
|
||||
const [chat] = await db
|
||||
.select()
|
||||
|
||||
@@ -38,7 +38,7 @@ async function updateToolCallStatus(
|
||||
|
||||
try {
|
||||
const key = `tool_call:${toolCallId}`
|
||||
const timeout = 60000 // 1 minute timeout
|
||||
const timeout = 600000 // 10 minutes timeout for user confirmation
|
||||
const pollInterval = 100 // Poll every 100ms
|
||||
const startTime = Date.now()
|
||||
|
||||
@@ -48,11 +48,6 @@ async function updateToolCallStatus(
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const exists = await redis.exists(key)
|
||||
if (exists) {
|
||||
logger.info('Tool call found in Redis, updating status', {
|
||||
toolCallId,
|
||||
key,
|
||||
pollDuration: Date.now() - startTime,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
@@ -79,27 +74,8 @@ async function updateToolCallStatus(
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Log what we're about to update in Redis
|
||||
logger.info('About to update Redis with tool call data', {
|
||||
toolCallId,
|
||||
key,
|
||||
toolCallData,
|
||||
serializedData: JSON.stringify(toolCallData),
|
||||
providedStatus: status,
|
||||
providedMessage: message,
|
||||
messageIsUndefined: message === undefined,
|
||||
messageIsNull: message === null,
|
||||
})
|
||||
|
||||
await redis.set(key, JSON.stringify(toolCallData), 'EX', 86400) // Keep 24 hour expiry
|
||||
|
||||
logger.info('Tool call status updated in Redis', {
|
||||
toolCallId,
|
||||
key,
|
||||
status,
|
||||
message,
|
||||
pollDuration: Date.now() - startTime,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to update tool call status in Redis', {
|
||||
@@ -131,13 +107,6 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { toolCallId, status, message } = ConfirmationSchema.parse(body)
|
||||
|
||||
logger.info(`[${tracker.requestId}] Tool call confirmation request`, {
|
||||
userId: authenticatedUserId,
|
||||
toolCallId,
|
||||
status,
|
||||
message,
|
||||
})
|
||||
|
||||
// Update the tool call status in Redis
|
||||
const updated = await updateToolCallStatus(toolCallId, status, message)
|
||||
|
||||
@@ -153,13 +122,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const duration = tracker.getDuration()
|
||||
logger.info(`[${tracker.requestId}] Tool call confirmation completed`, {
|
||||
userId: authenticatedUserId,
|
||||
toolCallId,
|
||||
status,
|
||||
internalStatus: status,
|
||||
duration,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry'
|
||||
import type { NotificationStatus } from '@/lib/copilot/types'
|
||||
import { env } from '@/lib/env'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getRedisClient } from '@/lib/redis'
|
||||
import { createErrorResponse } from '@/app/api/copilot/methods/utils'
|
||||
@@ -65,16 +65,10 @@ async function pollRedisForTool(
|
||||
}
|
||||
|
||||
const key = `tool_call:${toolCallId}`
|
||||
const timeout = 300000 // 5 minutes
|
||||
const timeout = 600000 // 10 minutes for long-running operations
|
||||
const pollInterval = 1000 // 1 second
|
||||
const startTime = Date.now()
|
||||
|
||||
logger.info('Starting to poll Redis for tool call status', {
|
||||
toolCallId,
|
||||
timeout,
|
||||
pollInterval,
|
||||
})
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
const redisValue = await redis.get(key)
|
||||
@@ -112,23 +106,6 @@ async function pollRedisForTool(
|
||||
rawRedisValue: redisValue,
|
||||
})
|
||||
|
||||
logger.info('Tool call status resolved', {
|
||||
toolCallId,
|
||||
status,
|
||||
message,
|
||||
duration: Date.now() - startTime,
|
||||
rawRedisValue: redisValue,
|
||||
parsedAsJSON: redisValue
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(redisValue)
|
||||
} catch {
|
||||
return 'failed-to-parse'
|
||||
}
|
||||
})()
|
||||
: null,
|
||||
})
|
||||
|
||||
// Special logging for set environment variables tool when Redis status is found
|
||||
if (toolCallId && (status === 'accepted' || status === 'rejected')) {
|
||||
logger.info('SET_ENV_VARS: Redis polling found status update', {
|
||||
@@ -240,33 +217,12 @@ async function interruptHandler(toolCallId: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Schema for method execution
|
||||
const MethodExecutionSchema = z.object({
|
||||
methodId: z.string().min(1, 'Method ID is required'),
|
||||
params: z.record(z.any()).optional().default({}),
|
||||
toolCallId: z.string().nullable().optional().default(null),
|
||||
})
|
||||
|
||||
// Simple internal API key authentication
|
||||
function checkInternalApiKey(req: NextRequest) {
|
||||
const apiKey = req.headers.get('x-api-key')
|
||||
const expectedApiKey = env.INTERNAL_API_SECRET
|
||||
|
||||
if (!expectedApiKey) {
|
||||
return { success: false, error: 'Internal API key not configured' }
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
return { success: false, error: 'Invalid API key' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/copilot/methods
|
||||
* Execute a method based on methodId with internal API key auth
|
||||
|
||||
@@ -3,9 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { decryptSecret, encryptSecret } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment } from '@/db/schema'
|
||||
|
||||
@@ -178,7 +178,7 @@ export function findLocalFile(filename: string): string | null {
|
||||
* Create a file response with appropriate headers
|
||||
*/
|
||||
export function createFileResponse(file: FileResponse): NextResponse {
|
||||
return new NextResponse(file.buffer, {
|
||||
return new NextResponse(file.buffer as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': file.contentType,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
const mockFreestyleExecuteScript = vi.fn()
|
||||
const mockCreateContext = vi.fn()
|
||||
const mockRunInContext = vi.fn()
|
||||
const mockLogger = {
|
||||
@@ -29,27 +28,10 @@ describe('Function Execute API Route', () => {
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.doMock('freestyle-sandboxes', () => ({
|
||||
FreestyleSandboxes: vi.fn().mockImplementation(() => ({
|
||||
executeScript: mockFreestyleExecuteScript,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
FREESTYLE_API_KEY: 'test-freestyle-key',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
}))
|
||||
|
||||
mockFreestyleExecuteScript.mockResolvedValue({
|
||||
result: 'freestyle success',
|
||||
logs: [],
|
||||
})
|
||||
|
||||
mockRunInContext.mockResolvedValue('vm success')
|
||||
mockCreateContext.mockReturnValue({})
|
||||
})
|
||||
@@ -228,107 +210,6 @@ describe('Function Execute API Route', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Freestyle Execution', () => {
|
||||
it('should use Freestyle when API key is available', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "freestyle test"',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/function/execute/route')
|
||||
await POST(req)
|
||||
|
||||
expect(mockFreestyleExecuteScript).toHaveBeenCalled()
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\[.*\] Using Freestyle for code execution/)
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle Freestyle errors and fallback to VM', async () => {
|
||||
mockFreestyleExecuteScript.mockRejectedValueOnce(new Error('Freestyle API error'))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "fallback test"',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/function/execute/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(mockFreestyleExecuteScript).toHaveBeenCalled()
|
||||
expect(mockRunInContext).toHaveBeenCalled()
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\[.*\] Freestyle API call failed, falling back to VM:/),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle Freestyle script errors', async () => {
|
||||
mockFreestyleExecuteScript.mockResolvedValueOnce({
|
||||
result: null,
|
||||
logs: [{ type: 'error', message: 'ReferenceError: undefined variable' }],
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return undefinedVariable',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/function/execute/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const data = await response.json()
|
||||
expect(data.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('VM Execution', () => {
|
||||
it.skip('should use VM when Freestyle API key is not available', async () => {
|
||||
// Mock no Freestyle API key
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
FREESTYLE_API_KEY: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "vm test"',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/function/execute/route')
|
||||
await POST(req)
|
||||
|
||||
expect(mockFreestyleExecuteScript).not.toHaveBeenCalled()
|
||||
expect(mockRunInContext).toHaveBeenCalled()
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/\[.*\] Using VM for code execution \(no Freestyle API key available\)/
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle VM execution errors', async () => {
|
||||
// Mock no Freestyle API key so it uses VM
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
FREESTYLE_API_KEY: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
mockRunInContext.mockRejectedValueOnce(new Error('VM execution error'))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return invalidCode(',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/function/execute/route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const data = await response.json()
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('VM execution error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Tools', () => {
|
||||
it('should handle custom tool execution with direct parameter access', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
@@ -651,113 +532,3 @@ SyntaxError: Invalid or unexpected token
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Function Execute API - Template Variable Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
FREESTYLE_API_KEY: 'test-freestyle-key',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('vm', () => ({
|
||||
createContext: mockCreateContext,
|
||||
Script: vi.fn().mockImplementation(() => ({
|
||||
runInContext: mockRunInContext,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.doMock('freestyle-sandboxes', () => ({
|
||||
FreestyleSandboxes: vi.fn().mockImplementation(() => ({
|
||||
executeScript: mockFreestyleExecuteScript,
|
||||
})),
|
||||
}))
|
||||
|
||||
mockFreestyleExecuteScript.mockResolvedValue({
|
||||
result: 'freestyle success',
|
||||
logs: [],
|
||||
})
|
||||
|
||||
mockRunInContext.mockResolvedValue('vm success')
|
||||
mockCreateContext.mockReturnValue({})
|
||||
})
|
||||
|
||||
it.skip('should handle nested template variables', async () => {
|
||||
mockFreestyleExecuteScript.mockResolvedValueOnce({
|
||||
result: 'environment-valueparam-value',
|
||||
logs: [],
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return {{outer}} + <inner>',
|
||||
envVars: {
|
||||
outer: 'environment-value',
|
||||
},
|
||||
params: {
|
||||
inner: 'param-value',
|
||||
},
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/function/execute/route')
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.result).toBe('environment-valueparam-value')
|
||||
})
|
||||
|
||||
it.skip('should prioritize environment variables over params for {{}} syntax', async () => {
|
||||
mockFreestyleExecuteScript.mockResolvedValueOnce({
|
||||
result: 'env-wins',
|
||||
logs: [],
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return {{conflictVar}}',
|
||||
envVars: {
|
||||
conflictVar: 'env-wins',
|
||||
},
|
||||
params: {
|
||||
conflictVar: 'param-loses',
|
||||
},
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/function/execute/route')
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
// Environment variable should take precedence
|
||||
expect(data.output.result).toBe('env-wins')
|
||||
})
|
||||
|
||||
it.skip('should handle missing template variables gracefully', async () => {
|
||||
mockFreestyleExecuteScript.mockResolvedValueOnce({
|
||||
result: '',
|
||||
logs: [],
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return {{nonexistent}} + <alsoMissing>',
|
||||
envVars: {},
|
||||
params: {},
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/function/execute/route')
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -367,150 +367,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const executionMethod = 'vm' // Default execution method
|
||||
|
||||
// // Try to use Freestyle if the API key is available
|
||||
// if (env.FREESTYLE_API_KEY) {
|
||||
// try {
|
||||
// logger.info(`[${requestId}] Using Freestyle for code execution`)
|
||||
// executionMethod = 'freestyle'
|
||||
|
||||
// // Extract npm packages from code if needed
|
||||
// const importRegex =
|
||||
// /import\s+?(?:(?:(?:[\w*\s{},]*)\s+from\s+?)|)(?:(?:"([^"]*)")|(?:'([^']*)'))[^;]*/g
|
||||
// const requireRegex = /const\s+[\w\s{}]*\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
||||
|
||||
// const packages: Record<string, string> = {}
|
||||
// const matches = [
|
||||
// ...resolvedCode.matchAll(importRegex),
|
||||
// ...resolvedCode.matchAll(requireRegex),
|
||||
// ]
|
||||
|
||||
// // Extract package names from import statements
|
||||
// for (const match of matches) {
|
||||
// const packageName = match[1] || match[2]
|
||||
// if (packageName && !packageName.startsWith('.') && !packageName.startsWith('/')) {
|
||||
// // Extract just the package name without version or subpath
|
||||
// const basePackageName = packageName.split('/')[0]
|
||||
// packages[basePackageName] = 'latest' // Use latest version
|
||||
// }
|
||||
// }
|
||||
|
||||
// const freestyle = new FreestyleSandboxes({
|
||||
// apiKey: env.FREESTYLE_API_KEY,
|
||||
// })
|
||||
|
||||
// // Wrap code in export default to match Freestyle's expectations
|
||||
// const wrappedCode = isCustomTool
|
||||
// ? `export default async () => {
|
||||
// // For custom tools, directly declare parameters as variables
|
||||
// ${Object.entries(executionParams)
|
||||
// .map(([key, value]) => `const ${key} = ${safeJSONStringify(value)};`)
|
||||
// .join('\n ')}
|
||||
// ${resolvedCode}
|
||||
// }`
|
||||
// : `export default async () => { ${resolvedCode} }`
|
||||
|
||||
// // Execute the code with Freestyle
|
||||
// const res = await freestyle.executeScript(wrappedCode, {
|
||||
// nodeModules: packages,
|
||||
// timeout: null,
|
||||
// envVars: envVars,
|
||||
// })
|
||||
|
||||
// // Check for direct API error response
|
||||
// // Type assertion since the library types don't include error response
|
||||
// const response = res as { _type?: string; error?: string }
|
||||
// if (response._type === 'error' && response.error) {
|
||||
// logger.error(`[${requestId}] Freestyle returned error response`, {
|
||||
// error: response.error,
|
||||
// })
|
||||
// throw response.error
|
||||
// }
|
||||
|
||||
// // Capture stdout/stderr from Freestyle logs
|
||||
// stdout =
|
||||
// res.logs
|
||||
// ?.map((log) => (log.type === 'error' ? 'ERROR: ' : '') + log.message)
|
||||
// .join('\n') || ''
|
||||
|
||||
// // Check for errors reported within Freestyle logs
|
||||
// const freestyleErrors = res.logs?.filter((log) => log.type === 'error') || []
|
||||
// if (freestyleErrors.length > 0) {
|
||||
// const errorMessage = freestyleErrors.map((log) => log.message).join('\n')
|
||||
// logger.error(`[${requestId}] Freestyle execution completed with script errors`, {
|
||||
// errorMessage,
|
||||
// stdout,
|
||||
// })
|
||||
// // Create a proper Error object to be caught by the outer handler
|
||||
// const scriptError = new Error(errorMessage)
|
||||
// scriptError.name = 'FreestyleScriptError'
|
||||
// throw scriptError
|
||||
// }
|
||||
|
||||
// // If no errors, execution was successful
|
||||
// result = res.result
|
||||
// logger.info(`[${requestId}] Freestyle execution successful`, {
|
||||
// result,
|
||||
// stdout,
|
||||
// })
|
||||
// } catch (error: any) {
|
||||
// // Check if the error came from our explicit throw above due to script errors
|
||||
// if (error.name === 'FreestyleScriptError') {
|
||||
// throw error // Re-throw to be caught by the outer handler
|
||||
// }
|
||||
|
||||
// // Otherwise, it's likely a Freestyle API call error (network, auth, config, etc.) -> Fallback to VM
|
||||
// logger.error(`[${requestId}] Freestyle API call failed, falling back to VM:`, {
|
||||
// error: error.message,
|
||||
// stack: error.stack,
|
||||
// })
|
||||
// executionMethod = 'vm_fallback'
|
||||
|
||||
// // Continue to VM execution
|
||||
// const context = createContext({
|
||||
// params: executionParams,
|
||||
// environmentVariables: envVars,
|
||||
// console: {
|
||||
// log: (...args: any[]) => {
|
||||
// const logMessage = `${args
|
||||
// .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
|
||||
// .join(' ')}\n`
|
||||
// stdout += logMessage
|
||||
// },
|
||||
// error: (...args: any[]) => {
|
||||
// const errorMessage = `${args
|
||||
// .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
|
||||
// .join(' ')}\n`
|
||||
// logger.error(`[${requestId}] Code Console Error: ${errorMessage}`)
|
||||
// stdout += `ERROR: ${errorMessage}`
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
|
||||
// const script = new Script(`
|
||||
// (async () => {
|
||||
// try {
|
||||
// ${
|
||||
// isCustomTool
|
||||
// ? `// For custom tools, make parameters directly accessible
|
||||
// ${Object.keys(executionParams)
|
||||
// .map((key) => `const ${key} = params.${key};`)
|
||||
// .join('\n ')}`
|
||||
// : ''
|
||||
// }
|
||||
// ${resolvedCode}
|
||||
// } catch (error) {
|
||||
// console.error(error);
|
||||
// throw error;
|
||||
// }
|
||||
// })()
|
||||
// `)
|
||||
|
||||
// result = await script.runInContext(context, {
|
||||
// timeout,
|
||||
// displayErrors: true,
|
||||
// })
|
||||
// }
|
||||
// } else {
|
||||
logger.info(`[${requestId}] Using VM for code execution`, {
|
||||
resolvedCode,
|
||||
hasEnvVars: Object.keys(envVars).length > 0,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { Resend } from 'resend'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
@@ -9,7 +10,6 @@ const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
const logger = createLogger('HelpAPI')
|
||||
|
||||
const helpFormSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
subject: z.string().min(1, 'Subject is required'),
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
type: z.enum(['bug', 'feedback', 'feature_request', 'other']),
|
||||
@@ -19,6 +19,15 @@ export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// Get user session
|
||||
const session = await getSession()
|
||||
if (!session?.user?.email) {
|
||||
logger.warn(`[${requestId}] Unauthorized help request attempt`)
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const email = session.user.email
|
||||
|
||||
// Check if Resend API key is configured
|
||||
if (!resend) {
|
||||
logger.error(`[${requestId}] RESEND_API_KEY not configured`)
|
||||
@@ -35,7 +44,6 @@ export async function POST(req: NextRequest) {
|
||||
const formData = await req.formData()
|
||||
|
||||
// Extract form fields
|
||||
const email = formData.get('email') as string
|
||||
const subject = formData.get('subject') as string
|
||||
const message = formData.get('message') as string
|
||||
const type = formData.get('type') as string
|
||||
@@ -47,7 +55,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Validate the form data
|
||||
const result = helpFormSchema.safeParse({
|
||||
email,
|
||||
subject,
|
||||
message,
|
||||
type,
|
||||
@@ -97,9 +104,9 @@ ${message}
|
||||
}
|
||||
|
||||
// Send email using Resend
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: `Sim <noreply@${getEmailDomain()}>`,
|
||||
to: [`help@${getEmailDomain()}`],
|
||||
const { error } = await resend.emails.send({
|
||||
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
|
||||
subject: `[${type.toUpperCase()}] ${subject}`,
|
||||
replyTo: email,
|
||||
text: emailText,
|
||||
@@ -121,7 +128,7 @@ ${message}
|
||||
// Send confirmation email to the user
|
||||
await resend.emails
|
||||
.send({
|
||||
from: `Sim <noreply@${getEmailDomain()}>`,
|
||||
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
to: [email],
|
||||
subject: `Your ${type} request has been received: ${subject}`,
|
||||
text: `
|
||||
@@ -137,7 +144,7 @@ ${images.length > 0 ? `You attached ${images.length} image(s).` : ''}
|
||||
Best regards,
|
||||
The Sim Team
|
||||
`,
|
||||
replyTo: `help@${getEmailDomain()}`,
|
||||
replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`,
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(`[${requestId}] Failed to send confirmation email`, err)
|
||||
|
||||
@@ -3,11 +3,8 @@ import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TaskStatusAPI')
|
||||
|
||||
@@ -4,9 +4,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { checkChunkAccess } from '@/app/api/knowledge/utils'
|
||||
import { db } from '@/db'
|
||||
import { document, embedding } from '@/db/schema'
|
||||
|
||||
@@ -4,9 +4,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { estimateTokenCount } from '@/lib/tokenization/estimators'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
import {
|
||||
|
||||
@@ -4,9 +4,6 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import {
|
||||
checkDocumentAccess,
|
||||
checkDocumentWriteAccess,
|
||||
|
||||
@@ -46,20 +46,7 @@ export async function GET(
|
||||
startedAt: workflowLog.startedAt.toISOString(),
|
||||
endedAt: workflowLog.endedAt?.toISOString(),
|
||||
totalDurationMs: workflowLog.totalDurationMs,
|
||||
blockStats: {
|
||||
total: workflowLog.blockCount,
|
||||
success: workflowLog.successCount,
|
||||
error: workflowLog.errorCount,
|
||||
skipped: workflowLog.skippedCount,
|
||||
},
|
||||
cost: {
|
||||
total: workflowLog.totalCost ? Number.parseFloat(workflowLog.totalCost) : null,
|
||||
input: workflowLog.totalInputCost ? Number.parseFloat(workflowLog.totalInputCost) : null,
|
||||
output: workflowLog.totalOutputCost
|
||||
? Number.parseFloat(workflowLog.totalOutputCost)
|
||||
: null,
|
||||
},
|
||||
totalTokens: workflowLog.totalTokens,
|
||||
cost: workflowLog.cost || null,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
102
apps/sim/app/api/logs/by-id/[id]/route.ts
Normal file
102
apps/sim/app/api/logs/by-id/[id]/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { permissions, workflow, workflowExecutionLogs } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('LogDetailsByIdAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized log details access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const { id } = await params
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
level: workflowExecutionLogs.level,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
files: workflowExecutionLogs.files,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
workflowColor: workflow.color,
|
||||
workflowFolderId: workflow.folderId,
|
||||
workflowUserId: workflow.userId,
|
||||
workflowWorkspaceId: workflow.workspaceId,
|
||||
workflowCreatedAt: workflow.createdAt,
|
||||
workflowUpdatedAt: workflow.updatedAt,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(eq(workflowExecutionLogs.id, id))
|
||||
.limit(1)
|
||||
|
||||
const log = rows[0]
|
||||
if (!log) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workflowSummary = {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName,
|
||||
description: log.workflowDescription,
|
||||
color: log.workflowColor,
|
||||
folderId: log.workflowFolderId,
|
||||
userId: log.workflowUserId,
|
||||
workspaceId: log.workflowWorkspaceId,
|
||||
createdAt: log.workflowCreatedAt,
|
||||
updatedAt: log.workflowUpdatedAt,
|
||||
}
|
||||
|
||||
const response = {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
level: log.level,
|
||||
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
|
||||
trigger: log.trigger,
|
||||
createdAt: log.startedAt.toISOString(),
|
||||
files: log.files || undefined,
|
||||
workflow: workflowSummary,
|
||||
executionData: {
|
||||
totalDuration: log.totalDurationMs,
|
||||
...(log.executionData as any),
|
||||
enhanced: true,
|
||||
},
|
||||
cost: log.cost as any,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: response })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] log details fetch error`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -99,21 +99,13 @@ export async function GET(request: NextRequest) {
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
level: workflowExecutionLogs.level,
|
||||
message: workflowExecutionLogs.message,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
blockCount: workflowExecutionLogs.blockCount,
|
||||
successCount: workflowExecutionLogs.successCount,
|
||||
errorCount: workflowExecutionLogs.errorCount,
|
||||
skippedCount: workflowExecutionLogs.skippedCount,
|
||||
totalCost: workflowExecutionLogs.totalCost,
|
||||
totalInputCost: workflowExecutionLogs.totalInputCost,
|
||||
totalOutputCost: workflowExecutionLogs.totalOutputCost,
|
||||
totalTokens: workflowExecutionLogs.totalTokens,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
files: workflowExecutionLogs.files,
|
||||
metadata: workflowExecutionLogs.metadata,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, gte, inArray, lte, or, type SQL, sql } from 'drizzle-orm'
|
||||
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -44,8 +44,7 @@ function extractBlockExecutionsFromTraceSpans(traceSpans: any[]): any[] {
|
||||
export const revalidate = 0
|
||||
|
||||
const QueryParamsSchema = z.object({
|
||||
includeWorkflow: z.coerce.boolean().optional().default(false),
|
||||
includeBlocks: z.coerce.boolean().optional().default(false),
|
||||
details: z.enum(['basic', 'full']).optional().default('basic'),
|
||||
limit: z.coerce.number().optional().default(100),
|
||||
offset: z.coerce.number().optional().default(0),
|
||||
level: z.string().optional(),
|
||||
@@ -81,20 +80,12 @@ export async function GET(request: NextRequest) {
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
level: workflowExecutionLogs.level,
|
||||
message: workflowExecutionLogs.message,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
blockCount: workflowExecutionLogs.blockCount,
|
||||
successCount: workflowExecutionLogs.successCount,
|
||||
errorCount: workflowExecutionLogs.errorCount,
|
||||
skippedCount: workflowExecutionLogs.skippedCount,
|
||||
totalCost: workflowExecutionLogs.totalCost,
|
||||
totalInputCost: workflowExecutionLogs.totalInputCost,
|
||||
totalOutputCost: workflowExecutionLogs.totalOutputCost,
|
||||
totalTokens: workflowExecutionLogs.totalTokens,
|
||||
metadata: workflowExecutionLogs.metadata,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
files: workflowExecutionLogs.files,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
workflowName: workflow.name,
|
||||
@@ -163,13 +154,8 @@ export async function GET(request: NextRequest) {
|
||||
// Filter by search query
|
||||
if (params.search) {
|
||||
const searchTerm = `%${params.search}%`
|
||||
conditions = and(
|
||||
conditions,
|
||||
or(
|
||||
sql`${workflowExecutionLogs.message} ILIKE ${searchTerm}`,
|
||||
sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`
|
||||
)
|
||||
)
|
||||
// With message removed, restrict search to executionId only
|
||||
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`)
|
||||
}
|
||||
|
||||
// Execute the query using the optimized join
|
||||
@@ -290,31 +276,20 @@ export async function GET(request: NextRequest) {
|
||||
const enhancedLogs = logs.map((log) => {
|
||||
const blockExecutions = blockExecutionsByExecution[log.executionId] || []
|
||||
|
||||
// Use stored trace spans from metadata if available, otherwise create from block executions
|
||||
const storedTraceSpans = (log.metadata as any)?.traceSpans
|
||||
// Use stored trace spans if available, otherwise create from block executions
|
||||
const storedTraceSpans = (log.executionData as any)?.traceSpans
|
||||
const traceSpans =
|
||||
storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0
|
||||
? storedTraceSpans
|
||||
: createTraceSpans(blockExecutions)
|
||||
|
||||
// Use extracted cost summary if available, otherwise use stored values
|
||||
// Prefer stored cost JSON; otherwise synthesize from blocks
|
||||
const costSummary =
|
||||
blockExecutions.length > 0
|
||||
? extractCostSummary(blockExecutions)
|
||||
: {
|
||||
input: Number(log.totalInputCost) || 0,
|
||||
output: Number(log.totalOutputCost) || 0,
|
||||
total: Number(log.totalCost) || 0,
|
||||
tokens: {
|
||||
total: log.totalTokens || 0,
|
||||
prompt: (log.metadata as any)?.tokenBreakdown?.prompt || 0,
|
||||
completion: (log.metadata as any)?.tokenBreakdown?.completion || 0,
|
||||
},
|
||||
models: (log.metadata as any)?.models || {},
|
||||
}
|
||||
log.cost && Object.keys(log.cost as any).length > 0
|
||||
? (log.cost as any)
|
||||
: extractCostSummary(blockExecutions)
|
||||
|
||||
// Build workflow object from joined data
|
||||
const workflow = {
|
||||
const workflowSummary = {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName,
|
||||
description: log.workflowDescription,
|
||||
@@ -329,67 +304,28 @@ export async function GET(request: NextRequest) {
|
||||
return {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
executionId: params.details === 'full' ? log.executionId : undefined,
|
||||
level: log.level,
|
||||
message: log.message,
|
||||
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
|
||||
trigger: log.trigger,
|
||||
createdAt: log.startedAt.toISOString(),
|
||||
files: log.files || undefined,
|
||||
workflow: params.includeWorkflow ? workflow : undefined,
|
||||
metadata: {
|
||||
totalDuration: log.totalDurationMs,
|
||||
cost: costSummary,
|
||||
blockStats: {
|
||||
total: log.blockCount,
|
||||
success: log.successCount,
|
||||
error: log.errorCount,
|
||||
skipped: log.skippedCount,
|
||||
},
|
||||
traceSpans,
|
||||
blockExecutions,
|
||||
enhanced: true,
|
||||
},
|
||||
files: params.details === 'full' ? log.files || undefined : undefined,
|
||||
workflow: workflowSummary,
|
||||
executionData:
|
||||
params.details === 'full'
|
||||
? {
|
||||
totalDuration: log.totalDurationMs,
|
||||
traceSpans,
|
||||
blockExecutions,
|
||||
enhanced: true,
|
||||
}
|
||||
: undefined,
|
||||
cost:
|
||||
params.details === 'full'
|
||||
? (costSummary as any)
|
||||
: { total: (costSummary as any)?.total || 0 },
|
||||
}
|
||||
})
|
||||
|
||||
// Include block execution data if requested
|
||||
if (params.includeBlocks) {
|
||||
// Block executions are now extracted from stored trace spans in metadata
|
||||
const blockLogsByExecution: Record<string, any[]> = {}
|
||||
|
||||
logs.forEach((log) => {
|
||||
const storedTraceSpans = (log.metadata as any)?.traceSpans
|
||||
if (storedTraceSpans && Array.isArray(storedTraceSpans)) {
|
||||
blockLogsByExecution[log.executionId] =
|
||||
extractBlockExecutionsFromTraceSpans(storedTraceSpans)
|
||||
} else {
|
||||
blockLogsByExecution[log.executionId] = []
|
||||
}
|
||||
})
|
||||
|
||||
// Add block logs to metadata
|
||||
const logsWithBlocks = enhancedLogs.map((log) => ({
|
||||
...log,
|
||||
metadata: {
|
||||
...log.metadata,
|
||||
blockExecutions: blockLogsByExecution[log.executionId] || [],
|
||||
},
|
||||
}))
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: logsWithBlocks,
|
||||
total: Number(count),
|
||||
page: Math.floor(params.offset / params.limit) + 1,
|
||||
pageSize: params.limit,
|
||||
totalPages: Math.ceil(Number(count) / params.limit),
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// Return basic logs
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: enhancedLogs,
|
||||
|
||||
@@ -21,8 +21,6 @@ import { invitation, member, organization, user, workspace, workspaceInvitation
|
||||
|
||||
const logger = createLogger('OrganizationInvitationsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
|
||||
@@ -7,8 +7,6 @@ import { member, user, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationMemberAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/members/[memberId]
|
||||
* Get individual organization member details
|
||||
|
||||
@@ -13,8 +13,6 @@ import { invitation, member, organization, user, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationMembersAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/members
|
||||
* Get organization members with optional usage data
|
||||
|
||||
@@ -9,8 +9,6 @@ import { invitation, member, permissions, workspaceInvitation } from '@/db/schem
|
||||
|
||||
const logger = createLogger('OrganizationInvitationAcceptanceAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Accept an organization invitation and any associated workspace invitations
|
||||
export async function GET(req: NextRequest) {
|
||||
const invitationId = req.nextUrl.searchParams.get('id')
|
||||
|
||||
@@ -3,9 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { db } from '@/db'
|
||||
import { workflow, workflowSchedule } from '@/db/schema'
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ import { workflow, workflowSchedule } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('ScheduledAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const ScheduleRequestSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
blockId: z.string().optional(),
|
||||
|
||||
@@ -80,7 +80,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
workspaceId: workspaceId,
|
||||
name: `${templateData.name} (copy)`,
|
||||
description: templateData.description,
|
||||
state: templateData.state,
|
||||
color: templateData.color,
|
||||
userId: session.user.id,
|
||||
createdAt: now,
|
||||
@@ -158,9 +157,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}))
|
||||
}
|
||||
|
||||
// Update the workflow with the corrected state
|
||||
await tx.update(workflow).set({ state: updatedState }).where(eq(workflow.id, newWorkflowId))
|
||||
|
||||
// Insert blocks and edges
|
||||
if (blockEntries.length > 0) {
|
||||
await tx.insert(workflowBlocks).values(blockEntries)
|
||||
|
||||
@@ -9,9 +9,6 @@ import { customTools } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CustomToolsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Define validation schema for custom tools
|
||||
const CustomToolSchema = z.object({
|
||||
tools: z.array(
|
||||
z.object({
|
||||
|
||||
@@ -7,8 +7,6 @@ import { isOrganizationOwnerOrAdmin } from '@/lib/permissions/utils'
|
||||
|
||||
const logger = createLogger('UnifiedUsageLimitsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Unified Usage Limits Endpoint
|
||||
* GET/PUT /api/usage-limits?context=user|member&userId=<id>&organizationId=<id>
|
||||
|
||||
@@ -2,9 +2,6 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { db } from '@/db'
|
||||
import { apiKey } from '@/db/schema'
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import { apiKey } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('ApiKeysAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/users/me/api-keys - Get all API keys for the current user
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,6 @@ const SettingsSchema = z.object({
|
||||
autoPan: z.boolean().optional(),
|
||||
consoleExpandedByDefault: z.boolean().optional(),
|
||||
telemetryEnabled: z.boolean().optional(),
|
||||
telemetryNotifiedUser: z.boolean().optional(),
|
||||
emailPreferences: z
|
||||
.object({
|
||||
unsubscribeAll: z.boolean().optional(),
|
||||
@@ -35,7 +34,6 @@ const defaultSettings = {
|
||||
autoPan: true,
|
||||
consoleExpandedByDefault: true,
|
||||
telemetryEnabled: true,
|
||||
telemetryNotifiedUser: false,
|
||||
emailPreferences: {},
|
||||
}
|
||||
|
||||
@@ -69,7 +67,6 @@ export async function GET() {
|
||||
autoPan: userSettings.autoPan,
|
||||
consoleExpandedByDefault: userSettings.consoleExpandedByDefault,
|
||||
telemetryEnabled: userSettings.telemetryEnabled,
|
||||
telemetryNotifiedUser: userSettings.telemetryNotifiedUser,
|
||||
emailPreferences: userSettings.emailPreferences ?? {},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,9 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { db } from '@/db'
|
||||
import { member, organization, subscription } from '@/db/schema'
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@ import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable, subscription } from '@/db/schema'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tasks } from '@trigger.dev/sdk/v3'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
handleSlackChallenge,
|
||||
@@ -245,7 +246,44 @@ export async function POST(
|
||||
// Continue processing - better to risk rate limit bypass than fail webhook
|
||||
}
|
||||
|
||||
// --- PHASE 4: Queue webhook execution via trigger.dev ---
|
||||
// --- PHASE 4: Usage limit check ---
|
||||
try {
|
||||
const usageCheck = await checkServerSideUsageLimits(foundWorkflow.userId)
|
||||
if (usageCheck.isExceeded) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${foundWorkflow.userId} has exceeded usage limits. Skipping webhook execution.`,
|
||||
{
|
||||
currentUsage: usageCheck.currentUsage,
|
||||
limit: usageCheck.limit,
|
||||
workflowId: foundWorkflow.id,
|
||||
provider: foundWebhook.provider,
|
||||
}
|
||||
)
|
||||
|
||||
// Return 200 to prevent webhook provider retries, but indicate usage limit exceeded
|
||||
if (foundWebhook.provider === 'microsoftteams') {
|
||||
// Microsoft Teams requires specific response format
|
||||
return NextResponse.json({
|
||||
type: 'message',
|
||||
text: 'Usage limit exceeded. Please upgrade your plan to continue.',
|
||||
})
|
||||
}
|
||||
|
||||
// Simple error response for other providers (return 200 to prevent retries)
|
||||
return NextResponse.json({ message: 'Usage limit exceeded' }, { status: 200 })
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Usage limit check passed for webhook`, {
|
||||
provider: foundWebhook.provider,
|
||||
currentUsage: usageCheck.currentUsage,
|
||||
limit: usageCheck.limit,
|
||||
})
|
||||
} catch (usageError) {
|
||||
logger.error(`[${requestId}] Error checking webhook usage limits:`, usageError)
|
||||
// Continue processing - better to risk usage limit bypass than fail webhook
|
||||
}
|
||||
|
||||
// --- PHASE 5: Queue webhook execution via trigger.dev ---
|
||||
try {
|
||||
// Queue the webhook execution task
|
||||
const handle = await tasks.trigger('webhook-execution', {
|
||||
|
||||
@@ -17,12 +17,6 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AutoLayoutAPI')
|
||||
|
||||
// Check API key configuration at module level
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
if (!SIM_AGENT_API_KEY) {
|
||||
logger.warn('SIM_AGENT_API_KEY not configured - autolayout requests will fail')
|
||||
}
|
||||
|
||||
const AutoLayoutRequestSchema = z.object({
|
||||
strategy: z
|
||||
.enum(['smart', 'hierarchical', 'layered', 'force-directed'])
|
||||
@@ -125,15 +119,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Apply autolayout
|
||||
logger.info(
|
||||
`[${requestId}] Applying autolayout to ${Object.keys(currentWorkflowData.blocks).length} blocks`,
|
||||
{
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
simAgentUrl: process.env.SIM_AGENT_API_URL || 'http://localhost:8000',
|
||||
}
|
||||
)
|
||||
|
||||
// Create workflow state for autolayout
|
||||
const workflowState = {
|
||||
blocks: currentWorkflowData.blocks,
|
||||
@@ -184,7 +169,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
resolveOutputType: resolveOutputType.toString(),
|
||||
},
|
||||
},
|
||||
apiKey: SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Log the full response for debugging
|
||||
|
||||
@@ -4,13 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
|
||||
import type { LoopConfig, ParallelConfig, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateAPI')
|
||||
|
||||
@@ -93,7 +90,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
folderId: folderId || source.folderId,
|
||||
name,
|
||||
description: description || source.description,
|
||||
state: source.state, // We'll update this later with new block IDs
|
||||
color: color || source.color,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
@@ -115,9 +111,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
// Create a mapping from old block IDs to new block IDs
|
||||
const blockIdMapping = new Map<string, string>()
|
||||
|
||||
// Initialize state for updating with new block IDs
|
||||
let updatedState: WorkflowState = source.state as WorkflowState
|
||||
|
||||
if (sourceBlocks.length > 0) {
|
||||
// First pass: Create all block ID mappings
|
||||
sourceBlocks.forEach((block) => {
|
||||
@@ -268,86 +261,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
)
|
||||
}
|
||||
|
||||
// Update the JSON state to use new block IDs
|
||||
if (updatedState && typeof updatedState === 'object') {
|
||||
updatedState = JSON.parse(JSON.stringify(updatedState)) as WorkflowState
|
||||
|
||||
// Update blocks object keys
|
||||
if (updatedState.blocks && typeof updatedState.blocks === 'object') {
|
||||
const newBlocks = {} as Record<string, (typeof updatedState.blocks)[string]>
|
||||
for (const [oldId, blockData] of Object.entries(updatedState.blocks)) {
|
||||
const newId = blockIdMapping.get(oldId) || oldId
|
||||
newBlocks[newId] = {
|
||||
...blockData,
|
||||
id: newId,
|
||||
// Update data.parentId and extent in the JSON state as well
|
||||
data: (() => {
|
||||
const block = blockData as any
|
||||
if (block.data && typeof block.data === 'object' && block.data.parentId) {
|
||||
return {
|
||||
...block.data,
|
||||
parentId: blockIdMapping.get(block.data.parentId) || block.data.parentId,
|
||||
extent: 'parent', // Ensure extent is set for child blocks
|
||||
}
|
||||
}
|
||||
return block.data
|
||||
})(),
|
||||
}
|
||||
}
|
||||
updatedState.blocks = newBlocks
|
||||
}
|
||||
|
||||
// Update edges array
|
||||
if (updatedState.edges && Array.isArray(updatedState.edges)) {
|
||||
updatedState.edges = updatedState.edges.map((edge) => ({
|
||||
...edge,
|
||||
id: crypto.randomUUID(),
|
||||
source: blockIdMapping.get(edge.source) || edge.source,
|
||||
target: blockIdMapping.get(edge.target) || edge.target,
|
||||
}))
|
||||
}
|
||||
|
||||
// Update loops and parallels if they exist
|
||||
if (updatedState.loops && typeof updatedState.loops === 'object') {
|
||||
const newLoops = {} as Record<string, (typeof updatedState.loops)[string]>
|
||||
for (const [oldId, loopData] of Object.entries(updatedState.loops)) {
|
||||
const newId = blockIdMapping.get(oldId) || oldId
|
||||
const loopConfig = loopData as any
|
||||
newLoops[newId] = {
|
||||
...loopConfig,
|
||||
id: newId,
|
||||
// Update node references in loop config
|
||||
nodes: loopConfig.nodes
|
||||
? loopConfig.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
updatedState.loops = newLoops
|
||||
}
|
||||
|
||||
if (updatedState.parallels && typeof updatedState.parallels === 'object') {
|
||||
const newParallels = {} as Record<string, (typeof updatedState.parallels)[string]>
|
||||
for (const [oldId, parallelData] of Object.entries(updatedState.parallels)) {
|
||||
const newId = blockIdMapping.get(oldId) || oldId
|
||||
const parallelConfig = parallelData as any
|
||||
newParallels[newId] = {
|
||||
...parallelConfig,
|
||||
id: newId,
|
||||
// Update node references in parallel config
|
||||
nodes: parallelConfig.nodes
|
||||
? parallelConfig.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
updatedState.parallels = newParallels
|
||||
}
|
||||
}
|
||||
|
||||
// Update the workflow state with the new block IDs
|
||||
// Update the workflow timestamp
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
state: updatedState,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workflow.id, newWorkflowId))
|
||||
|
||||
@@ -89,7 +89,14 @@ describe('Workflow By ID API Route', () => {
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: null,
|
||||
state: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isFromNormalizedTables: true,
|
||||
}
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
@@ -110,6 +117,10 @@ describe('Workflow By ID API Route', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/workflows/db-helpers', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
@@ -127,7 +138,14 @@ describe('Workflow By ID API Route', () => {
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
state: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isFromNormalizedTables: true,
|
||||
}
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
@@ -148,6 +166,10 @@ describe('Workflow By ID API Route', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/workflows/db-helpers', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('read'),
|
||||
hasAdminPermission: vi.fn().mockResolvedValue(false),
|
||||
@@ -170,7 +192,6 @@ describe('Workflow By ID API Route', () => {
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
state: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
@@ -213,7 +234,6 @@ describe('Workflow By ID API Route', () => {
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: null,
|
||||
state: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
|
||||
@@ -12,8 +12,6 @@ import { apiKey as apiKeyTable, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkflowByIdAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const UpdateWorkflowSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').optional(),
|
||||
description: z.string().optional(),
|
||||
@@ -122,8 +120,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
|
||||
const finalWorkflowData = { ...workflowData }
|
||||
|
||||
if (normalizedData) {
|
||||
logger.debug(`[${requestId}] Found normalized data for workflow ${workflowId}:`, {
|
||||
blocksCount: Object.keys(normalizedData.blocks).length,
|
||||
@@ -133,38 +129,31 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
loops: normalizedData.loops,
|
||||
})
|
||||
|
||||
// Use normalized table data - reconstruct complete state object
|
||||
// First get any existing state properties, then override with normalized data
|
||||
const existingState =
|
||||
workflowData.state && typeof workflowData.state === 'object' ? workflowData.state : {}
|
||||
|
||||
finalWorkflowData.state = {
|
||||
// Default values for expected properties
|
||||
deploymentStatuses: {},
|
||||
hasActiveWebhook: false,
|
||||
// Preserve any existing state properties
|
||||
...existingState,
|
||||
// Override with normalized data (this takes precedence)
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
// Construct response object with workflow data and state from normalized tables
|
||||
const finalWorkflowData = {
|
||||
...workflowData,
|
||||
state: {
|
||||
// Default values for expected properties
|
||||
deploymentStatuses: {},
|
||||
hasActiveWebhook: false,
|
||||
// Data from normalized tables
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
},
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
|
||||
} else {
|
||||
// Fallback to JSON blob
|
||||
logger.info(
|
||||
`[${requestId}] Using JSON blob for workflow ${workflowId} - no normalized data found`
|
||||
)
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`)
|
||||
|
||||
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`)
|
||||
|
||||
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
|
||||
return NextResponse.json({ error: 'Workflow has no normalized data' }, { status: 400 })
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error)
|
||||
|
||||
@@ -3,9 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { db } from '@/db'
|
||||
@@ -13,7 +10,6 @@ import { workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkflowStateAPI')
|
||||
|
||||
// Zod schemas for workflow state validation
|
||||
const PositionSchema = z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
@@ -224,7 +220,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
.set({
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
state: saveResult.jsonBlob, // Also update JSON blob for backward compatibility
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflow } from '@/db/schema'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { simAgentClient } from '@/lib/sim-agent'
|
||||
import { SIM_AGENT_API_URL_DEFAULT, simAgentClient } from '@/lib/sim-agent'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
saveWorkflowToNormalizedTables,
|
||||
@@ -17,15 +18,12 @@ import { db } from '@/db'
|
||||
import { workflowCheckpoints, workflow as workflowTable } from '@/db/schema'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkflowYamlAPI')
|
||||
|
||||
// Request schema for YAML workflow operations
|
||||
const YamlWorkflowRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1, 'YAML content is required'),
|
||||
description: z.string().optional(),
|
||||
@@ -74,7 +72,6 @@ async function createWorkflowCheckpoint(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowState: currentWorkflowData,
|
||||
@@ -288,7 +285,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
@@ -365,6 +361,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
position: { x: number; y: number }
|
||||
subBlocks?: Record<string, any>
|
||||
data?: Record<string, any>
|
||||
parentId?: string
|
||||
extent?: string
|
||||
}>
|
||||
const edges = workflowState.edges
|
||||
const warnings = conversionResult.warnings || []
|
||||
@@ -395,6 +393,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
if (!blockConfig && (block.type === 'loop' || block.type === 'parallel')) {
|
||||
// Handle loop/parallel blocks (they don't have regular block configs)
|
||||
// Preserve parentId if it exists (though loop/parallel shouldn't have parents)
|
||||
const containerData = block.data || {}
|
||||
if (block.parentId) {
|
||||
containerData.parentId = block.parentId
|
||||
containerData.extent = block.extent || 'parent'
|
||||
}
|
||||
|
||||
newWorkflowState.blocks[newId] = {
|
||||
id: newId,
|
||||
type: block.type,
|
||||
@@ -407,7 +412,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
isWide: false,
|
||||
advancedMode: false,
|
||||
height: 0,
|
||||
data: block.data || {},
|
||||
data: containerData,
|
||||
}
|
||||
logger.debug(`[${requestId}] Processed loop/parallel block: ${block.id} -> ${newId}`)
|
||||
} else if (blockConfig) {
|
||||
@@ -440,6 +445,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
// Set up outputs from block configuration
|
||||
const outputs = resolveOutputType(blockConfig.outputs)
|
||||
|
||||
// Preserve parentId if it exists in the imported block
|
||||
const blockData = block.data || {}
|
||||
if (block.parentId) {
|
||||
blockData.parentId = block.parentId
|
||||
blockData.extent = block.extent || 'parent'
|
||||
}
|
||||
|
||||
newWorkflowState.blocks[newId] = {
|
||||
id: newId,
|
||||
type: block.type,
|
||||
@@ -452,7 +464,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
isWide: false,
|
||||
advancedMode: false,
|
||||
height: 0,
|
||||
data: block.data || {},
|
||||
data: blockData,
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Processed regular block: ${block.id} -> ${newId}`)
|
||||
@@ -529,6 +541,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Log block parent-child relationships before generating loops
|
||||
// Generate loop and parallel configurations
|
||||
const loops = generateLoopBlocks(newWorkflowState.blocks)
|
||||
const parallels = generateParallelBlocks(newWorkflowState.blocks)
|
||||
@@ -632,14 +645,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
.set({
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
state: saveResult.jsonBlob,
|
||||
})
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
|
||||
// Notify socket server for real-time collaboration (for copilot and editor)
|
||||
if (source === 'copilot' || source === 'editor') {
|
||||
try {
|
||||
const socketUrl = process.env.SOCKET_URL || 'http://localhost:3002'
|
||||
const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
|
||||
await fetch(`${socketUrl}/api/copilot-workflow-edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -8,9 +8,6 @@ import { workflow, workflowBlocks } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkflowAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Schema for workflow creation
|
||||
const CreateWorkflowSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional().default(''),
|
||||
@@ -154,7 +151,6 @@ export async function POST(req: NextRequest) {
|
||||
folderId: folderId || null,
|
||||
name,
|
||||
description,
|
||||
state: initialState,
|
||||
color,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
|
||||
@@ -3,9 +3,6 @@ import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workspace } from '@/db/schema'
|
||||
|
||||
@@ -8,9 +8,6 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
|
||||
const logger = createLogger('WorkflowYamlAPI')
|
||||
|
||||
// Get API key at module level like working routes
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
@@ -55,7 +52,6 @@ export async function POST(request: NextRequest) {
|
||||
resolveOutputType: resolveOutputType.toString(),
|
||||
},
|
||||
},
|
||||
apiKey: SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
if (!result.success || !result.data?.yaml) {
|
||||
|
||||
179
apps/sim/app/api/workflows/yaml/export/route.ts
Normal file
179
apps/sim/app/api/workflows/yaml/export/route.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { simAgentClient } from '@/lib/sim-agent'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflow } from '@/db/schema'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
const logger = createLogger('WorkflowYamlExportAPI')
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const url = new URL(request.url)
|
||||
const workflowId = url.searchParams.get('workflowId')
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Exporting workflow YAML from database: ${workflowId}`)
|
||||
|
||||
if (!workflowId) {
|
||||
return NextResponse.json({ success: false, error: 'workflowId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the session for authentication
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch the workflow from database
|
||||
const workflowData = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has access to this workflow
|
||||
let hasAccess = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (workflowData.userId === userId) {
|
||||
hasAccess = true
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace the user has permissions for
|
||||
if (!hasAccess && workflowData.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
userId,
|
||||
'workspace',
|
||||
workflowData.workspaceId
|
||||
)
|
||||
if (userPermission !== null) {
|
||||
hasAccess = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Try to load from normalized tables first
|
||||
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
|
||||
let workflowState: any
|
||||
const subBlockValues: Record<string, Record<string, any>> = {}
|
||||
|
||||
if (normalizedData) {
|
||||
logger.debug(`[${requestId}] Found normalized data for workflow ${workflowId}:`, {
|
||||
blocksCount: Object.keys(normalizedData.blocks).length,
|
||||
edgesCount: normalizedData.edges.length,
|
||||
})
|
||||
|
||||
// Use normalized table data - construct state from normalized tables
|
||||
workflowState = {
|
||||
deploymentStatuses: {},
|
||||
hasActiveWebhook: false,
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
}
|
||||
|
||||
// Extract subblock values from the normalized blocks
|
||||
Object.entries(normalizedData.blocks).forEach(([blockId, block]: [string, any]) => {
|
||||
subBlockValues[blockId] = {}
|
||||
if (block.subBlocks) {
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => {
|
||||
if (subBlock && typeof subBlock === 'object' && 'value' in subBlock) {
|
||||
subBlockValues[blockId][subBlockId] = subBlock.value
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Workflow has no normalized data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Gather block registry and utilities for sim-agent
|
||||
const blocks = getAllBlocks()
|
||||
const blockRegistry = blocks.reduce(
|
||||
(acc, block) => {
|
||||
const blockType = block.type
|
||||
acc[blockType] = {
|
||||
...block,
|
||||
id: blockType,
|
||||
subBlocks: block.subBlocks || [],
|
||||
outputs: block.outputs || {},
|
||||
} as any
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, BlockConfig>
|
||||
)
|
||||
|
||||
// Call sim-agent directly
|
||||
const result = await simAgentClient.makeRequest('/api/workflow/to-yaml', {
|
||||
body: {
|
||||
workflowState,
|
||||
subBlockValues,
|
||||
blockRegistry,
|
||||
utilities: {
|
||||
generateLoopBlocks: generateLoopBlocks.toString(),
|
||||
generateParallelBlocks: generateParallelBlocks.toString(),
|
||||
resolveOutputType: resolveOutputType.toString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.success || !result.data?.yaml) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error || 'Failed to generate YAML',
|
||||
},
|
||||
{ status: result.status || 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully generated YAML from database`, {
|
||||
yamlLength: result.data.yaml.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
yaml: result.data.yaml,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] YAML export failed`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to export YAML: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,6 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUsersWithPermissions, hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { db } from '@/db'
|
||||
import { permissions, type permissionTypeEnum } from '@/db/schema'
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workspaceInvitation } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// DELETE /api/workspaces/invitations/[id] - Delete a workspace invitation
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
|
||||
@@ -6,8 +6,6 @@ import { env } from '@/lib/env'
|
||||
import { db } from '@/db'
|
||||
import { permissions, user, workspace, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Accept an invitation via token
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
|
||||
@@ -4,8 +4,6 @@ import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { workspace, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Get invitation details by token
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
|
||||
@@ -5,8 +5,6 @@ import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { permissions } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// DELETE /api/workspaces/members/[id] - Remove a member from a workspace
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: userId } = await params
|
||||
|
||||
@@ -7,8 +7,6 @@ import { permissions, type permissionTypeEnum, user } from '@/db/schema'
|
||||
|
||||
type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Add a member to a workspace
|
||||
export async function POST(req: Request) {
|
||||
const session = await getSession()
|
||||
|
||||
@@ -113,64 +113,6 @@ async function createWorkspace(userId: string, name: string) {
|
||||
|
||||
// Create initial workflow for the workspace with start block
|
||||
const starterId = crypto.randomUUID()
|
||||
const initialState = {
|
||||
blocks: {
|
||||
[starterId]: {
|
||||
id: starterId,
|
||||
type: 'starter',
|
||||
name: 'Start',
|
||||
position: { x: 100, y: 100 },
|
||||
subBlocks: {
|
||||
startWorkflow: {
|
||||
id: 'startWorkflow',
|
||||
type: 'dropdown',
|
||||
value: 'manual',
|
||||
},
|
||||
webhookPath: {
|
||||
id: 'webhookPath',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
webhookSecret: {
|
||||
id: 'webhookSecret',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
scheduleType: {
|
||||
id: 'scheduleType',
|
||||
type: 'dropdown',
|
||||
value: 'daily',
|
||||
},
|
||||
minutesInterval: {
|
||||
id: 'minutesInterval',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
minutesStartingAt: {
|
||||
id: 'minutesStartingAt',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
response: { type: { input: 'any' } },
|
||||
},
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
advancedMode: false,
|
||||
height: 95,
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
subflows: {},
|
||||
variables: {},
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
// Create the workflow
|
||||
await tx.insert(workflow).values({
|
||||
@@ -180,7 +122,6 @@ async function createWorkspace(userId: string, name: string) {
|
||||
folderId: null,
|
||||
name: 'default-agent',
|
||||
description: 'Your first workflow - start building here!',
|
||||
state: initialState,
|
||||
color: '#3972F6',
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -16,8 +18,7 @@ import {
|
||||
const logger = createLogger('YamlAutoLayoutAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const AutoLayoutRequestSchema = z.object({
|
||||
workflowState: z.object({
|
||||
@@ -58,7 +59,6 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Applying auto layout`, {
|
||||
blockCount: Object.keys(workflowState.blocks).length,
|
||||
edgeCount: workflowState.edges.length,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
strategy: options?.strategy || 'smart',
|
||||
simAgentUrl: SIM_AGENT_API_URL,
|
||||
})
|
||||
@@ -102,7 +102,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowState: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -16,8 +18,7 @@ import {
|
||||
const logger = createLogger('YamlDiffCreateAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const CreateDiffRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1),
|
||||
@@ -62,9 +63,13 @@ const CreateDiffRequestSchema = z.object({
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
logger.info(`[${requestId}] ===== YAML DIFF CREATE API CALLED =====`)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
logger.info(`[${requestId}] Request body received, parsing...`)
|
||||
const { yamlContent, diffAnalysis, options } = CreateDiffRequestSchema.parse(body)
|
||||
logger.info(`[${requestId}] Request parsed successfully`)
|
||||
|
||||
// Get current workflow state for comparison
|
||||
// Note: This endpoint is stateless, so we need to get this from the request
|
||||
@@ -85,7 +90,6 @@ export async function POST(request: NextRequest) {
|
||||
hasDiffAnalysis: !!diffAnalysis,
|
||||
hasOptions: !!options,
|
||||
options: options,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
hasCurrentWorkflowState: !!currentWorkflowState,
|
||||
currentBlockCount: currentWorkflowState
|
||||
? Object.keys(currentWorkflowState.blocks || {}).length
|
||||
@@ -113,7 +117,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
@@ -151,6 +154,36 @@ export async function POST(request: NextRequest) {
|
||||
// Log the full response to see if auto-layout is happening
|
||||
logger.info(`[${requestId}] Full sim agent response:`, JSON.stringify(result, null, 2))
|
||||
|
||||
// Log detailed block information to debug parent-child relationships
|
||||
if (result.success) {
|
||||
const blocks = result.diff?.proposedState?.blocks || result.blocks || {}
|
||||
logger.info(`[${requestId}] Sim agent blocks with parent-child info:`)
|
||||
Object.entries(blocks).forEach(([blockId, block]: [string, any]) => {
|
||||
if (block.data?.parentId || block.parentId) {
|
||||
logger.info(`[${requestId}] Child block ${blockId} (${block.name}):`, {
|
||||
type: block.type,
|
||||
parentId: block.data?.parentId || block.parentId,
|
||||
extent: block.data?.extent || block.extent,
|
||||
hasDataField: !!block.data,
|
||||
dataKeys: block.data ? Object.keys(block.data) : [],
|
||||
})
|
||||
}
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
logger.info(`[${requestId}] Container block ${blockId} (${block.name}):`, {
|
||||
type: block.type,
|
||||
hasData: !!block.data,
|
||||
dataKeys: block.data ? Object.keys(block.data) : [],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Log existing loops/parallels from sim-agent
|
||||
const loops = result.diff?.proposedState?.loops || result.loops || {}
|
||||
const parallels = result.diff?.proposedState?.parallels || result.parallels || {}
|
||||
logger.info(`[${requestId}] Sim agent loops:`, loops)
|
||||
logger.info(`[${requestId}] Sim agent parallels:`, parallels)
|
||||
}
|
||||
|
||||
// Log diff analysis specifically
|
||||
if (result.diff?.diffAnalysis) {
|
||||
logger.info(`[${requestId}] Diff analysis received:`, {
|
||||
@@ -164,19 +197,127 @@ export async function POST(request: NextRequest) {
|
||||
logger.warn(`[${requestId}] No diff analysis in response!`)
|
||||
}
|
||||
|
||||
// Post-process the result to ensure loops and parallels are properly generated
|
||||
const finalResult = result
|
||||
|
||||
if (result.success && result.diff?.proposedState) {
|
||||
// First, fix parent-child relationships based on edges
|
||||
const blocks = result.diff.proposedState.blocks
|
||||
const edges = result.diff.proposedState.edges || []
|
||||
|
||||
// Find all loop and parallel blocks
|
||||
const containerBlocks = Object.values(blocks).filter(
|
||||
(block: any) => block.type === 'loop' || block.type === 'parallel'
|
||||
)
|
||||
|
||||
// For each container, find its children based on loop-start edges
|
||||
containerBlocks.forEach((container: any) => {
|
||||
// Log all edges from this container to debug
|
||||
const allEdgesFromContainer = edges.filter((edge: any) => edge.source === container.id)
|
||||
logger.info(
|
||||
`[${requestId}] All edges from container ${container.id}:`,
|
||||
allEdgesFromContainer.map((e: any) => ({
|
||||
id: e.id,
|
||||
sourceHandle: e.sourceHandle,
|
||||
target: e.target,
|
||||
}))
|
||||
)
|
||||
|
||||
const childEdges = edges.filter(
|
||||
(edge: any) => edge.source === container.id && edge.sourceHandle === 'loop-start-source'
|
||||
)
|
||||
|
||||
childEdges.forEach((edge: any) => {
|
||||
const childBlock = blocks[edge.target]
|
||||
if (childBlock) {
|
||||
// Ensure data field exists
|
||||
if (!childBlock.data) {
|
||||
childBlock.data = {}
|
||||
}
|
||||
// Set parentId and extent
|
||||
childBlock.data.parentId = container.id
|
||||
childBlock.data.extent = 'parent'
|
||||
|
||||
logger.info(`[${requestId}] Fixed parent-child relationship:`, {
|
||||
parent: container.id,
|
||||
parentName: container.name,
|
||||
child: childBlock.id,
|
||||
childName: childBlock.name,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Now regenerate loops and parallels with the fixed relationships
|
||||
const loops = generateLoopBlocks(result.diff.proposedState.blocks)
|
||||
const parallels = generateParallelBlocks(result.diff.proposedState.blocks)
|
||||
|
||||
result.diff.proposedState.loops = loops
|
||||
result.diff.proposedState.parallels = parallels
|
||||
|
||||
logger.info(`[${requestId}] Regenerated loops and parallels after fixing parent-child:`, {
|
||||
loopsCount: Object.keys(loops).length,
|
||||
parallelsCount: Object.keys(parallels).length,
|
||||
loops: Object.keys(loops).map((id) => ({
|
||||
id,
|
||||
nodes: loops[id].nodes,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// If the sim agent returned blocks directly (when auto-layout is applied),
|
||||
// transform it to the expected diff format
|
||||
if (result.success && result.blocks && !result.diff) {
|
||||
logger.info(`[${requestId}] Transforming sim agent blocks response to diff format`)
|
||||
|
||||
// First, fix parent-child relationships based on edges
|
||||
const blocks = result.blocks
|
||||
const edges = result.edges || []
|
||||
|
||||
// Find all loop and parallel blocks
|
||||
const containerBlocks = Object.values(blocks).filter(
|
||||
(block: any) => block.type === 'loop' || block.type === 'parallel'
|
||||
)
|
||||
|
||||
// For each container, find its children based on loop-start edges
|
||||
containerBlocks.forEach((container: any) => {
|
||||
const childEdges = edges.filter(
|
||||
(edge: any) => edge.source === container.id && edge.sourceHandle === 'loop-start-source'
|
||||
)
|
||||
|
||||
childEdges.forEach((edge: any) => {
|
||||
const childBlock = blocks[edge.target]
|
||||
if (childBlock) {
|
||||
// Ensure data field exists
|
||||
if (!childBlock.data) {
|
||||
childBlock.data = {}
|
||||
}
|
||||
// Set parentId and extent
|
||||
childBlock.data.parentId = container.id
|
||||
childBlock.data.extent = 'parent'
|
||||
|
||||
logger.info(`[${requestId}] Fixed parent-child relationship (auto-layout):`, {
|
||||
parent: container.id,
|
||||
parentName: container.name,
|
||||
child: childBlock.id,
|
||||
childName: childBlock.name,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Generate loops and parallels for the blocks with fixed relationships
|
||||
const loops = generateLoopBlocks(result.blocks)
|
||||
const parallels = generateParallelBlocks(result.blocks)
|
||||
|
||||
const transformedResult = {
|
||||
success: result.success,
|
||||
diff: {
|
||||
proposedState: {
|
||||
blocks: result.blocks,
|
||||
edges: result.edges || [],
|
||||
loops: result.loops || {},
|
||||
parallels: result.parallels || {},
|
||||
loops: loops,
|
||||
parallels: parallels,
|
||||
},
|
||||
diffAnalysis: diffAnalysis,
|
||||
metadata: result.metadata || {
|
||||
@@ -190,7 +331,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(transformedResult)
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
return NextResponse.json(finalResult)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Diff creation failed:`, error)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -16,8 +18,7 @@ import {
|
||||
const logger = createLogger('YamlDiffMergeAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const MergeDiffRequestSchema = z.object({
|
||||
existingDiff: z.object({
|
||||
@@ -64,7 +65,6 @@ export async function POST(request: NextRequest) {
|
||||
hasDiffAnalysis: !!diffAnalysis,
|
||||
hasOptions: !!options,
|
||||
options: options,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry
|
||||
@@ -88,7 +88,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
existingDiff,
|
||||
@@ -126,19 +125,146 @@ export async function POST(request: NextRequest) {
|
||||
// Log the full response to see if auto-layout is happening
|
||||
logger.info(`[${requestId}] Full sim agent response:`, JSON.stringify(result, null, 2))
|
||||
|
||||
// Log detailed block information to debug parent-child relationships
|
||||
if (result.success) {
|
||||
const blocks = result.diff?.proposedState?.blocks || result.blocks || {}
|
||||
logger.info(`[${requestId}] Sim agent blocks with parent-child info:`)
|
||||
Object.entries(blocks).forEach(([blockId, block]: [string, any]) => {
|
||||
if (block.data?.parentId || block.parentId) {
|
||||
logger.info(`[${requestId}] Child block ${blockId} (${block.name}):`, {
|
||||
type: block.type,
|
||||
parentId: block.data?.parentId || block.parentId,
|
||||
extent: block.data?.extent || block.extent,
|
||||
hasDataField: !!block.data,
|
||||
dataKeys: block.data ? Object.keys(block.data) : [],
|
||||
})
|
||||
}
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
logger.info(`[${requestId}] Container block ${blockId} (${block.name}):`, {
|
||||
type: block.type,
|
||||
hasData: !!block.data,
|
||||
dataKeys: block.data ? Object.keys(block.data) : [],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Log existing loops/parallels from sim-agent
|
||||
const loops = result.diff?.proposedState?.loops || result.loops || {}
|
||||
const parallels = result.diff?.proposedState?.parallels || result.parallels || {}
|
||||
logger.info(`[${requestId}] Sim agent loops:`, loops)
|
||||
logger.info(`[${requestId}] Sim agent parallels:`, parallels)
|
||||
}
|
||||
|
||||
// Post-process the result to ensure loops and parallels are properly generated
|
||||
const finalResult = result
|
||||
|
||||
if (result.success && result.diff?.proposedState) {
|
||||
// First, fix parent-child relationships based on edges
|
||||
const blocks = result.diff.proposedState.blocks
|
||||
const edges = result.diff.proposedState.edges || []
|
||||
|
||||
// Find all loop and parallel blocks
|
||||
const containerBlocks = Object.values(blocks).filter(
|
||||
(block: any) => block.type === 'loop' || block.type === 'parallel'
|
||||
)
|
||||
|
||||
// For each container, find its children based on loop-start edges
|
||||
containerBlocks.forEach((container: any) => {
|
||||
const childEdges = edges.filter(
|
||||
(edge: any) => edge.source === container.id && edge.sourceHandle === 'loop-start-source'
|
||||
)
|
||||
|
||||
childEdges.forEach((edge: any) => {
|
||||
const childBlock = blocks[edge.target]
|
||||
if (childBlock) {
|
||||
// Ensure data field exists
|
||||
if (!childBlock.data) {
|
||||
childBlock.data = {}
|
||||
}
|
||||
// Set parentId and extent
|
||||
childBlock.data.parentId = container.id
|
||||
childBlock.data.extent = 'parent'
|
||||
|
||||
logger.info(`[${requestId}] Fixed parent-child relationship:`, {
|
||||
parent: container.id,
|
||||
parentName: container.name,
|
||||
child: childBlock.id,
|
||||
childName: childBlock.name,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Now regenerate loops and parallels with the fixed relationships
|
||||
const loops = generateLoopBlocks(result.diff.proposedState.blocks)
|
||||
const parallels = generateParallelBlocks(result.diff.proposedState.blocks)
|
||||
|
||||
result.diff.proposedState.loops = loops
|
||||
result.diff.proposedState.parallels = parallels
|
||||
|
||||
logger.info(`[${requestId}] Regenerated loops and parallels after fixing parent-child:`, {
|
||||
loopsCount: Object.keys(loops).length,
|
||||
parallelsCount: Object.keys(parallels).length,
|
||||
loops: Object.keys(loops).map((id) => ({
|
||||
id,
|
||||
nodes: loops[id].nodes,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// If the sim agent returned blocks directly (when auto-layout is applied),
|
||||
// transform it to the expected diff format
|
||||
if (result.success && result.blocks && !result.diff) {
|
||||
logger.info(`[${requestId}] Transforming sim agent blocks response to diff format`)
|
||||
|
||||
// First, fix parent-child relationships based on edges
|
||||
const blocks = result.blocks
|
||||
const edges = result.edges || []
|
||||
|
||||
// Find all loop and parallel blocks
|
||||
const containerBlocks = Object.values(blocks).filter(
|
||||
(block: any) => block.type === 'loop' || block.type === 'parallel'
|
||||
)
|
||||
|
||||
// For each container, find its children based on loop-start edges
|
||||
containerBlocks.forEach((container: any) => {
|
||||
const childEdges = edges.filter(
|
||||
(edge: any) => edge.source === container.id && edge.sourceHandle === 'loop-start-source'
|
||||
)
|
||||
|
||||
childEdges.forEach((edge: any) => {
|
||||
const childBlock = blocks[edge.target]
|
||||
if (childBlock) {
|
||||
// Ensure data field exists
|
||||
if (!childBlock.data) {
|
||||
childBlock.data = {}
|
||||
}
|
||||
// Set parentId and extent
|
||||
childBlock.data.parentId = container.id
|
||||
childBlock.data.extent = 'parent'
|
||||
|
||||
logger.info(`[${requestId}] Fixed parent-child relationship (auto-layout):`, {
|
||||
parent: container.id,
|
||||
parentName: container.name,
|
||||
child: childBlock.id,
|
||||
childName: childBlock.name,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Generate loops and parallels for the blocks with fixed relationships
|
||||
const loops = generateLoopBlocks(result.blocks)
|
||||
const parallels = generateParallelBlocks(result.blocks)
|
||||
|
||||
const transformedResult = {
|
||||
success: result.success,
|
||||
diff: {
|
||||
proposedState: {
|
||||
blocks: result.blocks,
|
||||
edges: result.edges || existingDiff.proposedState.edges || [],
|
||||
loops: result.loops || existingDiff.proposedState.loops || {},
|
||||
parallels: result.parallels || existingDiff.proposedState.parallels || {},
|
||||
loops: loops,
|
||||
parallels: parallels,
|
||||
},
|
||||
diffAnalysis: diffAnalysis,
|
||||
metadata: result.metadata || {
|
||||
@@ -152,7 +278,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(transformedResult)
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
return NextResponse.json(finalResult)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Diff merge failed:`, error)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -9,8 +11,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
const logger = createLogger('YamlGenerateAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const GenerateRequestSchema = z.object({
|
||||
workflowState: z.any(), // Let the yaml service handle validation
|
||||
@@ -27,7 +28,6 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Generating YAML from workflow`, {
|
||||
blocksCount: workflowState.blocks ? Object.keys(workflowState.blocks).length : 0,
|
||||
edgesCount: workflowState.edges ? workflowState.edges.length : 0,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry and utilities
|
||||
@@ -51,7 +51,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowState,
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
|
||||
const logger = createLogger('YamlHealthAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
export async function GET() {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Checking YAML service health`, {
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
logger.info(`[${requestId}] Checking YAML service health`)
|
||||
|
||||
// Check sim-agent health
|
||||
const response = await fetch(`${SIM_AGENT_API_URL}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -9,11 +11,10 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
const logger = createLogger('YamlParseAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const ParseRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1),
|
||||
yamlContent: z.string(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -25,7 +26,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Parsing YAML`, {
|
||||
contentLength: yamlContent.length,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry and utilities
|
||||
@@ -49,7 +49,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -9,8 +11,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
const logger = createLogger('YamlToWorkflowAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const ConvertRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1),
|
||||
@@ -33,7 +34,6 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Converting YAML to workflow`, {
|
||||
contentLength: yamlContent.length,
|
||||
hasOptions: !!options,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry and utilities
|
||||
@@ -57,7 +57,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
|
||||
@@ -429,7 +429,8 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
if (authRequired) {
|
||||
// Get title and description from the URL params or use defaults
|
||||
const title = new URLSearchParams(window.location.search).get('title') || 'chat'
|
||||
const primaryColor = new URLSearchParams(window.location.search).get('color') || '#802FFF'
|
||||
const primaryColor =
|
||||
new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)'
|
||||
|
||||
if (authRequired === 'password') {
|
||||
return (
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function EmailAuth({
|
||||
subdomain,
|
||||
onAuthSuccess,
|
||||
title = 'chat',
|
||||
primaryColor = '#802FFF',
|
||||
primaryColor = 'var(--brand-primary-hover-hex)',
|
||||
}: EmailAuthProps) {
|
||||
// Email auth state
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -149,10 +149,10 @@ export default function EmailAuth({
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='rounded-[6px]'
|
||||
>
|
||||
<rect width='50' height='50' fill='#701FFC' />
|
||||
<rect width='50' height='50' fill='var(--brand-primary-hex)' />
|
||||
<path
|
||||
d='M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z'
|
||||
fill='#701FFC'
|
||||
fill='var(--brand-primary-hex)'
|
||||
stroke='white'
|
||||
strokeWidth='3.5'
|
||||
strokeLinecap='round'
|
||||
@@ -160,7 +160,7 @@ export default function EmailAuth({
|
||||
/>
|
||||
<path
|
||||
d='M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z'
|
||||
fill='#701FFC'
|
||||
fill='var(--brand-primary-hex)'
|
||||
stroke='white'
|
||||
strokeWidth='4'
|
||||
strokeLinecap='round'
|
||||
@@ -168,7 +168,7 @@ export default function EmailAuth({
|
||||
/>
|
||||
<path
|
||||
d='M25.0915 14.856V19.0277V14.856ZM20.5645 32.1398V29.1216V32.1398ZM29.619 29.1216V32.1398V29.1216Z'
|
||||
fill='#701FFC'
|
||||
fill='var(--brand-primary-hex)'
|
||||
/>
|
||||
<path
|
||||
d='M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398'
|
||||
@@ -177,7 +177,7 @@ export default function EmailAuth({
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<circle cx='25' cy='11' r='2' fill='#701FFC' />
|
||||
<circle cx='25' cy='11' r='2' fill='var(--brand-primary-hex)' />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function PasswordAuth({
|
||||
subdomain,
|
||||
onAuthSuccess,
|
||||
title = 'chat',
|
||||
primaryColor = '#802FFF',
|
||||
primaryColor = 'var(--brand-primary-hover-hex)',
|
||||
}: PasswordAuthProps) {
|
||||
// Password auth state
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -91,10 +91,10 @@ export default function PasswordAuth({
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='rounded-[6px]'
|
||||
>
|
||||
<rect width='50' height='50' fill='#701FFC' />
|
||||
<rect width='50' height='50' fill='var(--brand-primary-hex)' />
|
||||
<path
|
||||
d='M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z'
|
||||
fill='#701FFC'
|
||||
fill='var(--brand-primary-hex)'
|
||||
stroke='white'
|
||||
strokeWidth='3.5'
|
||||
strokeLinecap='round'
|
||||
@@ -102,7 +102,7 @@ export default function PasswordAuth({
|
||||
/>
|
||||
<path
|
||||
d='M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z'
|
||||
fill='#701FFC'
|
||||
fill='var(--brand-primary-hex)'
|
||||
stroke='white'
|
||||
strokeWidth='4'
|
||||
strokeLinecap='round'
|
||||
@@ -110,7 +110,7 @@ export default function PasswordAuth({
|
||||
/>
|
||||
<path
|
||||
d='M25.0915 14.856V19.0277V14.856ZM20.5645 32.1398V29.1216V32.1398ZM29.619 29.1216V32.1398V29.1216Z'
|
||||
fill='#701FFC'
|
||||
fill='var(--brand-primary-hex)'
|
||||
/>
|
||||
<path
|
||||
d='M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398'
|
||||
@@ -119,7 +119,7 @@ export default function PasswordAuth({
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<circle cx='25' cy='11' r='2' fill='#701FFC' />
|
||||
<circle cx='25' cy='11' r='2' fill='var(--brand-primary-hex)' />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -21,10 +21,10 @@ export function ChatErrorState({ error, starCount }: ChatErrorStateProps) {
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='rounded-[6px]'
|
||||
>
|
||||
<rect width='50' height='50' fill='#701FFC' />
|
||||
<rect width='50' height='50' fill='var(--brand-primary-hex)' />
|
||||
<path
|
||||
d='M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z'
|
||||
fill='#701FFC'
|
||||
fill='var(--brand-primary-hex)'
|
||||
stroke='white'
|
||||
strokeWidth='3.5'
|
||||
strokeLinecap='round'
|
||||
@@ -32,7 +32,7 @@ export function ChatErrorState({ error, starCount }: ChatErrorStateProps) {
|
||||
/>
|
||||
<path
|
||||
d='M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z'
|
||||
fill='#701FFC'
|
||||
fill='var(--brand-primary-hex)'
|
||||
stroke='white'
|
||||
strokeWidth='4'
|
||||
strokeLinecap='round'
|
||||
@@ -40,7 +40,7 @@ export function ChatErrorState({ error, starCount }: ChatErrorStateProps) {
|
||||
/>
|
||||
<path
|
||||
d='M25.0915 14.856V19.0277V14.856ZM20.5645 32.1398V29.1216V32.1398ZM29.619 29.1216V32.1398V29.1216Z'
|
||||
fill='#701FFC'
|
||||
fill='var(--brand-primary-hex)'
|
||||
/>
|
||||
<path
|
||||
d='M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398'
|
||||
@@ -49,7 +49,7 @@ export function ChatErrorState({ error, starCount }: ChatErrorStateProps) {
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<circle cx='25' cy='11' r='2' fill='#701FFC' />
|
||||
<circle cx='25' cy='11' r='2' fill='var(--brand-primary-hex)' />
|
||||
</svg>
|
||||
</a>
|
||||
<ChatHeader chatConfig={null} starCount={starCount} />
|
||||
|
||||
@@ -16,7 +16,7 @@ interface ChatHeaderProps {
|
||||
}
|
||||
|
||||
export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
|
||||
const primaryColor = chatConfig?.customizations?.primaryColor || '#701FFC'
|
||||
const primaryColor = chatConfig?.customizations?.primaryColor || 'var(--brand-primary-hex)'
|
||||
const customImage = chatConfig?.customizations?.imageUrl || chatConfig?.customizations?.logoUrl
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
}
|
||||
|
||||
.workflow-container .react-flow__node-loopNode,
|
||||
.workflow-container .react-flow__node-parallelNode {
|
||||
.workflow-container .react-flow__node-parallelNode,
|
||||
.workflow-container .react-flow__node-subflowNode {
|
||||
z-index: -1 !important;
|
||||
}
|
||||
|
||||
@@ -93,6 +94,17 @@
|
||||
/* Gradient Colors */
|
||||
--gradient-primary: 263 85% 70%; /* More vibrant purple */
|
||||
--gradient-secondary: 336 95% 65%; /* More vibrant pink */
|
||||
|
||||
/* Brand Colors (Default Sim Theme) */
|
||||
--brand-primary-hex: #701ffc; /* Primary brand purple */
|
||||
--brand-primary-hover-hex: #802fff; /* Primary brand purple hover */
|
||||
--brand-secondary-hex: #6518e6; /* Secondary brand purple */
|
||||
--brand-accent-hex: #9d54ff; /* Accent purple for links */
|
||||
--brand-accent-hover-hex: #a66fff; /* Accent purple hover */
|
||||
--brand-background-hex: #0c0c0c; /* Primary dark background */
|
||||
|
||||
/* UI Surface Colors */
|
||||
--surface-elevated: #202020; /* Elevated surface background for dark mode */
|
||||
}
|
||||
|
||||
/* Dark Mode Theme */
|
||||
@@ -153,6 +165,17 @@
|
||||
/* Gradient Colors - Adjusted for dark mode */
|
||||
--gradient-primary: 263 90% 75%; /* More vibrant purple for dark mode */
|
||||
--gradient-secondary: 336 100% 72%; /* More vibrant pink for dark mode */
|
||||
|
||||
/* Brand Colors (Same in dark mode) */
|
||||
--brand-primary-hex: #701ffc; /* Primary brand purple */
|
||||
--brand-primary-hover-hex: #802fff; /* Primary brand purple hover */
|
||||
--brand-secondary-hex: #6518e6; /* Secondary brand purple */
|
||||
--brand-accent-hex: #9d54ff; /* Accent purple for links */
|
||||
--brand-accent-hover-hex: #a66fff; /* Accent purple hover */
|
||||
--brand-background-hex: #0c0c0c; /* Primary dark background */
|
||||
|
||||
/* UI Surface Colors */
|
||||
--surface-elevated: #202020; /* Elevated surface background for dark mode */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +363,60 @@ input[type="search"]::-ms-clear {
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Brand Color Utilities */
|
||||
.bg-brand-primary {
|
||||
background-color: var(--brand-primary-hex);
|
||||
}
|
||||
|
||||
.bg-brand-primary-hover {
|
||||
background-color: var(--brand-primary-hover-hex);
|
||||
}
|
||||
|
||||
.bg-brand-secondary {
|
||||
background-color: var(--brand-secondary-hex);
|
||||
}
|
||||
|
||||
.bg-brand-accent {
|
||||
background-color: var(--brand-accent-hex);
|
||||
}
|
||||
|
||||
.bg-brand-background {
|
||||
background-color: var(--brand-background-hex);
|
||||
}
|
||||
|
||||
.text-brand-primary {
|
||||
color: var(--brand-primary-hex);
|
||||
}
|
||||
|
||||
.text-brand-accent {
|
||||
color: var(--brand-accent-hex);
|
||||
}
|
||||
|
||||
.text-brand-accent-hover {
|
||||
color: var(--brand-accent-hover-hex);
|
||||
}
|
||||
|
||||
.border-brand-primary {
|
||||
border-color: var(--brand-primary-hex);
|
||||
}
|
||||
|
||||
.hover\:bg-brand-primary-hover:hover {
|
||||
background-color: var(--brand-primary-hover-hex);
|
||||
}
|
||||
|
||||
.hover\:bg-brand-secondary:hover {
|
||||
background-color: var(--brand-secondary-hex);
|
||||
}
|
||||
|
||||
.hover\:text-brand-accent-hover:hover {
|
||||
color: var(--brand-accent-hover-hex);
|
||||
}
|
||||
|
||||
/* Surface Utilities */
|
||||
.bg-surface-elevated {
|
||||
background-color: var(--surface-elevated);
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
.animate-pulse-ring {
|
||||
animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { env } from '@/lib/env'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import { TelemetryConsentDialog } from '@/app/telemetry-consent-dialog'
|
||||
import '@/app/globals.css'
|
||||
|
||||
import { ZoomPrevention } from '@/app/zoom-prevention'
|
||||
@@ -110,7 +109,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<body suppressHydrationWarning>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
<TelemetryConsentDialog />
|
||||
{children}
|
||||
{isHosted && (
|
||||
<>
|
||||
|
||||
@@ -11,8 +11,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
'Build and deploy AI agents using our Figma-like canvas. Build, write evals, and deploy AI agent workflows that automate workflows and streamline your business processes.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: brand.primaryColor || '#ffffff',
|
||||
theme_color: brand.primaryColor || '#ffffff',
|
||||
background_color: '#701FFC', // Default Sim brand primary color
|
||||
theme_color: '#701FFC', // Default Sim brand primary color
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon/android-chrome-192x192.png',
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SIM_TELEMETRY_ENABLED?: boolean
|
||||
__SIM_TRACK_EVENT?: (eventName: string, properties?: Record<string, any>) => void
|
||||
}
|
||||
}
|
||||
|
||||
const logger = createLogger('TelemetryConsentDialog')
|
||||
|
||||
// LocalStorage key for telemetry preferences
|
||||
const TELEMETRY_NOTIFIED_KEY = 'sim_telemetry_notified'
|
||||
const TELEMETRY_ENABLED_KEY = 'sim_telemetry_enabled'
|
||||
|
||||
const trackEvent = (eventName: string, properties?: Record<string, any>) => {
|
||||
if (typeof window !== 'undefined' && window.__SIM_TELEMETRY_ENABLED) {
|
||||
try {
|
||||
if (window.__SIM_TRACK_EVENT) {
|
||||
window.__SIM_TRACK_EVENT(eventName, properties)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to track event ${eventName}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function TelemetryConsentDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
||||
const telemetryEnabled = useGeneralStore((state) => state.telemetryEnabled)
|
||||
const telemetryNotifiedUser = useGeneralStore((state) => state.telemetryNotifiedUser)
|
||||
const setTelemetryEnabled = useGeneralStore((state) => state.setTelemetryEnabled)
|
||||
const setTelemetryNotifiedUser = useGeneralStore((state) => state.setTelemetryNotifiedUser)
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
|
||||
const hasShownDialogThisSession = useRef(false)
|
||||
|
||||
const isChatSubdomainOrPath =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.pathname.startsWith('/chat/') ||
|
||||
(window.location.hostname !== 'sim.ai' &&
|
||||
window.location.hostname !== 'localhost' &&
|
||||
window.location.hostname !== '127.0.0.1' &&
|
||||
!window.location.hostname.startsWith('www.')))
|
||||
|
||||
// Check localStorage for saved preferences
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || isChatSubdomainOrPath) return
|
||||
|
||||
try {
|
||||
const notified = localStorage.getItem(TELEMETRY_NOTIFIED_KEY) === 'true'
|
||||
const enabled = localStorage.getItem(TELEMETRY_ENABLED_KEY)
|
||||
|
||||
if (notified) {
|
||||
setTelemetryNotifiedUser(true)
|
||||
}
|
||||
|
||||
if (enabled !== null) {
|
||||
setTelemetryEnabled(enabled === 'true')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error reading telemetry preferences from localStorage:', error)
|
||||
}
|
||||
}, [setTelemetryNotifiedUser, setTelemetryEnabled, isChatSubdomainOrPath])
|
||||
|
||||
useEffect(() => {
|
||||
// Skip settings loading on chat subdomain pages
|
||||
if (isChatSubdomainOrPath) {
|
||||
setSettingsLoaded(true)
|
||||
return
|
||||
}
|
||||
|
||||
let isMounted = true
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
await loadSettings(true)
|
||||
if (isMounted) {
|
||||
setSettingsLoaded(true)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load settings:', error)
|
||||
if (isMounted) {
|
||||
setSettingsLoaded(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchSettings()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [loadSettings, isChatSubdomainOrPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded || isChatSubdomainOrPath) return
|
||||
|
||||
logger.debug('Settings loaded state:', {
|
||||
telemetryNotifiedUser,
|
||||
telemetryEnabled,
|
||||
hasShownInSession: hasShownDialogThisSession.current,
|
||||
environment: isDev,
|
||||
})
|
||||
|
||||
const localStorageNotified =
|
||||
typeof window !== 'undefined' && localStorage.getItem(TELEMETRY_NOTIFIED_KEY) === 'true'
|
||||
|
||||
// Only show dialog if:
|
||||
// 1. Settings are fully loaded from the database
|
||||
// 2. User has not been notified yet (according to database AND localStorage)
|
||||
// 3. Telemetry is currently enabled (default)
|
||||
// 4. Dialog hasn't been shown in this session already (extra protection)
|
||||
// 5. We're in development environment
|
||||
if (
|
||||
settingsLoaded &&
|
||||
!telemetryNotifiedUser &&
|
||||
!localStorageNotified &&
|
||||
telemetryEnabled &&
|
||||
!hasShownDialogThisSession.current &&
|
||||
isDev
|
||||
) {
|
||||
setOpen(true)
|
||||
hasShownDialogThisSession.current = true
|
||||
} else if (settingsLoaded && !telemetryNotifiedUser && !isDev) {
|
||||
// Auto-notify in non-development environments
|
||||
setTelemetryNotifiedUser(true)
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(TELEMETRY_NOTIFIED_KEY, 'true')
|
||||
} catch (error) {
|
||||
logger.error('Error saving telemetry notification to localStorage:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
settingsLoaded,
|
||||
telemetryNotifiedUser,
|
||||
telemetryEnabled,
|
||||
setTelemetryNotifiedUser,
|
||||
isChatSubdomainOrPath,
|
||||
])
|
||||
|
||||
const handleAccept = () => {
|
||||
trackEvent('telemetry_consent_accepted', {
|
||||
source: 'consent_dialog',
|
||||
defaultEnabled: true,
|
||||
})
|
||||
|
||||
setTelemetryNotifiedUser(true)
|
||||
setOpen(false)
|
||||
|
||||
// Save preference to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(TELEMETRY_NOTIFIED_KEY, 'true')
|
||||
localStorage.setItem(TELEMETRY_ENABLED_KEY, 'true')
|
||||
} catch (error) {
|
||||
logger.error('Error saving telemetry preferences to localStorage:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDecline = () => {
|
||||
trackEvent('telemetry_consent_declined', {
|
||||
source: 'consent_dialog',
|
||||
defaultEnabled: false,
|
||||
})
|
||||
|
||||
setTelemetryEnabled(false)
|
||||
setTelemetryNotifiedUser(true)
|
||||
setOpen(false)
|
||||
|
||||
// Save preference to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(TELEMETRY_NOTIFIED_KEY, 'true')
|
||||
localStorage.setItem(TELEMETRY_ENABLED_KEY, 'false')
|
||||
} catch (error) {
|
||||
logger.error('Error saving telemetry preferences to localStorage:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent className='max-w-md'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className='mb-2 font-bold text-2xl'>Telemetry</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className='space-y-4 text-base text-muted-foreground'>
|
||||
<div>
|
||||
To help us improve Sim, we collect anonymous usage data by default. This helps us
|
||||
understand which features are most useful and identify areas for improvement.
|
||||
</div>
|
||||
|
||||
<div className='py-2'>
|
||||
<div className='mb-2 font-semibold text-foreground'>We only collect:</div>
|
||||
<ul className='list-disc space-y-1 pl-6'>
|
||||
<li>Feature usage statistics</li>
|
||||
<li>Error reports (without personal info)</li>
|
||||
<li>Performance metrics</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='py-2'>
|
||||
<div className='mb-2 font-semibold text-foreground'>We never collect:</div>
|
||||
<ul className='list-disc space-y-1 pl-6'>
|
||||
<li>Personal information</li>
|
||||
<li>Workflow content or outputs</li>
|
||||
<li>API keys or tokens</li>
|
||||
<li>IP addresses or location data</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='pt-2 text-muted-foreground text-sm'>
|
||||
You can change this setting anytime in{' '}
|
||||
<span className='font-medium'>Settings → Privacy</span>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className='mt-4 flex flex-col gap-3 sm:flex-row'>
|
||||
<AlertDialogCancel asChild onClick={handleDecline}>
|
||||
<Button variant='outline' className='flex-1'>
|
||||
Disable telemetry
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild onClick={handleAccept}>
|
||||
<Button className='flex-1'>Continue with telemetry</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ function UnsubscribeContent() {
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-[#701ffc] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[#802FFF]'
|
||||
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
|
||||
@@ -192,7 +192,7 @@ export function CreateChunkModal({
|
||||
<Button
|
||||
onClick={handleCreateChunk}
|
||||
disabled={!isFormValid || isCreating}
|
||||
className='bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-secondary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function DocumentLoading({
|
||||
<Button
|
||||
disabled
|
||||
size='sm'
|
||||
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
|
||||
className='flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
|
||||
>
|
||||
<Plus className='h-3.5 w-3.5' />
|
||||
<span>Create Chunk</span>
|
||||
|
||||
@@ -312,7 +312,7 @@ export function EditChunkModal({
|
||||
<Button
|
||||
onClick={handleSaveContent}
|
||||
disabled={!isFormValid || isSaving || !hasUnsavedChanges || isNavigating}
|
||||
className='bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
||||
@@ -314,7 +314,7 @@ export function Document({
|
||||
onCheckedChange={(checked) => handleSelectChunk(chunk.id, checked as boolean)}
|
||||
disabled={!userPermissions.canEdit}
|
||||
aria-label={`Select chunk ${chunk.chunkIndex}`}
|
||||
className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[#701FFC]/20 data-[state=checked]:border-[#701FFC] data-[state=checked]:bg-[#701FFC] [&>*]:h-3 [&>*]:w-3'
|
||||
className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[var(--brand-primary-hex)]/20 data-[state=checked]:border-[var(--brand-primary-hex)] data-[state=checked]:bg-[var(--brand-primary-hex)] [&>*]:h-3 [&>*]:w-3'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
@@ -685,7 +685,7 @@ export function Document({
|
||||
onClick={() => setIsCreateChunkModalOpen(true)}
|
||||
disabled={documentData?.processingStatus === 'failed' || !userPermissions.canEdit}
|
||||
size='sm'
|
||||
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-white shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
className='flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-white shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-secondary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<Plus className='h-3.5 w-3.5' />
|
||||
<span>Create Chunk</span>
|
||||
@@ -726,7 +726,7 @@ export function Document({
|
||||
!userPermissions.canEdit
|
||||
}
|
||||
aria-label='Select all chunks'
|
||||
className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[#701FFC]/20 data-[state=checked]:border-[#701FFC] data-[state=checked]:bg-[#701FFC] [&>*]:h-3 [&>*]:w-3'
|
||||
className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[var(--brand-primary-hex)]/20 data-[state=checked]:border-[var(--brand-primary-hex)] data-[state=checked]:bg-[var(--brand-primary-hex)] [&>*]:h-3 [&>*]:w-3'
|
||||
/>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
|
||||
@@ -86,7 +86,7 @@ const getStatusDisplay = (doc: DocumentData) => {
|
||||
</>
|
||||
),
|
||||
className:
|
||||
'inline-flex items-center rounded-md bg-[#701FFC]/10 px-2 py-1 text-xs font-medium text-[#701FFC] dark:bg-[#701FFC]/20 dark:text-[#8B5FFF]',
|
||||
'inline-flex items-center rounded-md bg-purple-100 px-2 py-1 text-xs font-medium text-[var(--brand-primary-hex)] dark:bg-purple-900/30 dark:text-[var(--brand-primary-hex)]',
|
||||
}
|
||||
case 'failed':
|
||||
return {
|
||||
@@ -729,7 +729,7 @@ export function KnowledgeBase({
|
||||
onCheckedChange={handleSelectAll}
|
||||
disabled={!userPermissions.canEdit}
|
||||
aria-label='Select all documents'
|
||||
className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[#701FFC]/20 data-[state=checked]:border-[#701FFC] data-[state=checked]:bg-[#701FFC] [&>*]:h-3 [&>*]:w-3'
|
||||
className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[var(--brand-primary-hex)]/20 data-[state=checked]:border-[var(--brand-primary-hex)] data-[state=checked]:bg-[var(--brand-primary-hex)] [&>*]:h-3 [&>*]:w-3'
|
||||
/>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
@@ -886,7 +886,7 @@ export function KnowledgeBase({
|
||||
disabled={!userPermissions.canEdit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Select ${doc.filename}`}
|
||||
className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[#701FFC]/20 data-[state=checked]:border-[#701FFC] data-[state=checked]:bg-[#701FFC] [&>*]:h-3 [&>*]:w-3'
|
||||
className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[var(--brand-primary-hex)]/20 data-[state=checked]:border-[var(--brand-primary-hex)] data-[state=checked]:bg-[var(--brand-primary-hex)] [&>*]:h-3 [&>*]:w-3'
|
||||
/>
|
||||
</td>
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
|
||||
<Button
|
||||
disabled
|
||||
size='sm'
|
||||
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
|
||||
className='flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
|
||||
>
|
||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
|
||||
<span>Add Documents</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
|
||||
const logger = createLogger('UploadModal')
|
||||
@@ -152,6 +153,19 @@ export function UploadModal({
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string, filename: string) => {
|
||||
const IconComponent = getDocumentIcon(mimeType, filename)
|
||||
return <IconComponent className='h-10 w-8' />
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage =
|
||||
uploadProgress.totalFiles > 0
|
||||
@@ -221,11 +235,11 @@ export function UploadModal({
|
||||
multiple
|
||||
/>
|
||||
<p className='text-sm'>
|
||||
{isDragging ? 'Drop more files here!' : 'Add more files'}
|
||||
{isDragging ? 'Drop more files here!' : 'Drop more files or click to browse'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='max-h-60 space-y-1.5 overflow-auto'>
|
||||
<div className='max-h-60 space-y-2 overflow-auto'>
|
||||
{files.map((file, index) => {
|
||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||
const isCurrentlyUploading = fileStatus?.status === 'uploading'
|
||||
@@ -233,26 +247,31 @@ export function UploadModal({
|
||||
const isFailed = fileStatus?.status === 'failed'
|
||||
|
||||
return (
|
||||
<div key={index} className='space-y-1.5 rounded-md border p-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div key={index} className='rounded-md border p-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{getFileIcon(file.type, file.name)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isCurrentlyUploading && (
|
||||
<Loader2 className='h-4 w-4 animate-spin text-blue-500' />
|
||||
<Loader2 className='h-4 w-4 animate-spin text-[var(--brand-primary-hex)]' />
|
||||
)}
|
||||
{isCompleted && <Check className='h-4 w-4 text-green-500' />}
|
||||
{isFailed && <X className='h-4 w-4 text-red-500' />}
|
||||
{!isCurrentlyUploading && !isCompleted && !isFailed && (
|
||||
<div className='h-4 w-4' />
|
||||
)}
|
||||
<p className='truncate text-sm'>
|
||||
<span className='font-medium'>{file.name}</span>
|
||||
<span className='text-muted-foreground'>
|
||||
{' '}
|
||||
• {(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
</p>
|
||||
<p className='truncate font-medium text-sm'>{file.name}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
{isCurrentlyUploading && (
|
||||
<div className='min-w-0 max-w-32 flex-1'>
|
||||
<Progress value={fileStatus?.progress || 0} className='h-1' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isFailed && fileStatus?.error && (
|
||||
<p className='mt-1 text-red-500 text-xs'>{fileStatus.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -260,17 +279,11 @@ export function UploadModal({
|
||||
size='sm'
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
className='h-8 w-8 p-0'
|
||||
className='h-8 w-8 p-0 text-muted-foreground hover:text-destructive'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
{isCurrentlyUploading && (
|
||||
<Progress value={fileStatus?.progress || 0} className='h-1' />
|
||||
)}
|
||||
{isFailed && fileStatus?.error && (
|
||||
<p className='text-red-500 text-xs'>{fileStatus.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -287,7 +300,11 @@ export function UploadModal({
|
||||
<Button variant='outline' onClick={handleClose} disabled={isUploading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={files.length === 0 || isUploading}>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || isUploading}
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
>
|
||||
{isUploading
|
||||
? uploadProgress.stage === 'uploading'
|
||||
? `Uploading ${uploadProgress.filesCompleted + 1}/${uploadProgress.totalFiles}...`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { AlertCircle, CheckCircle2, X } from 'lucide-react'
|
||||
import { AlertCircle, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
@@ -109,6 +109,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
@@ -119,9 +120,32 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
},
|
||||
mode: 'onChange',
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
// Watch the name field to enable/disable the submit button
|
||||
const nameValue = watch('name')
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Reset states when modal opens
|
||||
setSubmitStatus(null)
|
||||
setFileError(null)
|
||||
setFiles([])
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
// Reset form to default values
|
||||
reset({
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 1,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
})
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
const processFiles = async (fileList: FileList | File[]) => {
|
||||
setFileError(null)
|
||||
|
||||
@@ -292,18 +316,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||
}
|
||||
|
||||
setSubmitStatus({
|
||||
type: 'success',
|
||||
message: 'Your knowledge base has been created successfully!',
|
||||
})
|
||||
reset({
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 1,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
})
|
||||
|
||||
// Clean up file previews
|
||||
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||
setFiles([])
|
||||
@@ -313,10 +325,8 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
onKnowledgeBaseCreated(newKnowledgeBase)
|
||||
}
|
||||
|
||||
// Close modal after a short delay to show success message
|
||||
setTimeout(() => {
|
||||
onOpenChange(false)
|
||||
}, 1500)
|
||||
// Close modal immediately - no need for success message
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating knowledge base:', error)
|
||||
setSubmitStatus({
|
||||
@@ -357,31 +367,13 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
className='scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'
|
||||
>
|
||||
<div className='flex min-h-full flex-col py-4'>
|
||||
{submitStatus && submitStatus.type === 'success' ? (
|
||||
<Alert className='mb-6 border-border border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30'>
|
||||
<div className='flex items-start gap-4 py-1'>
|
||||
<div className='mt-[-1.5px] flex-shrink-0'>
|
||||
<CheckCircle2 className='h-4 w-4 text-green-600 dark:text-green-400' />
|
||||
</div>
|
||||
<div className='mr-4 flex-1 space-y-2'>
|
||||
<AlertTitle className='-mt-0.5 flex items-center justify-between'>
|
||||
<span className='font-medium text-green-600 dark:text-green-400'>
|
||||
Success
|
||||
</span>
|
||||
</AlertTitle>
|
||||
<AlertDescription className='text-green-600 dark:text-green-400'>
|
||||
{submitStatus.message}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
) : submitStatus && submitStatus.type === 'error' ? (
|
||||
{submitStatus && submitStatus.type === 'error' && (
|
||||
<Alert variant='destructive' className='mb-6'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{submitStatus.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Form Fields Section - Fixed at top */}
|
||||
<div className='flex-shrink-0 space-y-4'>
|
||||
@@ -611,8 +603,8 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
className='bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
disabled={isSubmitting || !nameValue?.trim()}
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50 disabled:hover:shadow-none'
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Knowledge Base'}
|
||||
</Button>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function PrimaryButton({
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
className={cn(
|
||||
'flex items-center gap-1 bg-[#701FFC] font-[480] text-white shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-white shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
disabled && 'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function SearchInput({
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className='-translate-y-1/2 absolute top-1/2 right-3'>
|
||||
<div className='h-[18px] w-[18px] animate-spin rounded-full border-2 border-gray-300 border-t-[#701FFC]' />
|
||||
<div className='h-[18px] w-[18px] animate-spin rounded-full border-2 border-gray-300 border-t-[var(--brand-primary-hex)]' />
|
||||
</div>
|
||||
) : (
|
||||
value &&
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function KnowledgeLoading() {
|
||||
<Button
|
||||
disabled
|
||||
size='sm'
|
||||
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
|
||||
className='flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
|
||||
>
|
||||
<Plus className='h-3.5 w-3.5' />
|
||||
<span>Create</span>
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function FolderFilter() {
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[#202020]'
|
||||
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
{loading ? 'Loading folders...' : getSelectedFoldersText()}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
@@ -105,7 +105,7 @@ export default function FolderFilter() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
className='max-h-[300px] w-[200px] overflow-y-auto rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
|
||||
className='max-h-[300px] w-[200px] overflow-y-auto rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
key='all'
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function Level() {
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[#202020]'
|
||||
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
{getDisplayLabel()}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
@@ -37,7 +37,7 @@ export default function Level() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
|
||||
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
key='all'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user