Compare commits

..

13 Commits

Author SHA1 Message Date
waleed
51af1b50ff unwrap kb tags before API call, added more query invalidation for chunks 2026-01-19 16:44:09 -08:00
waleed
1fce9864d7 converted remaining manual kb fetches 2026-01-19 15:58:59 -08:00
waleed
08cefa67c8 improvement(kb): migrate manual fetches in kb module to use reactquery 2026-01-19 15:48:43 -08:00
Siddharth Ganesan
5f45db4343 improvement(copilot): variables, conditions, router (#2887)
* Temp

* Condition and router copilot syntax updates

* Plan respond plan
2026-01-19 15:24:50 -08:00
Waleed
81cbfe7af4 feat(browseruse): upgraded browseruse endpoints to v2 (#2890) 2026-01-19 14:47:19 -08:00
Waleed
739341b08e improvement(router): add resizable textareas for router conditions (#2888) 2026-01-19 13:59:13 -08:00
Waleed
3c43779ba3 feat(search): added operations to search modal in main app, updated retrieval in docs to use RRF (#2889) 2026-01-19 13:57:56 -08:00
Waleed
1861f77283 feat(terminal): add fix in copilot for errors (#2885) 2026-01-19 13:42:34 -08:00
Vikhyath Mondreti
72c2ba7443 fix(linear): team selector in tool input (#2886) 2026-01-19 12:40:45 -08:00
Waleed
037dad6975 fix(undo-redo): preserve subblock values during undo/redo cycles (#2884)
* fix(undo-redo): preserve subblock values during undo/redo cycles

* added tests
2026-01-19 12:19:51 -08:00
Waleed
408597e12b feat(notifs): added block name to error notifications (#2883) 2026-01-19 09:54:19 -08:00
Waleed
932f8fd654 feat(mcp): updated mcp subblocks for mcp tools to match subblocks (#2882)
* feat(mcp): updated mcp subblocks for mcp tools to match subblocks

* updated trigger descriptions
2026-01-19 09:50:03 -08:00
Waleed
b4c2294e67 improvement(emails): update unsub page, standardize unsub process (#2881) 2026-01-18 20:42:04 -08:00
73 changed files with 2649 additions and 2311 deletions

View File

@@ -86,27 +86,112 @@ export async function GET(request: NextRequest) {
)
.limit(candidateLimit)
const seenIds = new Set<string>()
const mergedResults = []
const knownLocales = ['en', 'es', 'fr', 'de', 'ja', 'zh']
for (let i = 0; i < Math.max(vectorResults.length, keywordResults.length); i++) {
if (i < vectorResults.length && !seenIds.has(vectorResults[i].chunkId)) {
mergedResults.push(vectorResults[i])
seenIds.add(vectorResults[i].chunkId)
}
if (i < keywordResults.length && !seenIds.has(keywordResults[i].chunkId)) {
mergedResults.push(keywordResults[i])
seenIds.add(keywordResults[i].chunkId)
const vectorRankMap = new Map<string, number>()
vectorResults.forEach((r, idx) => vectorRankMap.set(r.chunkId, idx + 1))
const keywordRankMap = new Map<string, number>()
keywordResults.forEach((r, idx) => keywordRankMap.set(r.chunkId, idx + 1))
const allChunkIds = new Set([
...vectorResults.map((r) => r.chunkId),
...keywordResults.map((r) => r.chunkId),
])
const k = 60
type ResultWithRRF = (typeof vectorResults)[0] & { rrfScore: number }
const scoredResults: ResultWithRRF[] = []
for (const chunkId of allChunkIds) {
const vectorRank = vectorRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
const keywordRank = keywordRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
const rrfScore = 1 / (k + vectorRank) + 1 / (k + keywordRank)
const result =
vectorResults.find((r) => r.chunkId === chunkId) ||
keywordResults.find((r) => r.chunkId === chunkId)
if (result) {
scoredResults.push({ ...result, rrfScore })
}
}
const filteredResults = mergedResults.slice(0, limit)
const searchResults = filteredResults.map((result) => {
scoredResults.sort((a, b) => b.rrfScore - a.rrfScore)
const localeFilteredResults = scoredResults.filter((result) => {
const firstPart = result.sourceDocument.split('/')[0]
if (knownLocales.includes(firstPart)) {
return firstPart === locale
}
return locale === 'en'
})
const queryLower = query.toLowerCase()
const getTitleBoost = (result: ResultWithRRF): number => {
const fileName = result.sourceDocument
.replace('.mdx', '')
.split('/')
.pop()
?.toLowerCase()
?.replace(/_/g, ' ')
if (fileName === queryLower) return 0.01
if (fileName?.includes(queryLower)) return 0.005
return 0
}
localeFilteredResults.sort((a, b) => {
return b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a))
})
const pageMap = new Map<string, ResultWithRRF>()
for (const result of localeFilteredResults) {
const pageKey = result.sourceDocument
const existing = pageMap.get(pageKey)
if (!existing || result.rrfScore > existing.rrfScore) {
pageMap.set(pageKey, result)
}
}
const deduplicatedResults = Array.from(pageMap.values())
.sort((a, b) => b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a)))
.slice(0, limit)
const searchResults = deduplicatedResults.map((result) => {
const title = result.headerText || result.sourceDocument.replace('.mdx', '')
const pathParts = result.sourceDocument
.replace('.mdx', '')
.split('/')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.filter((part) => part !== 'index' && !knownLocales.includes(part))
.map((part) => {
return part
.replace(/_/g, ' ')
.split(' ')
.map((word) => {
const acronyms = [
'api',
'mcp',
'sdk',
'url',
'http',
'json',
'xml',
'html',
'css',
'ai',
]
if (acronyms.includes(word.toLowerCase())) {
return word.toUpperCase()
}
return word.charAt(0).toUpperCase() + word.slice(1)
})
.join(' ')
})
return {
id: result.chunkId,

View File

@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
{...props}
version='1.0'
xmlns='http://www.w3.org/2000/svg'
width='150pt'
height='150pt'
width='28'
height='28'
viewBox='0 0 150 150'
preserveAspectRatio='xMidYMid meet'
>
<g transform='translate(0,150) scale(0.05,-0.05)' fill='#000000' stroke='none'>
<g transform='translate(0,150) scale(0.05,-0.05)' fill='currentColor' stroke='none'>
<path
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="browser_use"
color="#E0E0E0"
color="#181C1E"
/>
{/* MANUAL-CONTENT-START:intro */}

View File

@@ -52,6 +52,15 @@ Read content from a Google Slides presentation
| --------- | ---- | ----------- |
| `slides` | json | Array of slides with their content |
| `metadata` | json | Presentation metadata including ID, title, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `title` | string | The presentation title |
| ↳ `pageSize` | object | Presentation page size |
| ↳ `width` | json | Page width as a Dimension object |
| ↳ `height` | json | Page height as a Dimension object |
| ↳ `width` | json | Page width as a Dimension object |
| ↳ `height` | json | Page height as a Dimension object |
| ↳ `mimeType` | string | The mime type of the presentation |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_write`
@@ -71,6 +80,10 @@ Write or update content in a Google Slides presentation
| --------- | ---- | ----------- |
| `updatedContent` | boolean | Indicates if presentation content was updated successfully |
| `metadata` | json | Updated presentation metadata including ID, title, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `title` | string | The presentation title |
| ↳ `mimeType` | string | The mime type of the presentation |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_create`
@@ -90,6 +103,10 @@ Create a new Google Slides presentation
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `metadata` | json | Created presentation metadata including ID, title, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `title` | string | The presentation title |
| ↳ `mimeType` | string | The mime type of the presentation |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_replace_all_text`
@@ -111,6 +128,10 @@ Find and replace all occurrences of text throughout a Google Slides presentation
| --------- | ---- | ----------- |
| `occurrencesChanged` | number | Number of text occurrences that were replaced |
| `metadata` | json | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `findText` | string | The text that was searched for |
| ↳ `replaceText` | string | The text that replaced the matches |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_add_slide`
@@ -131,6 +152,10 @@ Add a new slide to a Google Slides presentation with a specified layout
| --------- | ---- | ----------- |
| `slideId` | string | The object ID of the newly created slide |
| `metadata` | json | Operation metadata including presentation ID, layout, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `layout` | string | The layout used for the new slide |
| ↳ `insertionIndex` | number | The zero-based index where the slide was inserted |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_add_image`
@@ -154,6 +179,10 @@ Insert an image into a specific slide in a Google Slides presentation
| --------- | ---- | ----------- |
| `imageId` | string | The object ID of the newly created image |
| `metadata` | json | Operation metadata including presentation ID and image URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `pageObjectId` | string | The page object ID where the image was inserted |
| ↳ `imageUrl` | string | The source image URL |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_get_thumbnail`
@@ -176,6 +205,10 @@ Generate a thumbnail image of a specific slide in a Google Slides presentation
| `width` | number | Width of the thumbnail in pixels |
| `height` | number | Height of the thumbnail in pixels |
| `metadata` | json | Operation metadata including presentation ID and page object ID |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `pageObjectId` | string | The page object ID for the thumbnail |
| ↳ `thumbnailSize` | string | The requested thumbnail size |
| ↳ `mimeType` | string | The thumbnail MIME type |
### `google_slides_get_page`

View File

@@ -1,10 +1,13 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { useBrandConfig } from '@/lib/branding/branding'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import { InviteLayout } from '@/app/invite/components'
interface UnsubscribeData {
success: boolean
@@ -27,7 +30,6 @@ function UnsubscribeContent() {
const [error, setError] = useState<string | null>(null)
const [processing, setProcessing] = useState(false)
const [unsubscribed, setUnsubscribed] = useState(false)
const brand = useBrandConfig()
const email = searchParams.get('email')
const token = searchParams.get('token')
@@ -109,7 +111,7 @@ function UnsubscribeContent() {
} else {
setError(result.error || 'Failed to unsubscribe')
}
} catch (error) {
} catch {
setError('Failed to process unsubscribe request')
} finally {
setProcessing(false)
@@ -118,272 +120,171 @@ function UnsubscribeContent() {
if (loading) {
return (
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Loading
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Validating your unsubscribe link...
</p>
</div>
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
<SupportFooter position='absolute' />
</InviteLayout>
)
}
if (error) {
return (
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
<CardDescription className='text-muted-foreground'>
This unsubscribe link is invalid or has expired
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-red-50 p-4'>
<p className='text-red-800 text-sm'>
<strong>Error:</strong> {error}
</p>
</div>
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Invalid Unsubscribe Link
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{error}
</p>
</div>
<div className='space-y-3'>
<p className='text-muted-foreground text-sm'>This could happen if:</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>The link is missing required parameters</li>
<li>The link has expired or been used already</li>
<li>The link was copied incorrectly</li>
</ul>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => window.history.back()}>Go Back</BrandedButton>
</div>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
'_blank'
)
}
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>
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
Go Back
</Button>
</div>
<div className='mt-4 text-center'>
<p className='text-muted-foreground text-xs'>
Need immediate help? Email us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
className='text-muted-foreground hover:underline'
>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
)
}
if (data?.isTransactional) {
return (
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
<CardDescription className='text-muted-foreground'>
This email contains important information about your account
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-blue-50 p-4'>
<p className='text-blue-800 text-sm'>
<strong>Transactional emails</strong> like password resets, account confirmations,
and security alerts cannot be unsubscribed from as they contain essential
information for your account security and functionality.
</p>
</div>
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Important Account Emails
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Transactional emails like password resets, account confirmations, and security alerts
cannot be unsubscribed from as they contain essential information for your account.
</p>
</div>
<div className='space-y-3'>
<p className='text-foreground text-sm'>
If you no longer wish to receive these emails, you can:
</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>Close your account entirely</li>
<li>Contact our support team for assistance</li>
</ul>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
</div>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
'_blank'
)
}
className='w-full bg-blue-600 text-white hover:bg-blue-700'
>
Contact Support
</Button>
<Button onClick={() => window.close()} variant='outline' className='w-full'>
Close
</Button>
</div>
</CardContent>
</Card>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
)
}
if (unsubscribed) {
return (
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
<CardDescription className='text-muted-foreground'>
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
</CardDescription>
</CardHeader>
<CardContent className='text-center'>
<p className='text-muted-foreground text-sm'>
If you change your mind, you can always update your email preferences in your account
settings or contact us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
className='text-muted-foreground hover:underline'
>
{brand.supportEmail}
</a>
</p>
</CardContent>
</Card>
</div>
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Successfully Unsubscribed
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
)
}
const isAlreadyUnsubscribedFromAll = data?.currentPreferences.unsubscribeAll
return (
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>We&apos;re sorry to see you go!</CardTitle>
<CardDescription className='text-muted-foreground'>
We understand email preferences are personal. Choose which emails you&apos;d like to
stop receiving from Sim.
</CardDescription>
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
<p className='text-muted-foreground text-xs'>
Email: <span className='font-medium text-foreground'>{data?.email}</span>
</p>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-3'>
<Button
onClick={() => handleUnsubscribe('all')}
disabled={processing || data?.currentPreferences.unsubscribeAll}
variant='destructive'
className='w-full'
>
{data?.currentPreferences.unsubscribeAll ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{processing
? 'Unsubscribing...'
: data?.currentPreferences.unsubscribeAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</Button>
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Email Preferences
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Choose which emails you'd like to stop receiving.
</p>
<p className={`${inter.className} mt-2 font-[380] text-[14px] text-muted-foreground`}>
{data?.email}
</p>
</div>
<div className='text-center text-muted-foreground text-sm'>
or choose specific types:
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton
onClick={() => handleUnsubscribe('all')}
disabled={processing || isAlreadyUnsubscribedFromAll}
loading={processing}
loadingText='Unsubscribing'
>
{isAlreadyUnsubscribedFromAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</BrandedButton>
<Button
onClick={() => handleUnsubscribe('marketing')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeMarketing
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeMarketing ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</Button>
<div className='py-2 text-center'>
<span className={`${inter.className} font-[380] text-[14px] text-muted-foreground`}>
or choose specific types
</span>
</div>
<Button
onClick={() => handleUnsubscribe('updates')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeUpdates
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeUpdates ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</Button>
<BrandedButton
onClick={() => handleUnsubscribe('marketing')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeMarketing
}
>
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</BrandedButton>
<Button
onClick={() => handleUnsubscribe('notifications')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeNotifications
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeNotifications ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</Button>
</div>
<BrandedButton
onClick={() => handleUnsubscribe('updates')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeUpdates
}
>
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</BrandedButton>
<div className='mt-6 space-y-3'>
<div className='rounded-lg border bg-muted/50 p-3'>
<p className='text-center text-muted-foreground text-xs'>
<strong>Note:</strong> You&apos;ll continue receiving important account emails like
password resets and security alerts.
</p>
</div>
<BrandedButton
onClick={() => handleUnsubscribe('notifications')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeNotifications
}
>
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</BrandedButton>
</div>
<p className='text-center text-muted-foreground text-xs'>
Questions? Contact us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
className='text-muted-foreground hover:underline'
>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
<div className={`${inter.className} mt-6 max-w-[410px] text-center`}>
<p className='font-[380] text-[13px] text-muted-foreground'>
You'll continue receiving important account emails like password resets and security
alerts.
</p>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
)
}
@@ -391,13 +292,20 @@ export default function Unsubscribe() {
return (
<Suspense
fallback={
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Loading
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Validating your unsubscribe link...
</p>
</div>
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
<SupportFooter position='absolute' />
</InviteLayout>
}
>
<UnsubscribeContent />

View File

@@ -2,7 +2,6 @@
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import {
Button,
Label,
@@ -14,7 +13,7 @@ import {
Textarea,
} from '@/components/emcn'
import type { DocumentData } from '@/lib/knowledge/types'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
import { useCreateChunk } from '@/hooks/queries/knowledge'
const logger = createLogger('CreateChunkModal')
@@ -31,16 +30,15 @@ export function CreateChunkModal({
document,
knowledgeBaseId,
}: CreateChunkModalProps) {
const queryClient = useQueryClient()
const { mutate: createChunk, isPending: isCreating, error: mutationError } = useCreateChunk()
const [content, setContent] = useState('')
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const isProcessingRef = useRef(false)
const error = mutationError?.message ?? null
const hasUnsavedChanges = content.trim().length > 0
const handleCreateChunk = async () => {
const handleCreateChunk = () => {
if (!document || content.trim().length === 0 || isProcessingRef.current) {
if (isProcessingRef.current) {
logger.warn('Chunk creation already in progress, ignoring duplicate request')
@@ -48,56 +46,30 @@ export function CreateChunkModal({
return
}
try {
isProcessingRef.current = true
setIsCreating(true)
setError(null)
isProcessingRef.current = true
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content.trim(),
enabled: true,
}),
}
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create chunk')
createChunk(
{
knowledgeBaseId,
documentId: document.id,
content: content.trim(),
enabled: true,
},
{
onSuccess: () => {
isProcessingRef.current = false
onClose()
},
onError: () => {
isProcessingRef.current = false
},
}
const result = await response.json()
if (result.success && result.data) {
logger.info('Chunk created successfully:', result.data.id)
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
onClose()
} else {
throw new Error(result.error || 'Failed to create chunk')
}
} catch (err) {
logger.error('Error creating chunk:', err)
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
isProcessingRef.current = false
setIsCreating(false)
}
)
}
const onClose = () => {
onOpenChange(false)
setContent('')
setError(null)
setShowUnsavedChangesAlert(false)
}

View File

@@ -1,13 +1,8 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import type { ChunkData } from '@/lib/knowledge/types'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
const logger = createLogger('DeleteChunkModal')
import { useDeleteChunk } from '@/hooks/queries/knowledge'
interface DeleteChunkModalProps {
chunk: ChunkData | null
@@ -24,44 +19,12 @@ export function DeleteChunkModal({
isOpen,
onClose,
}: DeleteChunkModalProps) {
const queryClient = useQueryClient()
const [isDeleting, setIsDeleting] = useState(false)
const { mutate: deleteChunk, isPending: isDeleting } = useDeleteChunk()
const handleDeleteChunk = async () => {
const handleDeleteChunk = () => {
if (!chunk || isDeleting) return
try {
setIsDeleting(true)
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunk.id}`,
{
method: 'DELETE',
}
)
if (!response.ok) {
throw new Error('Failed to delete chunk')
}
const result = await response.json()
if (result.success) {
logger.info('Chunk deleted successfully:', chunk.id)
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
onClose()
} else {
throw new Error(result.error || 'Failed to delete chunk')
}
} catch (err) {
logger.error('Error deleting chunk:', err)
} finally {
setIsDeleting(false)
}
deleteChunk({ knowledgeBaseId, documentId, chunkId: chunk.id }, { onSuccess: onClose })
}
if (!chunk) return null

View File

@@ -25,6 +25,7 @@ import {
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useNextAvailableSlot } from '@/hooks/kb/use-next-available-slot'
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions'
import { useUpdateDocumentTags } from '@/hooks/queries/knowledge'
const logger = createLogger('DocumentTagsModal')
@@ -58,8 +59,6 @@ function formatValueForDisplay(value: string, fieldType: string): string {
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
// For UTC dates, display the UTC date to prevent timezone shifts
// e.g., 2002-05-16T00:00:00.000Z should show as "May 16, 2002" not "May 15, 2002"
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
return new Date(
date.getUTCFullYear(),
@@ -96,6 +95,7 @@ export function DocumentTagsModal({
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
const { mutateAsync: updateDocumentTags } = useUpdateDocumentTags()
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
@@ -118,7 +118,6 @@ export function DocumentTagsModal({
const definition = definitions.find((def) => def.tagSlot === slot)
if (rawValue !== null && rawValue !== undefined && definition) {
// Convert value to string for storage
const stringValue = String(rawValue).trim()
if (stringValue) {
tags.push({
@@ -142,41 +141,34 @@ export function DocumentTagsModal({
async (tagsToSave: DocumentTag[]) => {
if (!documentData) return
try {
const tagData: Record<string, string> = {}
const tagData: Record<string, string> = {}
// Only include tags that have values (omit empty ones)
// Use empty string for slots that should be cleared
ALL_TAG_SLOTS.forEach((slot) => {
const tag = tagsToSave.find((t) => t.slot === slot)
if (tag?.value.trim()) {
tagData[slot] = tag.value.trim()
} else {
// Use empty string to clear a tag (API schema expects string, not null)
tagData[slot] = ''
}
})
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(tagData),
})
if (!response.ok) {
throw new Error('Failed to update document tags')
ALL_TAG_SLOTS.forEach((slot) => {
const tag = tagsToSave.find((t) => t.slot === slot)
if (tag?.value.trim()) {
tagData[slot] = tag.value.trim()
} else {
tagData[slot] = ''
}
})
onDocumentUpdate?.(tagData as Record<string, string>)
await fetchTagDefinitions()
} catch (error) {
logger.error('Error updating document tags:', error)
throw error
}
await updateDocumentTags({
knowledgeBaseId,
documentId,
tags: tagData,
})
onDocumentUpdate?.(tagData)
await fetchTagDefinitions()
},
[documentData, knowledgeBaseId, documentId, fetchTagDefinitions, onDocumentUpdate]
[
documentData,
knowledgeBaseId,
documentId,
updateDocumentTags,
fetchTagDefinitions,
onDocumentUpdate,
]
)
const handleRemoveTag = async (index: number) => {

View File

@@ -2,7 +2,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { ChevronDown, ChevronUp } from 'lucide-react'
import {
Button,
@@ -19,7 +18,7 @@ import {
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
import { useUpdateChunk } from '@/hooks/queries/knowledge'
const logger = createLogger('EditChunkModal')
@@ -50,17 +49,17 @@ export function EditChunkModal({
onNavigateToPage,
maxChunkSize,
}: EditChunkModalProps) {
const queryClient = useQueryClient()
const userPermissions = useUserPermissionsContext()
const { mutate: updateChunk, isPending: isSaving, error: mutationError } = useUpdateChunk()
const [editedContent, setEditedContent] = useState(chunk?.content || '')
const [isSaving, setIsSaving] = useState(false)
const [isNavigating, setIsNavigating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
const [tokenizerOn, setTokenizerOn] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const error = mutationError?.message ?? null
const hasUnsavedChanges = editedContent !== (chunk?.content || '')
const tokenStrings = useMemo(() => {
@@ -102,44 +101,15 @@ export function EditChunkModal({
const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1
const canNavigateNext = currentChunkIndex < allChunks.length - 1 || currentPage < totalPages
const handleSaveContent = async () => {
const handleSaveContent = () => {
if (!chunk || !document) return
try {
setIsSaving(true)
setError(null)
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks/${chunk.id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: editedContent,
}),
}
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update chunk')
}
const result = await response.json()
if (result.success) {
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
}
} catch (err) {
logger.error('Error updating chunk:', err)
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setIsSaving(false)
}
updateChunk({
knowledgeBaseId,
documentId: document.id,
chunkId: chunk.id,
content: editedContent,
})
}
const navigateToChunk = async (direction: 'prev' | 'next') => {
@@ -165,7 +135,6 @@ export function EditChunkModal({
}
} catch (err) {
logger.error(`Error navigating ${direction}:`, err)
setError(`Failed to navigate to ${direction === 'prev' ? 'previous' : 'next'} chunk`)
} finally {
setIsNavigating(false)
}

View File

@@ -48,7 +48,13 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
import { knowledgeKeys, useDocumentChunkSearchQuery } from '@/hooks/queries/knowledge'
import {
knowledgeKeys,
useBulkChunkOperation,
useDeleteDocument,
useDocumentChunkSearchQuery,
useUpdateChunk,
} from '@/hooks/queries/knowledge'
const logger = createLogger('Document')
@@ -403,11 +409,13 @@ export function Document({
const [isCreateChunkModalOpen, setIsCreateChunkModalOpen] = useState(false)
const [chunkToDelete, setChunkToDelete] = useState<ChunkData | null>(null)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isBulkOperating, setIsBulkOperating] = useState(false)
const [showDeleteDocumentDialog, setShowDeleteDocumentDialog] = useState(false)
const [isDeletingDocument, setIsDeletingDocument] = useState(false)
const [contextMenuChunk, setContextMenuChunk] = useState<ChunkData | null>(null)
const { mutate: updateChunkMutation } = useUpdateChunk()
const { mutate: deleteDocumentMutation, isPending: isDeletingDocument } = useDeleteDocument()
const { mutate: bulkChunkMutation, isPending: isBulkOperating } = useBulkChunkOperation()
const {
isOpen: isContextMenuOpen,
position: contextMenuPosition,
@@ -440,36 +448,23 @@ export function Document({
setSelectedChunk(null)
}
const handleToggleEnabled = async (chunkId: string) => {
const handleToggleEnabled = (chunkId: string) => {
const chunk = displayChunks.find((c) => c.id === chunkId)
if (!chunk) return
try {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: !chunk.enabled,
}),
}
)
if (!response.ok) {
throw new Error('Failed to update chunk')
updateChunkMutation(
{
knowledgeBaseId,
documentId,
chunkId,
enabled: !chunk.enabled,
},
{
onSuccess: () => {
updateChunk(chunkId, { enabled: !chunk.enabled })
},
}
const result = await response.json()
if (result.success) {
updateChunk(chunkId, { enabled: !chunk.enabled })
}
} catch (err) {
logger.error('Error updating chunk:', err)
}
)
}
const handleDeleteChunk = (chunkId: string) => {
@@ -515,107 +510,69 @@ export function Document({
/**
* Handles deleting the document
*/
const handleDeleteDocument = async () => {
const handleDeleteDocument = () => {
if (!documentData) return
try {
setIsDeletingDocument(true)
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete document')
deleteDocumentMutation(
{ knowledgeBaseId, documentId },
{
onSuccess: () => {
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
},
}
const result = await response.json()
if (result.success) {
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
} else {
throw new Error(result.error || 'Failed to delete document')
}
} catch (err) {
logger.error('Error deleting document:', err)
setIsDeletingDocument(false)
}
)
}
const performBulkChunkOperation = async (
const performBulkChunkOperation = (
operation: 'enable' | 'disable' | 'delete',
chunks: ChunkData[]
) => {
if (chunks.length === 0) return
try {
setIsBulkOperating(true)
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
operation,
chunkIds: chunks.map((chunk) => chunk.id),
}),
}
)
if (!response.ok) {
throw new Error(`Failed to ${operation} chunks`)
bulkChunkMutation(
{
knowledgeBaseId,
documentId,
operation,
chunkIds: chunks.map((chunk) => chunk.id),
},
{
onSuccess: (result) => {
if (operation === 'delete') {
refreshChunks()
} else {
result.results.forEach((opResult) => {
if (opResult.operation === operation) {
opResult.chunkIds.forEach((chunkId: string) => {
updateChunk(chunkId, { enabled: operation === 'enable' })
})
}
})
}
logger.info(`Successfully ${operation}d ${result.successCount} chunks`)
setSelectedChunks(new Set())
},
}
const result = await response.json()
if (result.success) {
if (operation === 'delete') {
await refreshChunks()
} else {
result.data.results.forEach((opResult: any) => {
if (opResult.operation === operation) {
opResult.chunkIds.forEach((chunkId: string) => {
updateChunk(chunkId, { enabled: operation === 'enable' })
})
}
})
}
logger.info(`Successfully ${operation}d ${result.data.successCount} chunks`)
}
setSelectedChunks(new Set())
} catch (err) {
logger.error(`Error ${operation}ing chunks:`, err)
} finally {
setIsBulkOperating(false)
}
)
}
const handleBulkEnable = async () => {
const handleBulkEnable = () => {
const chunksToEnable = displayChunks.filter(
(chunk) => selectedChunks.has(chunk.id) && !chunk.enabled
)
await performBulkChunkOperation('enable', chunksToEnable)
performBulkChunkOperation('enable', chunksToEnable)
}
const handleBulkDisable = async () => {
const handleBulkDisable = () => {
const chunksToDisable = displayChunks.filter(
(chunk) => selectedChunks.has(chunk.id) && chunk.enabled
)
await performBulkChunkOperation('disable', chunksToDisable)
performBulkChunkOperation('disable', chunksToDisable)
}
const handleBulkDelete = async () => {
const handleBulkDelete = () => {
const chunksToDelete = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))
await performBulkChunkOperation('delete', chunksToDelete)
performBulkChunkOperation('delete', chunksToDelete)
}
const selectedChunksList = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))

View File

@@ -2,7 +2,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import {
AlertCircle,
@@ -62,7 +61,12 @@ import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
import {
useBulkDocumentOperation,
useDeleteDocument,
useDeleteKnowledgeBase,
useUpdateDocument,
} from '@/hooks/queries/knowledge'
const logger = createLogger('KnowledgeBase')
@@ -407,12 +411,17 @@ export function KnowledgeBase({
id,
knowledgeBaseName: passedKnowledgeBaseName,
}: KnowledgeBaseProps) {
const queryClient = useQueryClient()
const params = useParams()
const workspaceId = params.workspaceId as string
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
const userPermissions = useUserPermissionsContext()
const { mutate: updateDocumentMutation } = useUpdateDocument()
const { mutate: deleteDocumentMutation } = useDeleteDocument()
const { mutate: deleteKnowledgeBaseMutation, isPending: isDeleting } =
useDeleteKnowledgeBase(workspaceId)
const { mutate: bulkDocumentMutation, isPending: isBulkOperating } = useBulkDocumentOperation()
const [searchQuery, setSearchQuery] = useState('')
const [showTagsModal, setShowTagsModal] = useState(false)
@@ -427,8 +436,6 @@ export function KnowledgeBase({
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isBulkOperating, setIsBulkOperating] = useState(false)
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
const [documentToDelete, setDocumentToDelete] = useState<string | null>(null)
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
@@ -550,7 +557,7 @@ export function KnowledgeBase({
/**
* Checks for documents with stale processing states and marks them as failed
*/
const checkForDeadProcesses = async () => {
const checkForDeadProcesses = () => {
const now = new Date()
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
@@ -567,116 +574,79 @@ export function KnowledgeBase({
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
const markFailedPromises = staleDocuments.map(async (doc) => {
try {
const response = await fetch(`/api/knowledge/${id}/documents/${doc.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
staleDocuments.forEach((doc) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: doc.id,
updates: { markFailedDueToTimeout: true },
},
{
onSuccess: () => {
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
},
body: JSON.stringify({
markFailedDueToTimeout: true,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
logger.error(`Failed to mark document ${doc.id} as failed: ${errorData.error}`)
return
}
const result = await response.json()
if (result.success) {
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
}
} catch (error) {
logger.error(`Error marking document ${doc.id} as failed:`, error)
}
)
})
await Promise.allSettled(markFailedPromises)
}
const handleToggleEnabled = async (docId: string) => {
const handleToggleEnabled = (docId: string) => {
const document = documents.find((doc) => doc.id === docId)
if (!document) return
const newEnabled = !document.enabled
// Optimistic update
updateDocument(docId, { enabled: newEnabled })
try {
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: docId,
updates: { enabled: newEnabled },
},
{
onError: () => {
// Rollback on error
updateDocument(docId, { enabled: !newEnabled })
},
body: JSON.stringify({
enabled: newEnabled,
}),
})
if (!response.ok) {
throw new Error('Failed to update document')
}
const result = await response.json()
if (!result.success) {
updateDocument(docId, { enabled: !newEnabled })
}
} catch (err) {
updateDocument(docId, { enabled: !newEnabled })
logger.error('Error updating document:', err)
}
)
}
/**
* Handles retrying a failed document processing
*/
const handleRetryDocument = async (docId: string) => {
try {
updateDocument(docId, {
processingStatus: 'pending',
processingError: null,
processingStartedAt: null,
processingCompletedAt: null,
})
const handleRetryDocument = (docId: string) => {
// Optimistic update
updateDocument(docId, {
processingStatus: 'pending',
processingError: null,
processingStartedAt: null,
processingCompletedAt: null,
})
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: docId,
updates: { retryProcessing: true },
},
{
onSuccess: () => {
refreshDocuments()
logger.info(`Document retry initiated successfully for: ${docId}`)
},
onError: (err) => {
logger.error('Error retrying document:', err)
updateDocument(docId, {
processingStatus: 'failed',
processingError:
err instanceof Error ? err.message : 'Failed to retry document processing',
})
},
body: JSON.stringify({
retryProcessing: true,
}),
})
if (!response.ok) {
throw new Error('Failed to retry document processing')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to retry document processing')
}
await refreshDocuments()
logger.info(`Document retry initiated successfully for: ${docId}`)
} catch (err) {
logger.error('Error retrying document:', err)
const currentDoc = documents.find((doc) => doc.id === docId)
if (currentDoc) {
updateDocument(docId, {
processingStatus: 'failed',
processingError:
err instanceof Error ? err.message : 'Failed to retry document processing',
})
}
}
)
}
/**
@@ -694,43 +664,32 @@ export function KnowledgeBase({
const currentDoc = documents.find((doc) => doc.id === documentId)
const previousName = currentDoc?.filename
// Optimistic update
updateDocument(documentId, { filename: newName })
queryClient.setQueryData<DocumentData>(knowledgeKeys.document(id, documentId), (previous) =>
previous ? { ...previous, filename: newName } : previous
)
try {
const response = await fetch(`/api/knowledge/${id}/documents/${documentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
return new Promise<void>((resolve, reject) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId,
updates: { filename: newName },
},
body: JSON.stringify({ filename: newName }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to rename document')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to rename document')
}
logger.info(`Document renamed: ${documentId}`)
} catch (err) {
if (previousName !== undefined) {
updateDocument(documentId, { filename: previousName })
queryClient.setQueryData<DocumentData>(
knowledgeKeys.document(id, documentId),
(previous) => (previous ? { ...previous, filename: previousName } : previous)
)
}
logger.error('Error renaming document:', err)
throw err
}
{
onSuccess: () => {
logger.info(`Document renamed: ${documentId}`)
resolve()
},
onError: (err) => {
// Rollback on error
if (previousName !== undefined) {
updateDocument(documentId, { filename: previousName })
}
logger.error('Error renaming document:', err)
reject(err)
},
}
)
})
}
/**
@@ -744,35 +703,26 @@ export function KnowledgeBase({
/**
* Confirms and executes the deletion of a single document
*/
const confirmDeleteDocument = async () => {
const confirmDeleteDocument = () => {
if (!documentToDelete) return
try {
const response = await fetch(`/api/knowledge/${id}/documents/${documentToDelete}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete document')
deleteDocumentMutation(
{ knowledgeBaseId: id, documentId: documentToDelete },
{
onSuccess: () => {
refreshDocuments()
setSelectedDocuments((prev) => {
const newSet = new Set(prev)
newSet.delete(documentToDelete)
return newSet
})
},
onSettled: () => {
setShowDeleteDocumentModal(false)
setDocumentToDelete(null)
},
}
const result = await response.json()
if (result.success) {
refreshDocuments()
setSelectedDocuments((prev) => {
const newSet = new Set(prev)
newSet.delete(documentToDelete)
return newSet
})
}
} catch (err) {
logger.error('Error deleting document:', err)
} finally {
setShowDeleteDocumentModal(false)
setDocumentToDelete(null)
}
)
}
/**
@@ -818,32 +768,18 @@ export function KnowledgeBase({
/**
* Handles deleting the entire knowledge base
*/
const handleDeleteKnowledgeBase = async () => {
const handleDeleteKnowledgeBase = () => {
if (!knowledgeBase) return
try {
setIsDeleting(true)
const response = await fetch(`/api/knowledge/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete knowledge base')
deleteKnowledgeBaseMutation(
{ knowledgeBaseId: id },
{
onSuccess: () => {
removeKnowledgeBase(id)
router.push(`/workspace/${workspaceId}/knowledge`)
},
}
const result = await response.json()
if (result.success) {
removeKnowledgeBase(id)
router.push(`/workspace/${workspaceId}/knowledge`)
} else {
throw new Error(result.error || 'Failed to delete knowledge base')
}
} catch (err) {
logger.error('Error deleting knowledge base:', err)
setIsDeleting(false)
}
)
}
/**
@@ -856,93 +792,57 @@ export function KnowledgeBase({
/**
* Handles bulk enabling of selected documents
*/
const handleBulkEnable = async () => {
const handleBulkEnable = () => {
const documentsToEnable = documents.filter(
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
)
if (documentsToEnable.length === 0) return
try {
setIsBulkOperating(true)
const response = await fetch(`/api/knowledge/${id}/documents`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'enable',
documentIds: documentsToEnable.map((doc) => doc.id),
},
{
onSuccess: (result) => {
result.updatedDocuments?.forEach((updatedDoc) => {
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
})
logger.info(`Successfully enabled ${result.successCount} documents`)
setSelectedDocuments(new Set())
},
body: JSON.stringify({
operation: 'enable',
documentIds: documentsToEnable.map((doc) => doc.id),
}),
})
if (!response.ok) {
throw new Error('Failed to enable documents')
}
const result = await response.json()
if (result.success) {
result.data.updatedDocuments.forEach((updatedDoc: { id: string; enabled: boolean }) => {
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
})
logger.info(`Successfully enabled ${result.data.successCount} documents`)
}
setSelectedDocuments(new Set())
} catch (err) {
logger.error('Error enabling documents:', err)
} finally {
setIsBulkOperating(false)
}
)
}
/**
* Handles bulk disabling of selected documents
*/
const handleBulkDisable = async () => {
const handleBulkDisable = () => {
const documentsToDisable = documents.filter(
(doc) => selectedDocuments.has(doc.id) && doc.enabled
)
if (documentsToDisable.length === 0) return
try {
setIsBulkOperating(true)
const response = await fetch(`/api/knowledge/${id}/documents`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'disable',
documentIds: documentsToDisable.map((doc) => doc.id),
},
{
onSuccess: (result) => {
result.updatedDocuments?.forEach((updatedDoc) => {
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
})
logger.info(`Successfully disabled ${result.successCount} documents`)
setSelectedDocuments(new Set())
},
body: JSON.stringify({
operation: 'disable',
documentIds: documentsToDisable.map((doc) => doc.id),
}),
})
if (!response.ok) {
throw new Error('Failed to disable documents')
}
const result = await response.json()
if (result.success) {
result.data.updatedDocuments.forEach((updatedDoc: { id: string; enabled: boolean }) => {
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
})
logger.info(`Successfully disabled ${result.data.successCount} documents`)
}
setSelectedDocuments(new Set())
} catch (err) {
logger.error('Error disabling documents:', err)
} finally {
setIsBulkOperating(false)
}
)
}
/**
@@ -956,44 +856,28 @@ export function KnowledgeBase({
/**
* Confirms and executes the bulk deletion of selected documents
*/
const confirmBulkDelete = async () => {
const confirmBulkDelete = () => {
const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id))
if (documentsToDelete.length === 0) return
try {
setIsBulkOperating(true)
const response = await fetch(`/api/knowledge/${id}/documents`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'delete',
documentIds: documentsToDelete.map((doc) => doc.id),
},
{
onSuccess: (result) => {
logger.info(`Successfully deleted ${result.successCount} documents`)
refreshDocuments()
setSelectedDocuments(new Set())
},
onSettled: () => {
setShowBulkDeleteModal(false)
},
body: JSON.stringify({
operation: 'delete',
documentIds: documentsToDelete.map((doc) => doc.id),
}),
})
if (!response.ok) {
throw new Error('Failed to delete documents')
}
const result = await response.json()
if (result.success) {
logger.info(`Successfully deleted ${result.data.successCount} documents`)
}
await refreshDocuments()
setSelectedDocuments(new Set())
} catch (err) {
logger.error('Error deleting documents:', err)
} finally {
setIsBulkOperating(false)
setShowBulkDeleteModal(false)
}
)
}
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))

View File

@@ -22,10 +22,10 @@ import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
const logger = createLogger('BaseTagsModal')
/** Field type display labels */
const FIELD_TYPE_LABELS: Record<string, string> = {
text: 'Text',
number: 'Number',
@@ -45,7 +45,6 @@ interface DocumentListProps {
totalCount: number
}
/** Displays a list of documents affected by tag operations */
function DocumentList({ documents, totalCount }: DocumentListProps) {
const displayLimit = 5
const hasMore = totalCount > displayLimit
@@ -95,13 +94,14 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const createTagMutation = useCreateTagDefinition()
const deleteTagMutation = useDeleteTagDefinition()
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
const [isDeletingTag, setIsDeletingTag] = useState(false)
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
const [isCreatingTag, setIsCreatingTag] = useState(false)
const [isSavingTag, setIsSavingTag] = useState(false)
const [createTagForm, setCreateTagForm] = useState({
displayName: '',
fieldType: 'text',
@@ -177,13 +177,12 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
}
const tagNameConflict =
isCreatingTag && !isSavingTag && hasTagNameConflict(createTagForm.displayName)
isCreatingTag && !createTagMutation.isPending && hasTagNameConflict(createTagForm.displayName)
const canSaveTag = () => {
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
}
/** Get slot usage counts per field type */
const getSlotUsageByFieldType = (fieldType: string): { used: number; max: number } => {
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
if (!config) return { used: 0, max: 0 }
@@ -191,13 +190,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
return { used, max: config.maxSlots }
}
/** Check if a field type has available slots */
const hasAvailableSlots = (fieldType: string): boolean => {
const { used, max } = getSlotUsageByFieldType(fieldType)
return used < max
}
/** Field type options for Combobox */
const fieldTypeOptions: ComboboxOption[] = useMemo(() => {
return SUPPORTED_FIELD_TYPES.filter((type) => hasAvailableSlots(type)).map((type) => {
const { used, max } = getSlotUsageByFieldType(type)
@@ -211,43 +208,17 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
const saveTagDefinition = async () => {
if (!canSaveTag()) return
setIsSavingTag(true)
try {
// Check if selected field type has available slots
if (!hasAvailableSlots(createTagForm.fieldType)) {
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
}
// Get the next available slot from the API
const slotResponse = await fetch(
`/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${createTagForm.fieldType}`
)
if (!slotResponse.ok) {
throw new Error('Failed to get available slot')
}
const slotResult = await slotResponse.json()
if (!slotResult.success || !slotResult.data?.nextAvailableSlot) {
throw new Error('No available tag slots for this field type')
}
const newTagDefinition = {
tagSlot: slotResult.data.nextAvailableSlot,
await createTagMutation.mutateAsync({
knowledgeBaseId,
displayName: createTagForm.displayName.trim(),
fieldType: createTagForm.fieldType,
}
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newTagDefinition),
})
if (!response.ok) {
throw new Error('Failed to create tag definition')
}
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
setCreateTagForm({
@@ -257,27 +228,17 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
setIsCreatingTag(false)
} catch (error) {
logger.error('Error creating tag definition:', error)
} finally {
setIsSavingTag(false)
}
}
const confirmDeleteTag = async () => {
if (!selectedTag) return
setIsDeletingTag(true)
try {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${selectedTag.id}`,
{
method: 'DELETE',
}
)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to delete tag definition: ${response.status} ${errorText}`)
}
await deleteTagMutation.mutateAsync({
knowledgeBaseId,
tagDefinitionId: selectedTag.id,
})
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
@@ -285,8 +246,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
setSelectedTag(null)
} catch (error) {
logger.error('Error deleting tag definition:', error)
} finally {
setIsDeletingTag(false)
}
}
@@ -433,11 +392,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
className='flex-1'
disabled={
!canSaveTag() ||
isSavingTag ||
createTagMutation.isPending ||
!hasAvailableSlots(createTagForm.fieldType)
}
>
{isSavingTag ? 'Creating...' : 'Create Tag'}
{createTagMutation.isPending ? 'Creating...' : 'Create Tag'}
</Button>
</div>
</div>
@@ -481,13 +440,17 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<ModalFooter>
<Button
variant='default'
disabled={isDeletingTag}
disabled={deleteTagMutation.isPending}
onClick={() => setDeleteTagDialogOpen(false)}
>
Cancel
</Button>
<Button variant='destructive' onClick={confirmDeleteTag} disabled={isDeletingTag}>
{isDeletingTag ? <>Deleting...</> : 'Delete Tag'}
<Button
variant='destructive'
onClick={confirmDeleteTag}
disabled={deleteTagMutation.isPending}
>
{deleteTagMutation.isPending ? 'Deleting...' : 'Delete Tag'}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -3,7 +3,6 @@
import { useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, RotateCcw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
@@ -23,7 +22,7 @@ import { cn } from '@/lib/core/utils/cn'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
import { useCreateKnowledgeBase, useDeleteKnowledgeBase } from '@/hooks/queries/knowledge'
const logger = createLogger('CreateBaseModal')
@@ -82,10 +81,11 @@ interface SubmitStatus {
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const queryClient = useQueryClient()
const createKnowledgeBaseMutation = useCreateKnowledgeBase(workspaceId)
const deleteKnowledgeBaseMutation = useDeleteKnowledgeBase(workspaceId)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
const [files, setFiles] = useState<FileWithPreview[]>([])
const [fileError, setFileError] = useState<string | null>(null)
@@ -245,12 +245,14 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
})
}
const isSubmitting =
createKnowledgeBaseMutation.isPending || deleteKnowledgeBaseMutation.isPending || isUploading
const onSubmit = async (data: FormValues) => {
setIsSubmitting(true)
setSubmitStatus(null)
try {
const knowledgeBasePayload = {
const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({
name: data.name,
description: data.description || undefined,
workspaceId: workspaceId,
@@ -259,29 +261,8 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
minSize: data.minChunkSize,
overlap: data.overlapSize,
},
}
const response = await fetch('/api/knowledge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(knowledgeBasePayload),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create knowledge base')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to create knowledge base')
}
const newKnowledgeBase = result.data
if (files.length > 0) {
try {
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
@@ -293,15 +274,11 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
} catch (uploadError) {
logger.error('File upload failed, deleting knowledge base:', uploadError)
try {
await fetch(`/api/knowledge/${newKnowledgeBase.id}`, {
method: 'DELETE',
await deleteKnowledgeBaseMutation.mutateAsync({
knowledgeBaseId: newKnowledgeBase.id,
})
logger.info(`Deleted orphaned knowledge base: ${newKnowledgeBase.id}`)
} catch (deleteError) {
@@ -309,10 +286,6 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
}
throw uploadError
}
} else {
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
}
files.forEach((file) => URL.revokeObjectURL(file.preview))
@@ -325,8 +298,6 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
type: 'error',
message: error instanceof Error ? error.message : 'An unknown error occurred',
})
} finally {
setIsSubmitting(false)
}
}

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import {
@@ -15,7 +14,7 @@ import {
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
import { useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
const logger = createLogger('KnowledgeHeader')
@@ -54,14 +53,13 @@ interface Workspace {
}
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
const queryClient = useQueryClient()
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
const [isUpdatingWorkspace, setIsUpdatingWorkspace] = useState(false)
// Fetch available workspaces
const updateKnowledgeBase = useUpdateKnowledgeBase()
useEffect(() => {
if (!options?.knowledgeBaseId) return
@@ -76,7 +74,6 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
const data = await response.json()
// Filter workspaces where user has write/admin permissions
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
@@ -97,47 +94,27 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
}, [options?.knowledgeBaseId])
const handleWorkspaceChange = async (workspaceId: string | null) => {
if (isUpdatingWorkspace || !options?.knowledgeBaseId) return
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return
try {
setIsUpdatingWorkspace(true)
setIsWorkspacePopoverOpen(false)
setIsWorkspacePopoverOpen(false)
const response = await fetch(`/api/knowledge/${options.knowledgeBaseId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
updateKnowledgeBase.mutate(
{
knowledgeBaseId: options.knowledgeBaseId,
updates: { workspaceId },
},
{
onSuccess: () => {
logger.info(
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
)
options.onWorkspaceChange?.(workspaceId)
},
onError: (err) => {
logger.error('Error updating workspace:', err)
},
body: JSON.stringify({
workspaceId,
}),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update workspace')
}
const result = await response.json()
if (result.success) {
logger.info(
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
)
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(options.knowledgeBaseId),
})
await options.onWorkspaceChange?.(workspaceId)
} else {
throw new Error(result.error || 'Failed to update workspace')
}
} catch (err) {
logger.error('Error updating workspace:', err)
} finally {
setIsUpdatingWorkspace(false)
}
)
}
const currentWorkspace = workspaces.find((ws) => ws.id === options?.currentWorkspaceId)
@@ -147,7 +124,6 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
<div className={HEADER_STYLES.container}>
<div className={HEADER_STYLES.breadcrumbs}>
{breadcrumbs.map((breadcrumb, index) => {
// Use unique identifier when available, fallback to content-based key
const key = breadcrumb.id || `${breadcrumb.label}-${breadcrumb.href || index}`
return (
@@ -189,13 +165,13 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={isLoadingWorkspaces || isUpdatingWorkspace}
disabled={isLoadingWorkspaces || updateKnowledgeBase.isPending}
className={filterButtonClass}
>
<span className='truncate'>
{isLoadingWorkspaces
? 'Loading...'
: isUpdatingWorkspace
: updateKnowledgeBase.isPending
? 'Updating...'
: currentWorkspace?.name || 'No workspace'}
</span>

View File

@@ -32,6 +32,7 @@ import {
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
import { useDebounce } from '@/hooks/use-debounce'
const logger = createLogger('Knowledge')
@@ -51,10 +52,12 @@ export function Knowledge() {
const params = useParams()
const workspaceId = params.workspaceId as string
const { knowledgeBases, isLoading, error, removeKnowledgeBase, updateKnowledgeBase } =
useKnowledgeBasesList(workspaceId)
const { knowledgeBases, isLoading, error } = useKnowledgeBasesList(workspaceId)
const userPermissions = useUserPermissionsContext()
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
@@ -112,29 +115,13 @@ export function Knowledge() {
*/
const handleUpdateKnowledgeBase = useCallback(
async (id: string, name: string, description: string) => {
const response = await fetch(`/api/knowledge/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, description }),
await updateKnowledgeBaseMutation({
knowledgeBaseId: id,
updates: { name, description },
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update knowledge base')
}
const result = await response.json()
if (result.success) {
logger.info(`Knowledge base updated: ${id}`)
updateKnowledgeBase(id, { name, description })
} else {
throw new Error(result.error || 'Failed to update knowledge base')
}
logger.info(`Knowledge base updated: ${id}`)
},
[updateKnowledgeBase]
[updateKnowledgeBaseMutation]
)
/**
@@ -142,25 +129,10 @@ export function Knowledge() {
*/
const handleDeleteKnowledgeBase = useCallback(
async (id: string) => {
const response = await fetch(`/api/knowledge/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete knowledge base')
}
const result = await response.json()
if (result.success) {
logger.info(`Knowledge base deleted: ${id}`)
removeKnowledgeBase(id)
} else {
throw new Error(result.error || 'Failed to delete knowledge base')
}
await deleteKnowledgeBaseMutation({ knowledgeBaseId: id })
logger.info(`Knowledge base deleted: ${id}`)
},
[removeKnowledgeBase]
[deleteKnowledgeBaseMutation]
)
/**

View File

@@ -26,9 +26,6 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Parse special tags from content
*/
/**
* Plan step can be either a string or an object with title and plan
*/
@@ -47,6 +44,56 @@ interface ParsedTags {
cleanContent: string
}
/**
* Extract plan steps from plan_respond tool calls in subagent blocks.
* Returns { steps, isComplete } where steps is in the format expected by PlanSteps component.
*/
function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
steps: Record<string, PlanStep> | undefined
isComplete: boolean
} {
if (!blocks) return { steps: undefined, isComplete: false }
// Find the plan_respond tool call
const planRespondBlock = blocks.find(
(b) => b.type === 'subagent_tool_call' && b.toolCall?.name === 'plan_respond'
)
if (!planRespondBlock?.toolCall) {
return { steps: undefined, isComplete: false }
}
// Tool call arguments can be in different places depending on the source
// Also handle nested data.arguments structure from the schema
const tc = planRespondBlock.toolCall as any
const args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {}
const stepsArray = args.steps
if (!Array.isArray(stepsArray) || stepsArray.length === 0) {
return { steps: undefined, isComplete: false }
}
// Convert array format to Record<string, PlanStep> format
// From: [{ number: 1, title: "..." }, { number: 2, title: "..." }]
// To: { "1": "...", "2": "..." }
const steps: Record<string, PlanStep> = {}
for (const step of stepsArray) {
if (step.number !== undefined && step.title) {
steps[String(step.number)] = step.title
}
}
// Check if the tool call is complete (not pending/executing)
const isComplete =
planRespondBlock.toolCall.state === ClientToolCallState.success ||
planRespondBlock.toolCall.state === ClientToolCallState.error
return {
steps: Object.keys(steps).length > 0 ? steps : undefined,
isComplete,
}
}
/**
* Try to parse partial JSON for streaming options.
* Attempts to extract complete key-value pairs from incomplete JSON.
@@ -654,11 +701,20 @@ function SubAgentThinkingContent({
}
}
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(blocks)
const allParsed = parseSpecialTags(allRawText)
if (!cleanText.trim() && !allParsed.plan) return null
// Prefer plan_respond tool data over <plan> tags
const hasPlan =
!!(planSteps && Object.keys(planSteps).length > 0) ||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
const planToRender = planSteps || allParsed.plan
const isPlanStreaming = planSteps ? !planComplete : isStreaming
const hasSpecialTags = !!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
if (!cleanText.trim() && !hasPlan) return null
const hasSpecialTags = hasPlan
return (
<div className='space-y-1.5'>
@@ -670,9 +726,7 @@ function SubAgentThinkingContent({
hasSpecialTags={hasSpecialTags}
/>
)}
{allParsed.plan && Object.keys(allParsed.plan).length > 0 && (
<PlanSteps steps={allParsed.plan} streaming={isStreaming} />
)}
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
</div>
)
}
@@ -744,8 +798,19 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
}
const allParsed = parseSpecialTags(allRawText)
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(
toolCall.subAgentBlocks
)
const hasPlan =
!!(planSteps && Object.keys(planSteps).length > 0) ||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
const planToRender = planSteps || allParsed.plan
const isPlanStreaming = planSteps ? !planComplete : isStreaming
const hasSpecialTags = !!(
(allParsed.plan && Object.keys(allParsed.plan).length > 0) ||
hasPlan ||
(allParsed.options && Object.keys(allParsed.options).length > 0)
)
@@ -757,8 +822,6 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
const outerLabel = getSubagentCompletionLabel(toolCall.name)
const durationText = `${outerLabel} for ${formatDuration(duration)}`
const hasPlan = allParsed.plan && Object.keys(allParsed.plan).length > 0
const renderCollapsibleContent = () => (
<>
{segments.map((segment, index) => {
@@ -800,7 +863,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
return (
<div className='w-full space-y-1.5'>
{renderCollapsibleContent()}
{hasPlan && <PlanSteps steps={allParsed.plan!} streaming={isStreaming} />}
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
</div>
)
}
@@ -832,7 +895,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
</div>
{/* Plan stays outside the collapsible */}
{hasPlan && <PlanSteps steps={allParsed.plan!} />}
{hasPlan && planToRender && <PlanSteps steps={planToRender} />}
</div>
)
})
@@ -1412,7 +1475,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
if (
toolCall.name === 'checkoff_todo' ||
toolCall.name === 'mark_todo_in_progress' ||
toolCall.name === 'tool_search_tool_regex'
toolCall.name === 'tool_search_tool_regex' ||
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_responsd' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond'
)
return null

View File

@@ -1,7 +1,7 @@
import type { ReactElement } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ChevronDown, ChevronUp, Plus } from 'lucide-react'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import Editor from 'react-simple-code-editor'
import { useUpdateNodeInternals } from 'reactflow'
@@ -39,6 +39,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('ConditionInput')
/**
* Default height for router textareas in pixels
*/
const ROUTER_DEFAULT_HEIGHT_PX = 100
/**
* Minimum height for router textareas in pixels
*/
const ROUTER_MIN_HEIGHT_PX = 80
/**
* Represents a single conditional block (if/else if/else).
*/
@@ -743,6 +753,61 @@ export function ConditionInput({
}
}, [conditionalBlocks, isRouterMode])
// State for tracking individual router textarea heights
const [routerHeights, setRouterHeights] = useState<{ [key: string]: number }>({})
const isResizing = useRef(false)
/**
* Gets the height for a specific router block, returning default if not set.
*
* @param blockId - ID of the router block
* @returns Height in pixels
*/
const getRouterHeight = (blockId: string): number => {
return routerHeights[blockId] ?? ROUTER_DEFAULT_HEIGHT_PX
}
/**
* Handles mouse-based resize for router textareas.
*
* @param e - Mouse event from the resize handle
* @param blockId - ID of the block being resized
*/
const startRouterResize = (e: React.MouseEvent, blockId: string) => {
if (isPreview || disabled) return
e.preventDefault()
e.stopPropagation()
isResizing.current = true
const startY = e.clientY
const startHeight = getRouterHeight(blockId)
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizing.current) return
const deltaY = moveEvent.clientY - startY
const newHeight = Math.max(ROUTER_MIN_HEIGHT_PX, startHeight + deltaY)
// Update the textarea height directly for smooth resizing
const textarea = inputRefs.current.get(blockId)
if (textarea) {
textarea.style.height = `${newHeight}px`
}
// Update state to keep track
setRouterHeights((prev) => ({ ...prev, [blockId]: newHeight }))
}
const handleMouseUp = () => {
isResizing.current = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
// Show loading or empty state if not ready or no blocks
if (!isReady || conditionalBlocks.length === 0) {
return (
@@ -907,10 +972,24 @@ export function ConditionInput({
}}
placeholder='Describe when this route should be taken...'
disabled={disabled || isPreview}
className='min-h-[60px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
rows={2}
className='min-h-[100px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
rows={4}
style={{ height: `${getRouterHeight(block.id)}px` }}
/>
{/* Custom resize handle */}
{!isPreview && !disabled && (
<div
className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
onMouseDown={(e) => startRouterResize(e, block.id)}
onDragStart={(e) => {
e.preventDefault()
}}
>
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
</div>
)}
{block.showEnvVars && (
<EnvVarDropdown
visible={block.showEnvVars}

View File

@@ -234,48 +234,45 @@ export function LongInput({
}, [value])
// Handle resize functionality
const startResize = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
isResizing.current = true
const startResize = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
isResizing.current = true
const startY = e.clientY
const startHeight = height
const startY = e.clientY
const startHeight = height
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizing.current) return
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizing.current) return
const deltaY = moveEvent.clientY - startY
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
const deltaY = moveEvent.clientY - startY
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
if (textareaRef.current && overlayRef.current) {
textareaRef.current.style.height = `${newHeight}px`
overlayRef.current.style.height = `${newHeight}px`
}
if (containerRef.current) {
containerRef.current.style.height = `${newHeight}px`
}
// Keep React state in sync so parent layouts (e.g., Editor) update during drag
setHeight(newHeight)
if (textareaRef.current && overlayRef.current) {
textareaRef.current.style.height = `${newHeight}px`
overlayRef.current.style.height = `${newHeight}px`
}
if (containerRef.current) {
containerRef.current.style.height = `${newHeight}px`
}
// Keep React state in sync so parent layouts (e.g., Editor) update during drag
setHeight(newHeight)
}
const handleMouseUp = () => {
if (textareaRef.current) {
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
setHeight(finalHeight)
}
const handleMouseUp = () => {
if (textareaRef.current) {
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
setHeight(finalHeight)
}
isResizing.current = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
isResizing.current = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[height]
)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
// Expose wand control handlers to parent via ref
useImperativeHandle(

View File

@@ -1,281 +1,17 @@
import type { RefObject } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Combobox, Input, Label, Slider, Switch, Textarea } from '@/components/emcn/components'
import { Combobox, Label, Slider, Switch } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { formatParameterLabel } from '@/tools/params'
const logger = createLogger('McpDynamicArgs')
interface McpInputWithTagsProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
isPassword?: boolean
blockId: string
accessiblePrefixes?: Set<string>
}
function McpInputWithTags({
value,
onChange,
placeholder,
disabled,
isPassword,
blockId,
accessiblePrefixes,
}: McpInputWithTagsProps) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const inputNameRef = useRef(`mcp_input_${Math.random()}`)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
onChange(newValue)
setCursorPosition(newCursorPosition)
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const dropPosition = inputRef.current?.selectionStart ?? value.length ?? 0
const currentValue = value ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
setTimeout(() => {
if (inputRef.current) {
inputRef.current.selectionStart = dropPosition + 1
inputRef.current.selectionEnd = dropPosition + 1
}
}, 0)
} catch (error) {
logger.error('Failed to parse drop data:', { error })
}
}
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
}
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
return (
<div className='relative'>
<div className='relative'>
<Input
ref={inputRef}
type={isPassword ? 'password' : 'text'}
value={value || ''}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
placeholder={placeholder}
disabled={disabled}
name={inputNameRef.current}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
data-form-type='other'
data-lpignore='true'
data-1p-ignore
readOnly
onFocus={(e) => {
e.currentTarget.removeAttribute('readOnly')
// Show tag dropdown on focus when input is empty
if (!disabled && (value?.trim() === '' || !value)) {
setShowTags(true)
setCursorPosition(0)
}
}}
className={cn(!isPassword && 'text-transparent caret-foreground')}
/>
{!isPassword && (
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(value?.toString() || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
)}
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
inputRef={inputRef as RefObject<HTMLInputElement>}
/>
</div>
)
}
interface McpTextareaWithTagsProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
blockId: string
accessiblePrefixes?: Set<string>
rows?: number
}
function McpTextareaWithTags({
value,
onChange,
placeholder,
disabled,
blockId,
accessiblePrefixes,
rows = 4,
}: McpTextareaWithTagsProps) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const textareaNameRef = useRef(`mcp_textarea_${Math.random()}`)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
onChange(newValue)
setCursorPosition(newCursorPosition)
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const dropPosition = textareaRef.current?.selectionStart ?? value.length ?? 0
const currentValue = value ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = dropPosition + 1
textareaRef.current.selectionEnd = dropPosition + 1
}
}, 0)
} catch (error) {
logger.error('Failed to parse drop data:', { error })
}
}
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
}
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
return (
<div className='relative'>
<Textarea
ref={textareaRef}
value={value || ''}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
onFocus={() => {
// Show tag dropdown on focus when input is empty
if (!disabled && (value?.trim() === '' || !value)) {
setShowTags(true)
setCursorPosition(0)
}
}}
placeholder={placeholder}
disabled={disabled}
rows={rows}
name={textareaNameRef.current}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
data-form-type='other'
data-lpignore='true'
data-1p-ignore
className={cn('min-h-[80px] resize-none text-transparent caret-foreground')}
/>
<div className='pointer-events-none absolute inset-0 overflow-auto whitespace-pre-wrap break-words px-[8px] py-[8px] font-medium font-sans text-sm'>
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
inputRef={textareaRef as RefObject<HTMLTextAreaElement>}
/>
</div>
)
}
interface McpDynamicArgsProps {
blockId: string
subBlockId: string
@@ -284,6 +20,27 @@ interface McpDynamicArgsProps {
previewValue?: any
}
/**
* Creates a minimal SubBlockConfig for MCP tool parameters
*/
function createParamConfig(
paramName: string,
paramSchema: any,
inputType: 'long-input' | 'short-input'
): SubBlockConfig {
const placeholder =
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description || `Enter ${formatParameterLabel(paramName).toLowerCase()}`
return {
id: paramName,
type: inputType,
title: formatParameterLabel(paramName),
placeholder,
}
}
export function McpDynamicArgs({
blockId,
subBlockId,
@@ -297,7 +54,6 @@ export function McpDynamicArgs({
const [selectedTool] = useSubBlockValue(blockId, 'tool')
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
@@ -308,7 +64,7 @@ export function McpDynamicArgs({
try {
return JSON.parse(previewValue)
} catch (error) {
console.warn('Failed to parse preview value as JSON:', error)
logger.warn('Failed to parse preview value as JSON:', { error })
return previewValue
}
}
@@ -318,7 +74,7 @@ export function McpDynamicArgs({
try {
return JSON.parse(toolArgs)
} catch (error) {
console.warn('Failed to parse toolArgs as JSON:', error)
logger.warn('Failed to parse toolArgs as JSON:', { error })
return {}
}
}
@@ -460,24 +216,23 @@ export function McpDynamicArgs({
)
}
case 'long-input':
case 'long-input': {
const config = createParamConfig(paramName, paramSchema, 'long-input')
return (
<McpTextareaWithTags
<LongInput
key={`${paramName}-long`}
blockId={blockId}
subBlockId={`_mcp_${paramName}`}
config={config}
placeholder={config.placeholder}
rows={4}
value={value || ''}
onChange={(newValue) => updateParameter(paramName, newValue)}
placeholder={
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
isPreview={isPreview}
disabled={disabled}
blockId={blockId}
accessiblePrefixes={accessiblePrefixes}
rows={4}
/>
)
}
default: {
const isPassword =
@@ -485,10 +240,16 @@ export function McpDynamicArgs({
paramName.toLowerCase().includes('password') ||
paramName.toLowerCase().includes('token')
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
const config = createParamConfig(paramName, paramSchema, 'short-input')
return (
<McpInputWithTags
<ShortInput
key={`${paramName}-short`}
blockId={blockId}
subBlockId={`_mcp_${paramName}`}
config={config}
placeholder={config.placeholder}
password={isPassword}
value={value?.toString() || ''}
onChange={(newValue) => {
let processedValue: any = newValue
@@ -506,16 +267,8 @@ export function McpDynamicArgs({
}
updateParameter(paramName, processedValue)
}}
placeholder={
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
isPreview={isPreview}
disabled={disabled}
isPassword={isPassword}
blockId={blockId}
accessiblePrefixes={accessiblePrefixes}
/>
)
}
@@ -578,26 +331,40 @@ export function McpDynamicArgs({
tabIndex={-1}
readOnly
/>
<div className='space-y-4'>
<div>
{toolSchema.properties &&
Object.entries(toolSchema.properties).map(([paramName, paramSchema]) => {
Object.entries(toolSchema.properties).map(([paramName, paramSchema], index, entries) => {
const inputType = getInputType(paramSchema as any)
const showLabel = inputType !== 'switch'
const showDivider = index < entries.length - 1
return (
<div key={paramName} className='space-y-2'>
{showLabel && (
<Label
className={cn(
'font-medium text-sm',
toolSchema.required?.includes(paramName) &&
'after:ml-1 after:text-red-500 after:content-["*"]'
)}
>
{formatParameterLabel(paramName)}
</Label>
<div key={paramName} className='subblock-row'>
<div className='subblock-content flex flex-col gap-[10px]'>
{showLabel && (
<Label
className={cn(
'font-medium text-sm',
toolSchema.required?.includes(paramName) &&
'after:ml-1 after:text-red-500 after:content-["*"]'
)}
>
{formatParameterLabel(paramName)}
</Label>
)}
{renderParameterInput(paramName, paramSchema as any)}
</div>
{showDivider && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
{renderParameterInput(paramName, paramSchema as any)}
</div>
)
})}

View File

@@ -2069,6 +2069,7 @@ export const ToolInput = memo(function ToolInput({
placeholder: uiComponent.placeholder,
requiredScopes: uiComponent.requiredScopes,
dependsOn: uiComponent.dependsOn,
canonicalParamId: uiComponent.canonicalParamId ?? param.id,
}}
onProjectSelect={onChange}
disabled={disabled}

View File

@@ -34,6 +34,7 @@ interface LogRowContextMenuProps {
onCopyRunId: (runId: string) => void
onClearFilters: () => void
onClearConsole: () => void
onFixInCopilot: (entry: ConsoleEntry) => void
hasActiveFilters: boolean
}
@@ -54,6 +55,7 @@ export function LogRowContextMenu({
onCopyRunId,
onClearFilters,
onClearConsole,
onFixInCopilot,
hasActiveFilters,
}: LogRowContextMenuProps) {
const hasRunId = entry?.executionId != null
@@ -96,6 +98,21 @@ export function LogRowContextMenu({
</>
)}
{/* Fix in Copilot - only for error rows */}
{entry && !entry.success && (
<>
<PopoverItem
onClick={() => {
onFixInCopilot(entry)
onClose()
}}
>
Fix in Copilot
</PopoverItem>
<PopoverDivider />
</>
)}
{/* Filter actions */}
{entry && (
<>

View File

@@ -54,6 +54,7 @@ import { useShowTrainingControls } from '@/hooks/queries/general-settings'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
import { openCopilotWithMessage } from '@/stores/notifications/utils'
import type { ConsoleEntry } from '@/stores/terminal'
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -226,7 +227,6 @@ const isEventFromEditableElement = (e: KeyboardEvent): boolean => {
return false
}
// Check target and walk up ancestors in case editors render nested elements
let el: HTMLElement | null = target
while (el) {
if (isEditable(el)) return true
@@ -1159,6 +1159,17 @@ export const Terminal = memo(function Terminal() {
clearCurrentWorkflowConsole()
}, [clearCurrentWorkflowConsole])
const handleFixInCopilot = useCallback(
(entry: ConsoleEntry) => {
const errorMessage = entry.error ? String(entry.error) : 'Unknown error'
const blockName = entry.blockName || 'Unknown Block'
const message = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
openCopilotWithMessage(message)
closeLogRowMenu()
},
[closeLogRowMenu]
)
const handleTrainingClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -1949,6 +1960,7 @@ export const Terminal = memo(function Terminal() {
closeLogRowMenu()
}}
onClearConsole={handleClearConsoleFromMenu}
onFixInCopilot={handleFixInCopilot}
hasActiveFilters={hasActiveFilters}
/>
</>

View File

@@ -692,7 +692,8 @@ const WorkflowContent = React.memo(() => {
parentId?: string,
extent?: 'parent',
autoConnectEdge?: Edge,
triggerMode?: boolean
triggerMode?: boolean,
presetSubBlockValues?: Record<string, unknown>
) => {
setPendingSelection([id])
setSelectedEdges(new Map())
@@ -722,6 +723,14 @@ const WorkflowContent = React.memo(() => {
}
}
// Apply preset subblock values (e.g., from tool-operation search)
if (presetSubBlockValues) {
if (!subBlockValues[id]) {
subBlockValues[id] = {}
}
Object.assign(subBlockValues[id], presetSubBlockValues)
}
collaborativeBatchAddBlocks(
[block],
autoConnectEdge ? [autoConnectEdge] : [],
@@ -1489,7 +1498,7 @@ const WorkflowContent = React.memo(() => {
return
}
const { type, enableTriggerMode } = event.detail
const { type, enableTriggerMode, presetOperation } = event.detail
if (!type) return
if (type === 'connectionBlock') return
@@ -1552,7 +1561,8 @@ const WorkflowContent = React.memo(() => {
undefined,
undefined,
autoConnectEdge,
enableTriggerMode
enableTriggerMode,
presetOperation ? { operation: presetOperation } : undefined
)
}

View File

@@ -8,6 +8,7 @@ import { useParams, useRouter } from 'next/navigation'
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
import { useBrandConfig } from '@/lib/branding/branding'
import { cn } from '@/lib/core/utils/cn'
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
@@ -81,10 +82,12 @@ type SearchItem = {
color?: string
href?: string
shortcut?: string
type: 'block' | 'trigger' | 'tool' | 'workflow' | 'workspace' | 'page' | 'doc'
type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc'
isCurrent?: boolean
blockType?: string
config?: any
operationId?: string
aliases?: string[]
}
interface SearchResultItemProps {
@@ -101,7 +104,11 @@ const SearchResultItem = memo(function SearchResultItem({
onItemClick,
}: SearchResultItemProps) {
const Icon = item.icon
const showColoredIcon = item.type === 'block' || item.type === 'trigger' || item.type === 'tool'
const showColoredIcon =
item.type === 'block' ||
item.type === 'trigger' ||
item.type === 'tool' ||
item.type === 'tool-operation'
const isWorkflow = item.type === 'workflow'
const isWorkspace = item.type === 'workspace'
@@ -278,6 +285,24 @@ export const SearchModal = memo(function SearchModal({
)
}, [open, isOnWorkflowPage, filterBlocks])
const toolOperations = useMemo(() => {
if (!open || !isOnWorkflowPage) return []
const allowedBlockTypes = new Set(tools.map((t) => t.type))
return getToolOperationsIndex()
.filter((op) => allowedBlockTypes.has(op.blockType))
.map((op) => ({
id: op.id,
name: `${op.serviceName}: ${op.operationName}`,
icon: op.icon,
bgColor: op.bgColor,
blockType: op.blockType,
operationId: op.operationId,
aliases: op.aliases,
}))
}, [open, isOnWorkflowPage, tools])
const pages = useMemo(
(): PageItem[] => [
{
@@ -396,6 +421,19 @@ export const SearchModal = memo(function SearchModal({
})
})
toolOperations.forEach((op) => {
items.push({
id: op.id,
name: op.name,
icon: op.icon,
bgColor: op.bgColor,
type: 'tool-operation',
blockType: op.blockType,
operationId: op.operationId,
aliases: op.aliases,
})
})
docs.forEach((doc) => {
items.push({
id: doc.id,
@@ -407,10 +445,10 @@ export const SearchModal = memo(function SearchModal({
})
return items
}, [workspaces, workflows, pages, blocks, triggers, tools, docs])
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
const sectionOrder = useMemo<SearchItem['type'][]>(
() => ['block', 'tool', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
() => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
[]
)
@@ -457,6 +495,7 @@ export const SearchModal = memo(function SearchModal({
page: [],
trigger: [],
block: [],
'tool-operation': [],
tool: [],
doc: [],
}
@@ -512,6 +551,17 @@ export const SearchModal = memo(function SearchModal({
window.dispatchEvent(event)
}
break
case 'tool-operation':
if (item.blockType && item.operationId) {
const event = new CustomEvent('add-block-from-toolbar', {
detail: {
type: item.blockType,
presetOperation: item.operationId,
},
})
window.dispatchEvent(event)
}
break
case 'workspace':
if (item.isCurrent) {
break
@@ -592,6 +642,7 @@ export const SearchModal = memo(function SearchModal({
page: 'Pages',
trigger: 'Triggers',
block: 'Blocks',
'tool-operation': 'Tool Operations',
tool: 'Tools',
doc: 'Docs',
}

View File

@@ -8,17 +8,19 @@ export interface SearchableItem {
name: string
description?: string
type: string
aliases?: string[]
[key: string]: any
}
export interface SearchResult<T extends SearchableItem> {
item: T
score: number
matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | 'description'
matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description'
}
const SCORE_EXACT_MATCH = 10000
const SCORE_PREFIX_MATCH = 5000
const SCORE_ALIAS_MATCH = 3000
const SCORE_WORD_BOUNDARY = 1000
const SCORE_SUBSTRING_MATCH = 100
const DESCRIPTION_WEIGHT = 0.3
@@ -67,6 +69,39 @@ function calculateFieldScore(
return { score: 0, matchType: null }
}
/**
* Check if query matches any alias in the item's aliases array
* Returns the alias score if a match is found, 0 otherwise
*/
function calculateAliasScore(
query: string,
aliases?: string[]
): { score: number; matchType: 'alias' | null } {
if (!aliases || aliases.length === 0) {
return { score: 0, matchType: null }
}
const normalizedQuery = query.toLowerCase().trim()
for (const alias of aliases) {
const normalizedAlias = alias.toLowerCase().trim()
if (normalizedAlias === normalizedQuery) {
return { score: SCORE_ALIAS_MATCH, matchType: 'alias' }
}
if (normalizedAlias.startsWith(normalizedQuery)) {
return { score: SCORE_ALIAS_MATCH * 0.8, matchType: 'alias' }
}
if (normalizedQuery.includes(normalizedAlias) || normalizedAlias.includes(normalizedQuery)) {
return { score: SCORE_ALIAS_MATCH * 0.6, matchType: 'alias' }
}
}
return { score: 0, matchType: null }
}
/**
* Search items using tiered matching algorithm
* Returns items sorted by relevance (highest score first)
@@ -90,15 +125,20 @@ export function searchItems<T extends SearchableItem>(
? calculateFieldScore(normalizedQuery, item.description)
: { score: 0, matchType: null }
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
const nameScore = nameMatch.score
const descScore = descMatch.score * DESCRIPTION_WEIGHT
const aliasScore = aliasMatch.score
const bestScore = Math.max(nameScore, descScore)
const bestScore = Math.max(nameScore, descScore, aliasScore)
if (bestScore > 0) {
let matchType: SearchResult<T>['matchType'] = 'substring'
if (nameScore >= descScore) {
if (nameScore >= descScore && nameScore >= aliasScore) {
matchType = nameMatch.matchType || 'substring'
} else if (aliasScore >= descScore) {
matchType = 'alias'
} else {
matchType = 'description'
}
@@ -125,6 +165,8 @@ export function getMatchTypeLabel(matchType: SearchResult<any>['matchType']): st
return 'Exact match'
case 'prefix':
return 'Starts with'
case 'alias':
return 'Similar to'
case 'word-boundary':
return 'Word match'
case 'substring':

View File

@@ -11,7 +11,7 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
'Integrate Browser Use into the workflow. Can navigate the web and perform actions as if a real user was interacting with the browser.',
docsLink: 'https://docs.sim.ai/tools/browser_use',
category: 'tools',
bgColor: '#E0E0E0',
bgColor: '#181C1E',
icon: BrowserUseIcon,
subBlocks: [
{

View File

@@ -34,7 +34,7 @@ export function OTPVerificationEmail({
const brand = getBrandConfig()
return (
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)}>
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Your verification code:</Text>
<Section style={baseStyles.codeContainer}>

View File

@@ -12,7 +12,7 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw
const brand = getBrandConfig()
return (
<EmailLayout preview={`Reset your ${brand.name} password`}>
<EmailLayout preview={`Reset your ${brand.name} password`} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello {username},</Text>
<Text style={baseStyles.paragraph}>
A password reset was requested for your {brand.name} account. Click below to set a new

View File

@@ -13,7 +13,7 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) {
const baseUrl = getBaseUrl()
return (
<EmailLayout preview={`Welcome to ${brand.name}`}>
<EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hey ${userName},` : 'Hey,'}
</Text>

View File

@@ -23,7 +23,7 @@ export function CreditPurchaseEmail({
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>

View File

@@ -18,7 +18,10 @@ export function EnterpriseSubscriptionEmail({
const effectiveLoginLink = loginLink || `${baseUrl}/login`
return (
<EmailLayout preview={`Your Enterprise Plan is now active on ${brand.name}`}>
<EmailLayout
preview={`Your Enterprise Plan is now active on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
<Text style={baseStyles.paragraph}>
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced

View File

@@ -31,7 +31,7 @@ export function FreeTierUpgradeEmail({
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>

View File

@@ -25,7 +25,7 @@ export function PaymentFailedEmail({
const previewText = `${brand.name}: Payment Failed - Action Required`
return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>

View File

@@ -18,7 +18,7 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE
const previewText = `${brand.name}: Your ${planName} plan is active`
return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>

View File

@@ -25,7 +25,7 @@ export function UsageThresholdEmail({
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>

View File

@@ -20,7 +20,10 @@ export function CareersConfirmationEmail({
const baseUrl = getBaseUrl()
return (
<EmailLayout preview={`Your application to ${brand.name} has been received`}>
<EmailLayout
preview={`Your application to ${brand.name} has been received`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello {name},</Text>
<Text style={baseStyles.paragraph}>
We've received your application for <strong>{position}</strong>. Our team reviews every

View File

@@ -40,7 +40,7 @@ export function CareersSubmissionEmail({
submittedDate = new Date(),
}: CareersSubmissionEmailProps) {
return (
<EmailLayout preview={`New Career Application from ${name}`} hideFooter>
<EmailLayout preview={`New Career Application from ${name}`} hideFooter showUnsubscribe={false}>
<Text
style={{
...baseStyles.paragraph,

View File

@@ -4,22 +4,29 @@ import { getBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface UnsubscribeOptions {
unsubscribeToken?: string
email?: string
}
interface EmailFooterProps {
baseUrl?: string
unsubscribe?: UnsubscribeOptions
messageId?: string
/**
* Whether to show unsubscribe link. Defaults to true.
* Set to false for transactional emails where unsubscribe doesn't apply.
*/
showUnsubscribe?: boolean
}
/**
* Email footer component styled to match Stripe's email design.
* Sits in the gray area below the main white card.
*
* For non-transactional emails, the unsubscribe link uses placeholders
* {{UNSUBSCRIBE_TOKEN}} and {{UNSUBSCRIBE_EMAIL}} which are replaced
* by the mailer when sending.
*/
export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: EmailFooterProps) {
export function EmailFooter({
baseUrl = getBaseUrl(),
messageId,
showUnsubscribe = true,
}: EmailFooterProps) {
const brand = getBrandConfig()
const footerLinkStyle = {
@@ -181,19 +188,20 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }:
{' '}
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
Terms of Service
</a>{' '}
{' '}
<a
href={
unsubscribe?.unsubscribeToken && unsubscribe?.email
? `${baseUrl}/unsubscribe?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}`
: `mailto:${brand.supportEmail}?subject=Unsubscribe%20Request&body=Please%20unsubscribe%20me%20from%20all%20emails.`
}
style={footerLinkStyle}
rel='noopener noreferrer'
>
Unsubscribe
</a>
{showUnsubscribe && (
<>
{' '}
{' '}
<a
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
style={footerLinkStyle}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</>
)}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;

View File

@@ -11,13 +11,23 @@ interface EmailLayoutProps {
children: React.ReactNode
/** Optional: hide footer for internal emails */
hideFooter?: boolean
/**
* Whether to show unsubscribe link in footer.
* Set to false for transactional emails where unsubscribe doesn't apply.
*/
showUnsubscribe: boolean
}
/**
* Shared email layout wrapper providing consistent structure.
* Includes Html, Head, Body, Container with logo header, and Footer.
*/
export function EmailLayout({ preview, children, hideFooter = false }: EmailLayoutProps) {
export function EmailLayout({
preview,
children,
hideFooter = false,
showUnsubscribe,
}: EmailLayoutProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
@@ -43,7 +53,7 @@ export function EmailLayout({ preview, children, hideFooter = false }: EmailLayo
</Container>
{/* Footer in gray section */}
{!hideFooter && <EmailFooter baseUrl={baseUrl} />}
{!hideFooter && <EmailFooter baseUrl={baseUrl} showUnsubscribe={showUnsubscribe} />}
</Body>
</Html>
)

View File

@@ -54,6 +54,7 @@ export function BatchInvitationEmail({
return (
<EmailLayout
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>

View File

@@ -36,7 +36,10 @@ export function InvitationEmail({
}
return (
<EmailLayout preview={`You've been invited to join ${organizationName} on ${brand.name}`}>
<EmailLayout
preview={`You've been invited to join ${organizationName} on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}

View File

@@ -22,7 +22,10 @@ export function PollingGroupInvitationEmail({
const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'
return (
<EmailLayout preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}>
<EmailLayout
preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> from <strong>{organizationName}</strong> has invited you to

View File

@@ -41,6 +41,7 @@ export function WorkspaceInvitationEmail({
return (
<EmailLayout
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>

View File

@@ -73,7 +73,7 @@ export function WorkflowNotificationEmail({
: 'Your workflow completed successfully.'
return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>Hello,</Text>
<Text style={baseStyles.paragraph}>{message}</Text>

View File

@@ -32,7 +32,10 @@ export function HelpConfirmationEmail({
const typeLabel = getTypeLabel(type)
return (
<EmailLayout preview={`Your ${typeLabel.toLowerCase()} has been received`}>
<EmailLayout
preview={`Your ${typeLabel.toLowerCase()} has been received`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you

View File

@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
{...props}
version='1.0'
xmlns='http://www.w3.org/2000/svg'
width='150pt'
height='150pt'
width='28'
height='28'
viewBox='0 0 150 150'
preserveAspectRatio='xMidYMid meet'
>
<g transform='translate(0,150) scale(0.05,-0.05)' fill='#000000' stroke='none'>
<g transform='translate(0,150) scale(0.05,-0.05)' fill='currentColor' stroke='none'>
<path
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111

View File

@@ -1,599 +0,0 @@
/**
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/execution/cancellation', () => ({
isExecutionCancelled: vi.fn(),
isRedisCancellationEnabled: vi.fn(),
}))
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { ExecutionEngine } from './engine'
function createMockBlock(id: string): SerializedBlock {
return {
id,
metadata: { id: 'test', name: 'Test Block' },
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
function createMockNode(id: string, blockType = 'test'): DAGNode {
return {
id,
block: {
...createMockBlock(id),
metadata: { id: blockType, name: `Block ${id}` },
},
outgoingEdges: new Map(),
incomingEdges: new Set(),
metadata: {},
}
}
function createMockContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'test-workflow',
workspaceId: 'test-workspace',
executionId: 'test-execution',
userId: 'test-user',
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
loopExecutions: new Map(),
parallelExecutions: new Map(),
completedLoops: new Set(),
activeExecutionPath: new Set(),
metadata: {
executionId: 'test-execution',
startTime: new Date().toISOString(),
pendingBlocks: [],
},
envVars: {},
...overrides,
}
}
function createMockDAG(nodes: DAGNode[]): DAG {
const nodeMap = new Map<string, DAGNode>()
nodes.forEach((node) => nodeMap.set(node.id, node))
return {
nodes: nodeMap,
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
}
interface MockEdgeManager extends EdgeManager {
processOutgoingEdges: ReturnType<typeof vi.fn>
}
function createMockEdgeManager(
processOutgoingEdgesImpl?: (node: DAGNode) => string[]
): MockEdgeManager {
const mockFn = vi.fn().mockImplementation(processOutgoingEdgesImpl || (() => []))
return {
processOutgoingEdges: mockFn,
isNodeReady: vi.fn().mockReturnValue(true),
deactivateEdgeAndDescendants: vi.fn(),
restoreIncomingEdge: vi.fn(),
clearDeactivatedEdges: vi.fn(),
clearDeactivatedEdgesForNodes: vi.fn(),
} as unknown as MockEdgeManager
}
interface MockNodeOrchestrator extends NodeExecutionOrchestrator {
executionCount: number
}
function createMockNodeOrchestrator(executeDelay = 0): MockNodeOrchestrator {
const mock = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async () => {
mock.executionCount++
if (executeDelay > 0) {
await new Promise((resolve) => setTimeout(resolve, executeDelay))
}
return { nodeId: 'test', output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
}
return mock as unknown as MockNodeOrchestrator
}
describe('ExecutionEngine', () => {
beforeEach(() => {
vi.clearAllMocks()
;(isExecutionCancelled as Mock).mockResolvedValue(false)
;(isRedisCancellationEnabled as Mock).mockReturnValue(false)
})
afterEach(() => {
vi.useRealTimers()
})
describe('Normal execution', () => {
it('should execute a simple linear workflow', async () => {
const startNode = createMockNode('start', 'starter')
const endNode = createMockNode('end', 'function')
startNode.outgoingEdges.set('edge1', { target: 'end' })
endNode.incomingEdges.add('start')
const dag = createMockDAG([startNode, endNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['end']
return []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.success).toBe(true)
expect(nodeOrchestrator.executionCount).toBe(2)
})
it('should mark execution as successful when completed without cancellation', async () => {
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.success).toBe(true)
expect(result.status).toBeUndefined()
})
it('should execute all nodes in a multi-node workflow', async () => {
const nodes = [
createMockNode('start', 'starter'),
createMockNode('middle1', 'function'),
createMockNode('middle2', 'function'),
createMockNode('end', 'function'),
]
nodes[0].outgoingEdges.set('e1', { target: 'middle1' })
nodes[1].outgoingEdges.set('e2', { target: 'middle2' })
nodes[2].outgoingEdges.set('e3', { target: 'end' })
const dag = createMockDAG(nodes)
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['middle1']
if (node.id === 'middle1') return ['middle2']
if (node.id === 'middle2') return ['end']
return []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.success).toBe(true)
expect(nodeOrchestrator.executionCount).toBe(4)
})
})
describe('Cancellation via AbortSignal', () => {
it('should stop execution immediately when aborted before start', async () => {
const abortController = new AbortController()
abortController.abort()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.status).toBe('cancelled')
expect(nodeOrchestrator.executionCount).toBe(0)
})
it('should stop execution when aborted mid-workflow', async () => {
const abortController = new AbortController()
const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function'))
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` })
}
const dag = createMockDAG(nodes)
const context = createMockContext({ abortSignal: abortController.signal })
let callCount = 0
const edgeManager = createMockEdgeManager((node) => {
callCount++
if (callCount === 2) abortController.abort()
const idx = Number.parseInt(node.id.replace('node', ''))
if (idx < 4) return [`node${idx + 1}`]
return []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('node0')
expect(result.success).toBe(false)
expect(result.status).toBe('cancelled')
expect(nodeOrchestrator.executionCount).toBeLessThan(5)
})
it('should not wait for slow executions when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const slowNode = createMockNode('slow', 'function')
startNode.outgoingEdges.set('edge1', { target: 'slow' })
const dag = createMockDAG([startNode, slowNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['slow']
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(500)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const executionPromise = engine.run('start')
setTimeout(() => abortController.abort(), 50)
const startTime = Date.now()
const result = await executionPromise
const duration = Date.now() - startTime
expect(result.status).toBe('cancelled')
expect(duration).toBeLessThan(400)
})
it('should return cancelled status even if error thrown during cancellation', async () => {
const abortController = new AbortController()
abortController.abort()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.status).toBe('cancelled')
expect(result.success).toBe(false)
})
})
describe('Cancellation via Redis', () => {
it('should check Redis for cancellation when enabled', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
;(isExecutionCancelled as Mock).mockResolvedValue(false)
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await engine.run('start')
expect(isExecutionCancelled as Mock).toHaveBeenCalled()
})
it('should stop execution when Redis reports cancellation', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
let checkCount = 0
;(isExecutionCancelled as Mock).mockImplementation(async () => {
checkCount++
return checkCount > 1
})
const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function'))
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` })
}
const dag = createMockDAG(nodes)
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
const idx = Number.parseInt(node.id.replace('node', ''))
if (idx < 4) return [`node${idx + 1}`]
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(150)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('node0')
expect(result.success).toBe(false)
expect(result.status).toBe('cancelled')
})
it('should respect cancellation check interval', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
;(isExecutionCancelled as Mock).mockResolvedValue(false)
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await engine.run('start')
expect((isExecutionCancelled as Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
})
})
describe('Loop execution with cancellation', () => {
it('should break out of loop when cancelled mid-iteration', async () => {
const abortController = new AbortController()
const loopStartNode = createMockNode('loop-start', 'loop_sentinel')
loopStartNode.metadata = { isSentinel: true, sentinelType: 'start', loopId: 'loop1' }
const loopBodyNode = createMockNode('loop-body', 'function')
loopBodyNode.metadata = { isLoopNode: true, loopId: 'loop1' }
const loopEndNode = createMockNode('loop-end', 'loop_sentinel')
loopEndNode.metadata = { isSentinel: true, sentinelType: 'end', loopId: 'loop1' }
loopStartNode.outgoingEdges.set('edge1', { target: 'loop-body' })
loopBodyNode.outgoingEdges.set('edge2', { target: 'loop-end' })
loopEndNode.outgoingEdges.set('loop_continue', {
target: 'loop-start',
sourceHandle: 'loop_continue',
})
const dag = createMockDAG([loopStartNode, loopBodyNode, loopEndNode])
const context = createMockContext({ abortSignal: abortController.signal })
let iterationCount = 0
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'loop-start') return ['loop-body']
if (node.id === 'loop-body') return ['loop-end']
if (node.id === 'loop-end') {
iterationCount++
if (iterationCount === 3) abortController.abort()
return ['loop-start']
}
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(5)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('loop-start')
expect(result.status).toBe('cancelled')
expect(iterationCount).toBeLessThan(100)
})
})
describe('Parallel execution with cancellation', () => {
it('should stop queueing parallel branches when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const parallelNodes = Array.from({ length: 10 }, (_, i) =>
createMockNode(`parallel${i}`, 'function')
)
parallelNodes.forEach((_, i) => {
startNode.outgoingEdges.set(`edge${i}`, { target: `parallel${i}` })
})
const dag = createMockDAG([startNode, ...parallelNodes])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') {
return parallelNodes.map((_, i) => `parallel${i}`)
}
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(50)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const executionPromise = engine.run('start')
setTimeout(() => abortController.abort(), 30)
const result = await executionPromise
expect(result.status).toBe('cancelled')
expect(nodeOrchestrator.executionCount).toBeLessThan(11)
})
it('should not wait for all parallel branches when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const slowNodes = Array.from({ length: 5 }, (_, i) => createMockNode(`slow${i}`, 'function'))
slowNodes.forEach((_, i) => {
startNode.outgoingEdges.set(`edge${i}`, { target: `slow${i}` })
})
const dag = createMockDAG([startNode, ...slowNodes])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return slowNodes.map((_, i) => `slow${i}`)
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(200)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const executionPromise = engine.run('start')
setTimeout(() => abortController.abort(), 50)
const startTime = Date.now()
const result = await executionPromise
const duration = Date.now() - startTime
expect(result.status).toBe('cancelled')
expect(duration).toBeLessThan(500)
})
})
describe('Edge cases', () => {
it('should handle empty DAG gracefully', async () => {
const dag = createMockDAG([])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run()
expect(result.success).toBe(true)
expect(nodeOrchestrator.executionCount).toBe(0)
})
it('should preserve partial output when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const endNode = createMockNode('end', 'function')
endNode.outgoingEdges = new Map()
startNode.outgoingEdges.set('edge1', { target: 'end' })
const dag = createMockDAG([startNode, endNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['end']
return []
})
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'start') {
return { nodeId: 'start', output: { startData: 'value' }, isFinalOutput: false }
}
abortController.abort()
return { nodeId: 'end', output: { endData: 'value' }, isFinalOutput: true }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.status).toBe('cancelled')
expect(result.output).toBeDefined()
})
it('should populate metadata on cancellation', async () => {
const abortController = new AbortController()
abortController.abort()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.metadata).toBeDefined()
expect(result.metadata.endTime).toBeDefined()
expect(result.metadata.duration).toBeDefined()
})
it('should return logs even when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
context.blockLogs.push({
blockId: 'test',
blockName: 'Test',
blockType: 'test',
startedAt: '',
endedAt: '',
durationMs: 0,
success: true,
})
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
abortController.abort()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.logs).toBeDefined()
expect(result.logs.length).toBeGreaterThan(0)
})
})
describe('Cancellation flag behavior', () => {
it('should set cancelledFlag when abort signal fires', async () => {
const abortController = new AbortController()
const nodes = Array.from({ length: 3 }, (_, i) => createMockNode(`node${i}`, 'function'))
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` })
}
const dag = createMockDAG(nodes)
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'node0') {
abortController.abort()
return ['node1']
}
return node.id === 'node1' ? ['node2'] : []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('node0')
expect(result.status).toBe('cancelled')
})
it('should cache Redis cancellation result', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
;(isExecutionCancelled as Mock).mockResolvedValue(true)
const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function'))
const dag = createMockDAG(nodes)
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await engine.run('node0')
expect((isExecutionCancelled as Mock).mock.calls.length).toBeLessThanOrEqual(3)
})
})
})

View File

@@ -28,8 +28,6 @@ export class ExecutionEngine {
private lastCancellationCheck = 0
private readonly useRedisCancellation: boolean
private readonly CANCELLATION_CHECK_INTERVAL_MS = 500
private abortPromise: Promise<void> | null = null
private abortResolve: (() => void) | null = null
constructor(
private context: ExecutionContext,
@@ -39,34 +37,6 @@ export class ExecutionEngine {
) {
this.allowResumeTriggers = this.context.metadata.resumeFromSnapshot === true
this.useRedisCancellation = isRedisCancellationEnabled() && !!this.context.executionId
this.initializeAbortHandler()
}
/**
* Sets up a single abort promise that can be reused throughout execution.
* This avoids creating multiple event listeners and potential memory leaks.
*/
private initializeAbortHandler(): void {
if (!this.context.abortSignal) return
if (this.context.abortSignal.aborted) {
this.cancelledFlag = true
this.abortPromise = Promise.resolve()
return
}
this.abortPromise = new Promise<void>((resolve) => {
this.abortResolve = resolve
})
this.context.abortSignal.addEventListener(
'abort',
() => {
this.cancelledFlag = true
this.abortResolve?.()
},
{ once: true }
)
}
private async checkCancellation(): Promise<boolean> {
@@ -103,15 +73,12 @@ export class ExecutionEngine {
this.initializeQueue(triggerBlockId)
while (this.hasWork()) {
if (await this.checkCancellation()) {
if ((await this.checkCancellation()) && this.executing.size === 0) {
break
}
await this.processQueue()
}
if (!this.cancelledFlag) {
await this.waitForAllExecutions()
}
await this.waitForAllExecutions()
if (this.pausedBlocks.size > 0) {
return this.buildPausedResult(startTime)
@@ -197,7 +164,11 @@ export class ExecutionEngine {
private trackExecution(promise: Promise<void>): void {
this.executing.add(promise)
promise.catch(() => {})
// Attach error handler to prevent unhandled rejection warnings
// The actual error handling happens in waitForAllExecutions/waitForAnyExecution
promise.catch(() => {
// Error will be properly handled by Promise.all/Promise.race in wait methods
})
promise.finally(() => {
this.executing.delete(promise)
})
@@ -205,30 +176,12 @@ export class ExecutionEngine {
private async waitForAnyExecution(): Promise<void> {
if (this.executing.size > 0) {
const abortPromise = this.getAbortPromise()
if (abortPromise) {
await Promise.race([...this.executing, abortPromise])
} else {
await Promise.race(this.executing)
}
await Promise.race(this.executing)
}
}
private async waitForAllExecutions(): Promise<void> {
const abortPromise = this.getAbortPromise()
if (abortPromise) {
await Promise.race([Promise.all(this.executing), abortPromise])
} else {
await Promise.all(this.executing)
}
}
/**
* Returns the cached abort promise. This is safe to call multiple times
* as it reuses the same promise instance created during initialization.
*/
private getAbortPromise(): Promise<void> | null {
return this.abortPromise
await Promise.all(Array.from(this.executing))
}
private async withQueueLock<T>(fn: () => Promise<T> | T): Promise<T> {
@@ -324,7 +277,7 @@ export class ExecutionEngine {
this.trackExecution(promise)
}
if (this.executing.size > 0 && !this.cancelledFlag) {
if (this.executing.size > 0) {
await this.waitForAnyExecution()
}
}
@@ -383,6 +336,7 @@ export class ExecutionEngine {
this.addMultipleToQueue(readyNodes)
// Check for dynamically added nodes (e.g., from parallel expansion)
if (this.context.pendingDynamicNodes && this.context.pendingDynamicNodes.length > 0) {
const dynamicNodes = this.context.pendingDynamicNodes
this.context.pendingDynamicNodes = []

View File

@@ -1,4 +1,4 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type {
ChunkData,
ChunksPagination,
@@ -332,3 +332,629 @@ export function useDocumentChunkSearchQuery(
placeholderData: keepPreviousData,
})
}
export interface UpdateChunkParams {
knowledgeBaseId: string
documentId: string
chunkId: string
content?: string
enabled?: boolean
}
export async function updateChunk({
knowledgeBaseId,
documentId,
chunkId,
content,
enabled,
}: UpdateChunkParams): Promise<ChunkData> {
const body: Record<string, unknown> = {}
if (content !== undefined) body.content = content
if (enabled !== undefined) body.enabled = enabled
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update chunk')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to update chunk')
}
return result.data
}
export function useUpdateChunk() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateChunk,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface DeleteChunkParams {
knowledgeBaseId: string
documentId: string
chunkId: string
}
export async function deleteChunk({
knowledgeBaseId,
documentId,
chunkId,
}: DeleteChunkParams): Promise<void> {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete chunk')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to delete chunk')
}
}
export function useDeleteChunk() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteChunk,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface CreateChunkParams {
knowledgeBaseId: string
documentId: string
content: string
enabled?: boolean
}
export async function createChunk({
knowledgeBaseId,
documentId,
content,
enabled = true,
}: CreateChunkParams): Promise<ChunkData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, enabled }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create chunk')
}
const result = await response.json()
if (!result?.success || !result?.data) {
throw new Error(result?.error || 'Failed to create chunk')
}
return result.data
}
export function useCreateChunk() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createChunk,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface UpdateDocumentParams {
knowledgeBaseId: string
documentId: string
updates: {
enabled?: boolean
filename?: string
retryProcessing?: boolean
markFailedDueToTimeout?: boolean
}
}
export async function updateDocument({
knowledgeBaseId,
documentId,
updates,
}: UpdateDocumentParams): Promise<DocumentData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update document')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to update document')
}
return result.data
}
export function useUpdateDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateDocument,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface DeleteDocumentParams {
knowledgeBaseId: string
documentId: string
}
export async function deleteDocument({
knowledgeBaseId,
documentId,
}: DeleteDocumentParams): Promise<void> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete document')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to delete document')
}
}
export function useDeleteDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteDocument,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}
export interface BulkDocumentOperationParams {
knowledgeBaseId: string
operation: 'enable' | 'disable' | 'delete'
documentIds: string[]
}
export interface BulkDocumentOperationResult {
successCount: number
failedCount: number
updatedDocuments?: Array<{ id: string; enabled: boolean }>
}
export async function bulkDocumentOperation({
knowledgeBaseId,
operation,
documentIds,
}: BulkDocumentOperationParams): Promise<BulkDocumentOperationResult> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operation, documentIds }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || `Failed to ${operation} documents`)
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || `Failed to ${operation} documents`)
}
return result.data
}
export function useBulkDocumentOperation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: bulkDocumentOperation,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}
export interface CreateKnowledgeBaseParams {
name: string
description?: string
workspaceId: string
chunkingConfig: {
maxSize: number
minSize: number
overlap: number
}
}
export async function createKnowledgeBase(
params: CreateKnowledgeBaseParams
): Promise<KnowledgeBaseData> {
const response = await fetch('/api/knowledge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create knowledge base')
}
const result = await response.json()
if (!result?.success || !result?.data) {
throw new Error(result?.error || 'Failed to create knowledge base')
}
return result.data
}
export function useCreateKnowledgeBase(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createKnowledgeBase,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
},
})
}
export interface UpdateKnowledgeBaseParams {
knowledgeBaseId: string
updates: {
name?: string
description?: string
workspaceId?: string | null
}
}
export async function updateKnowledgeBase({
knowledgeBaseId,
updates,
}: UpdateKnowledgeBaseParams): Promise<KnowledgeBaseData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update knowledge base')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to update knowledge base')
}
return result.data
}
export function useUpdateKnowledgeBase(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateKnowledgeBase,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
},
})
}
export interface DeleteKnowledgeBaseParams {
knowledgeBaseId: string
}
export async function deleteKnowledgeBase({
knowledgeBaseId,
}: DeleteKnowledgeBaseParams): Promise<void> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete knowledge base')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to delete knowledge base')
}
}
export function useDeleteKnowledgeBase(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteKnowledgeBase,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
},
})
}
export interface BulkChunkOperationParams {
knowledgeBaseId: string
documentId: string
operation: 'enable' | 'disable' | 'delete'
chunkIds: string[]
}
export interface BulkChunkOperationResult {
successCount: number
failedCount: number
results: Array<{
operation: string
chunkIds: string[]
}>
}
export async function bulkChunkOperation({
knowledgeBaseId,
documentId,
operation,
chunkIds,
}: BulkChunkOperationParams): Promise<BulkChunkOperationResult> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operation, chunkIds }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || `Failed to ${operation} chunks`)
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || `Failed to ${operation} chunks`)
}
return result.data
}
export function useBulkChunkOperation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: bulkChunkOperation,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface UpdateDocumentTagsParams {
knowledgeBaseId: string
documentId: string
tags: Record<string, string>
}
export async function updateDocumentTags({
knowledgeBaseId,
documentId,
tags,
}: UpdateDocumentTagsParams): Promise<DocumentData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tags),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update document tags')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to update document tags')
}
return result.data
}
export function useUpdateDocumentTags() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateDocumentTags,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface TagDefinitionData {
id: string
tagSlot: string
displayName: string
fieldType: string
createdAt: string
updatedAt: string
}
export interface CreateTagDefinitionParams {
knowledgeBaseId: string
displayName: string
fieldType: string
}
async function fetchNextAvailableSlot(knowledgeBaseId: string, fieldType: string): Promise<string> {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${fieldType}`
)
if (!response.ok) {
throw new Error('Failed to get available slot')
}
const result = await response.json()
if (!result.success || !result.data?.nextAvailableSlot) {
throw new Error('No available tag slots for this field type')
}
return result.data.nextAvailableSlot
}
export async function createTagDefinition({
knowledgeBaseId,
displayName,
fieldType,
}: CreateTagDefinitionParams): Promise<TagDefinitionData> {
const tagSlot = await fetchNextAvailableSlot(knowledgeBaseId, fieldType)
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagSlot, displayName, fieldType }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create tag definition')
}
const result = await response.json()
if (!result?.success || !result?.data) {
throw new Error(result?.error || 'Failed to create tag definition')
}
return result.data
}
export function useCreateTagDefinition() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createTagDefinition,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}
export interface DeleteTagDefinitionParams {
knowledgeBaseId: string
tagDefinitionId: string
}
export async function deleteTagDefinition({
knowledgeBaseId,
tagDefinitionId,
}: DeleteTagDefinitionParams): Promise<void> {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${tagDefinitionId}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete tag definition')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to delete tag definition')
}
}
export function useDeleteTagDefinition() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteTagDefinition,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}

View File

@@ -203,10 +203,11 @@ function resolveProjectSelector(
): SelectorResolution {
const serviceId = subBlock.serviceId
const context = buildBaseContext(args)
const selectorId = subBlock.canonicalParamId ?? subBlock.id
switch (serviceId) {
case 'linear': {
const key: SelectorKey = subBlock.id === 'teamId' ? 'linear.teams' : 'linear.projects'
const key: SelectorKey = selectorId === 'teamId' ? 'linear.teams' : 'linear.projects'
return { key, context, allowSearch: true }
}
case 'jira':

View File

@@ -21,6 +21,8 @@ import {
type BatchToggleEnabledOperation,
type BatchToggleHandlesOperation,
type BatchUpdateParentOperation,
captureLatestEdges,
captureLatestSubBlockValues,
createOperationEntry,
runWithUndoRedoRecordingSuspended,
type UpdateParentOperation,
@@ -28,7 +30,6 @@ import {
} from '@/stores/undo-redo'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -445,34 +446,19 @@ export function useUndoRedo() {
break
}
const latestEdges = useWorkflowStore
.getState()
.edges.filter(
(e) => existingBlockIds.includes(e.source) || existingBlockIds.includes(e.target)
)
const latestEdges = captureLatestEdges(
useWorkflowStore.getState().edges,
existingBlockIds
)
batchRemoveOp.data.edgeSnapshots = latestEdges
const latestSubBlockValues: Record<string, Record<string, unknown>> = {}
existingBlockIds.forEach((blockId) => {
const merged = mergeSubblockState(
useWorkflowStore.getState().blocks,
activeWorkflowId,
blockId
)
const block = merged[blockId]
if (block?.subBlocks) {
const values: Record<string, unknown> = {}
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
values[subBlockId] = subBlock.value
}
})
if (Object.keys(values).length > 0) {
latestSubBlockValues[blockId] = values
}
}
})
const latestSubBlockValues = captureLatestSubBlockValues(
useWorkflowStore.getState().blocks,
activeWorkflowId,
existingBlockIds
)
batchRemoveOp.data.subBlockValues = latestSubBlockValues
;(entry.operation as BatchAddBlocksOperation).data.subBlockValues = latestSubBlockValues
addToQueue({
id: opId,
@@ -1153,6 +1139,20 @@ export function useUndoRedo() {
break
}
const latestEdges = captureLatestEdges(
useWorkflowStore.getState().edges,
existingBlockIds
)
batchOp.data.edgeSnapshots = latestEdges
const latestSubBlockValues = captureLatestSubBlockValues(
useWorkflowStore.getState().blocks,
activeWorkflowId,
existingBlockIds
)
batchOp.data.subBlockValues = latestSubBlockValues
;(entry.inverse as BatchAddBlocksOperation).data.subBlockValues = latestSubBlockValues
addToQueue({
id: opId,
operation: {

View File

@@ -29,13 +29,11 @@ export class DocsChunker {
private readonly baseUrl: string
constructor(options: DocsChunkerOptions = {}) {
// Use the existing TextChunker for chunking logic
this.textChunker = new TextChunker({
chunkSize: options.chunkSize ?? 300, // Max 300 tokens per chunk
minCharactersPerChunk: options.minCharactersPerChunk ?? 1,
chunkOverlap: options.chunkOverlap ?? 50,
})
// Use localhost docs in development, production docs otherwise
this.baseUrl = options.baseUrl ?? 'https://docs.sim.ai'
}
@@ -74,24 +72,18 @@ export class DocsChunker {
const content = await fs.readFile(filePath, 'utf-8')
const relativePath = path.relative(basePath, filePath)
// Parse frontmatter and content
const { data: frontmatter, content: markdownContent } = this.parseFrontmatter(content)
// Extract headers from the content
const headers = this.extractHeaders(markdownContent)
// Generate document URL
const documentUrl = this.generateDocumentUrl(relativePath)
// Split content into chunks
const textChunks = await this.splitContent(markdownContent)
// Generate embeddings for all chunks at once (batch processing)
logger.info(`Generating embeddings for ${textChunks.length} chunks in ${relativePath}`)
const embeddings = textChunks.length > 0 ? await generateEmbeddings(textChunks) : []
const embeddingModel = 'text-embedding-3-small'
// Convert to DocChunk objects with header context and embeddings
const chunks: DocChunk[] = []
let currentPosition = 0
@@ -100,7 +92,6 @@ export class DocsChunker {
const chunkStart = currentPosition
const chunkEnd = currentPosition + chunkText.length
// Find the most relevant header for this chunk
const relevantHeader = this.findRelevantHeader(headers, chunkStart)
const chunk: DocChunk = {
@@ -186,11 +177,21 @@ export class DocsChunker {
/**
* Generate document URL from relative path
* Handles index.mdx files specially - they are served at the parent directory path
*/
private generateDocumentUrl(relativePath: string): string {
// Convert file path to URL path
// e.g., "tools/knowledge.mdx" -> "/tools/knowledge"
const urlPath = relativePath.replace(/\.mdx$/, '').replace(/\\/g, '/') // Handle Windows paths
// e.g., "triggers/index.mdx" -> "/triggers" (NOT "/triggers/index")
let urlPath = relativePath.replace(/\.mdx$/, '').replace(/\\/g, '/') // Handle Windows paths
// In fumadocs, index.mdx files are served at the parent directory path
// e.g., "triggers/index" -> "triggers"
if (urlPath.endsWith('/index')) {
urlPath = urlPath.slice(0, -6) // Remove "/index"
} else if (urlPath === 'index') {
urlPath = '' // Root index.mdx
}
return `${this.baseUrl}/${urlPath}`
}
@@ -201,7 +202,6 @@ export class DocsChunker {
private findRelevantHeader(headers: HeaderInfo[], position: number): HeaderInfo | null {
if (headers.length === 0) return null
// Find the last header that comes before this position
let relevantHeader: HeaderInfo | null = null
for (const header of headers) {
@@ -219,23 +219,18 @@ export class DocsChunker {
* Split content into chunks using the existing TextChunker with table awareness
*/
private async splitContent(content: string): Promise<string[]> {
// Clean the content first
const cleanedContent = this.cleanContent(content)
// Detect table boundaries to avoid splitting them
const tableBoundaries = this.detectTableBoundaries(cleanedContent)
// Use the existing TextChunker
const chunks = await this.textChunker.chunk(cleanedContent)
// Post-process chunks to ensure tables aren't split
const processedChunks = this.mergeTableChunks(
chunks.map((chunk) => chunk.text),
tableBoundaries,
cleanedContent
)
// Ensure no chunk exceeds 300 tokens
const finalChunks = this.enforceSizeLimit(processedChunks)
return finalChunks
@@ -273,7 +268,6 @@ export class DocsChunker {
const [, frontmatterText, markdownContent] = match
const data: Frontmatter = {}
// Simple YAML parsing for title and description
const lines = frontmatterText.split('\n')
for (const line of lines) {
const colonIndex = line.indexOf(':')
@@ -294,7 +288,6 @@ export class DocsChunker {
* Estimate token count (rough approximation)
*/
private estimateTokens(text: string): number {
// Rough approximation: 1 token ≈ 4 characters
return Math.ceil(text.length / 4)
}
@@ -311,17 +304,13 @@ export class DocsChunker {
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
// Detect table start (markdown table row with pipes)
if (line.includes('|') && line.split('|').length >= 3 && !inTable) {
// Check if next line is table separator (contains dashes and pipes)
const nextLine = lines[i + 1]?.trim()
if (nextLine?.includes('|') && nextLine.includes('-')) {
inTable = true
tableStart = i
}
}
// Detect table end (empty line or non-table content)
else if (inTable && (!line.includes('|') || line === '' || line.startsWith('#'))) {
} else if (inTable && (!line.includes('|') || line === '' || line.startsWith('#'))) {
tables.push({
start: this.getCharacterPosition(lines, tableStart),
end: this.getCharacterPosition(lines, i - 1) + lines[i - 1]?.length || 0,
@@ -330,7 +319,6 @@ export class DocsChunker {
}
}
// Handle table at end of content
if (inTable && tableStart >= 0) {
tables.push({
start: this.getCharacterPosition(lines, tableStart),
@@ -367,7 +355,6 @@ export class DocsChunker {
const chunkStart = originalContent.indexOf(chunk, currentPosition)
const chunkEnd = chunkStart + chunk.length
// Check if this chunk intersects with any table
const intersectsTable = tableBoundaries.some(
(table) =>
(chunkStart >= table.start && chunkStart <= table.end) ||
@@ -376,7 +363,6 @@ export class DocsChunker {
)
if (intersectsTable) {
// Find which table(s) this chunk intersects with
const affectedTables = tableBoundaries.filter(
(table) =>
(chunkStart >= table.start && chunkStart <= table.end) ||
@@ -384,12 +370,10 @@ export class DocsChunker {
(chunkStart <= table.start && chunkEnd >= table.end)
)
// Create a chunk that includes the complete table(s)
const minStart = Math.min(chunkStart, ...affectedTables.map((t) => t.start))
const maxEnd = Math.max(chunkEnd, ...affectedTables.map((t) => t.end))
const completeChunk = originalContent.slice(minStart, maxEnd)
// Only add if we haven't already included this content
if (!mergedChunks.some((existing) => existing.includes(completeChunk.trim()))) {
mergedChunks.push(completeChunk.trim())
}
@@ -400,7 +384,7 @@ export class DocsChunker {
currentPosition = chunkEnd
}
return mergedChunks.filter((chunk) => chunk.length > 50) // Filter out tiny chunks
return mergedChunks.filter((chunk) => chunk.length > 50)
}
/**
@@ -413,10 +397,8 @@ export class DocsChunker {
const tokens = this.estimateTokens(chunk)
if (tokens <= 300) {
// Chunk is within limit
finalChunks.push(chunk)
} else {
// Chunk is too large - split it
const lines = chunk.split('\n')
let currentChunk = ''
@@ -426,7 +408,6 @@ export class DocsChunker {
if (this.estimateTokens(testChunk) <= 300) {
currentChunk = testChunk
} else {
// Adding this line would exceed limit
if (currentChunk.trim()) {
finalChunks.push(currentChunk.trim())
}
@@ -434,7 +415,6 @@ export class DocsChunker {
}
}
// Add final chunk if it has content
if (currentChunk.trim()) {
finalChunks.push(currentChunk.trim())
}

View File

@@ -209,13 +209,17 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
}
}
const variablesArray = Object.values(byName)
// Convert byName (keyed by name) to record keyed by ID for the API
const variablesRecord: Record<string, any> = {}
for (const v of Object.values(byName)) {
variablesRecord[v.id] = v
}
// POST full variables array to persist
// POST full variables record to persist
const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesArray }),
body: JSON.stringify({ variables: variablesRecord }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')

View File

@@ -817,6 +817,8 @@ function normalizeResponseFormat(value: any): string {
interface EdgeHandleValidationResult {
valid: boolean
error?: string
/** The normalized handle to use (e.g., simple 'if' normalized to 'condition-{uuid}') */
normalizedHandle?: string
}
/**
@@ -851,13 +853,6 @@ function validateSourceHandleForBlock(
}
case 'condition': {
if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "${EDGE.CONDITION_PREFIX}"`,
}
}
const conditionsValue = sourceBlock?.subBlocks?.conditions?.value
if (!conditionsValue) {
return {
@@ -866,6 +861,8 @@ function validateSourceHandleForBlock(
}
}
// validateConditionHandle accepts simple format (if, else-if-0, else),
// legacy format (condition-{blockId}-if), and internal ID format (condition-{uuid})
return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue)
}
@@ -879,13 +876,6 @@ function validateSourceHandleForBlock(
}
case 'router_v2': {
if (!sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for router_v2 block. Must start with "${EDGE.ROUTER_PREFIX}"`,
}
}
const routesValue = sourceBlock?.subBlocks?.routes?.value
if (!routesValue) {
return {
@@ -894,6 +884,8 @@ function validateSourceHandleForBlock(
}
}
// validateRouterHandle accepts simple format (route-0, route-1),
// legacy format (router-{blockId}-route-1), and internal ID format (router-{uuid})
return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue)
}
@@ -910,7 +902,12 @@ function validateSourceHandleForBlock(
/**
* Validates condition handle references a valid condition in the block.
* Accepts both internal IDs (condition-blockId-if) and semantic keys (condition-blockId-else-if)
* Accepts multiple formats:
* - Simple format: "if", "else-if-0", "else-if-1", "else"
* - Legacy semantic format: "condition-{blockId}-if", "condition-{blockId}-else-if"
* - Internal ID format: "condition-{conditionId}"
*
* Returns the normalized handle (condition-{conditionId}) for storage.
*/
function validateConditionHandle(
sourceHandle: string,
@@ -943,48 +940,80 @@ function validateConditionHandle(
}
}
const validHandles = new Set<string>()
const semanticPrefix = `condition-${blockId}-`
let elseIfCount = 0
// Build a map of all valid handle formats -> normalized handle (condition-{conditionId})
const handleToNormalized = new Map<string, string>()
const legacySemanticPrefix = `condition-${blockId}-`
let elseIfIndex = 0
for (const condition of conditions) {
if (condition.id) {
validHandles.add(`condition-${condition.id}`)
}
if (!condition.id) continue
const normalizedHandle = `condition-${condition.id}`
const title = condition.title?.toLowerCase()
// Always accept internal ID format
handleToNormalized.set(normalizedHandle, normalizedHandle)
if (title === 'if') {
// Simple format: "if"
handleToNormalized.set('if', normalizedHandle)
// Legacy format: "condition-{blockId}-if"
handleToNormalized.set(`${legacySemanticPrefix}if`, normalizedHandle)
} else if (title === 'else if') {
// Simple format: "else-if-0", "else-if-1", etc. (0-indexed)
handleToNormalized.set(`else-if-${elseIfIndex}`, normalizedHandle)
// Legacy format: "condition-{blockId}-else-if" for first, "condition-{blockId}-else-if-2" for second
if (elseIfIndex === 0) {
handleToNormalized.set(`${legacySemanticPrefix}else-if`, normalizedHandle)
} else {
handleToNormalized.set(
`${legacySemanticPrefix}else-if-${elseIfIndex + 1}`,
normalizedHandle
)
}
elseIfIndex++
} else if (title === 'else') {
// Simple format: "else"
handleToNormalized.set('else', normalizedHandle)
// Legacy format: "condition-{blockId}-else"
handleToNormalized.set(`${legacySemanticPrefix}else`, normalizedHandle)
}
}
const normalizedHandle = handleToNormalized.get(sourceHandle)
if (normalizedHandle) {
return { valid: true, normalizedHandle }
}
// Build list of valid simple format options for error message
const simpleOptions: string[] = []
elseIfIndex = 0
for (const condition of conditions) {
const title = condition.title?.toLowerCase()
if (title === 'if') {
validHandles.add(`${semanticPrefix}if`)
simpleOptions.push('if')
} else if (title === 'else if') {
elseIfCount++
validHandles.add(
elseIfCount === 1 ? `${semanticPrefix}else-if` : `${semanticPrefix}else-if-${elseIfCount}`
)
simpleOptions.push(`else-if-${elseIfIndex}`)
elseIfIndex++
} else if (title === 'else') {
validHandles.add(`${semanticPrefix}else`)
simpleOptions.push('else')
}
}
if (validHandles.has(sourceHandle)) {
return { valid: true }
}
const validOptions = Array.from(validHandles).slice(0, 5)
const moreCount = validHandles.size - validOptions.length
let validOptionsStr = validOptions.join(', ')
if (moreCount > 0) {
validOptionsStr += `, ... and ${moreCount} more`
}
return {
valid: false,
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
}
}
/**
* Validates router handle references a valid route in the block.
* Accepts both internal IDs (router-{routeId}) and semantic keys (router-{blockId}-route-1)
* Accepts multiple formats:
* - Simple format: "route-0", "route-1", "route-2" (0-indexed)
* - Legacy semantic format: "router-{blockId}-route-1" (1-indexed)
* - Internal ID format: "router-{routeId}"
*
* Returns the normalized handle (router-{routeId}) for storage.
*/
function validateRouterHandle(
sourceHandle: string,
@@ -1017,47 +1046,48 @@ function validateRouterHandle(
}
}
const validHandles = new Set<string>()
const semanticPrefix = `router-${blockId}-`
// Build a map of all valid handle formats -> normalized handle (router-{routeId})
const handleToNormalized = new Map<string, string>()
const legacySemanticPrefix = `router-${blockId}-`
for (let i = 0; i < routes.length; i++) {
const route = routes[i]
if (!route.id) continue
// Accept internal ID format: router-{uuid}
if (route.id) {
validHandles.add(`router-${route.id}`)
}
const normalizedHandle = `router-${route.id}`
// Accept 1-indexed route number format: router-{blockId}-route-1, router-{blockId}-route-2, etc.
validHandles.add(`${semanticPrefix}route-${i + 1}`)
// Always accept internal ID format: router-{uuid}
handleToNormalized.set(normalizedHandle, normalizedHandle)
// Simple format: route-0, route-1, etc. (0-indexed)
handleToNormalized.set(`route-${i}`, normalizedHandle)
// Legacy 1-indexed route number format: router-{blockId}-route-1
handleToNormalized.set(`${legacySemanticPrefix}route-${i + 1}`, normalizedHandle)
// Accept normalized title format: router-{blockId}-{normalized-title}
// Normalize: lowercase, replace spaces with dashes, remove special chars
if (route.title && typeof route.title === 'string') {
const normalizedTitle = route.title
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
if (normalizedTitle) {
validHandles.add(`${semanticPrefix}${normalizedTitle}`)
handleToNormalized.set(`${legacySemanticPrefix}${normalizedTitle}`, normalizedHandle)
}
}
}
if (validHandles.has(sourceHandle)) {
return { valid: true }
const normalizedHandle = handleToNormalized.get(sourceHandle)
if (normalizedHandle) {
return { valid: true, normalizedHandle }
}
const validOptions = Array.from(validHandles).slice(0, 5)
const moreCount = validHandles.size - validOptions.length
let validOptionsStr = validOptions.join(', ')
if (moreCount > 0) {
validOptionsStr += `, ... and ${moreCount} more`
}
// Build list of valid simple format options for error message
const simpleOptions = routes.map((_, i) => `route-${i}`)
return {
valid: false,
error: `Invalid router handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
error: `Invalid router handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
}
}
@@ -1172,10 +1202,13 @@ function createValidatedEdge(
return false
}
// Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}')
const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle
modifiedState.edges.push({
id: crypto.randomUUID(),
source: sourceBlockId,
sourceHandle,
sourceHandle: finalSourceHandle,
target: targetBlockId,
targetHandle,
type: 'default',
@@ -1184,7 +1217,11 @@ function createValidatedEdge(
}
/**
* Adds connections as edges for a block
* Adds connections as edges for a block.
* Supports multiple target formats:
* - String: "target-block-id"
* - Object: { block: "target-block-id", handle?: "custom-target-handle" }
* - Array of strings or objects
*/
function addConnectionsAsEdges(
modifiedState: any,
@@ -1194,19 +1231,34 @@ function addConnectionsAsEdges(
skippedItems?: SkippedItem[]
): void {
Object.entries(connections).forEach(([sourceHandle, targets]) => {
const targetArray = Array.isArray(targets) ? targets : [targets]
targetArray.forEach((targetId: string) => {
if (targets === null) return
const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => {
createValidatedEdge(
modifiedState,
blockId,
targetId,
targetBlock,
sourceHandle,
'target',
targetHandle || 'target',
'add_edge',
logger,
skippedItems
)
})
}
if (typeof targets === 'string') {
addEdgeForTarget(targets)
} else if (Array.isArray(targets)) {
targets.forEach((target: any) => {
if (typeof target === 'string') {
addEdgeForTarget(target)
} else if (target?.block) {
addEdgeForTarget(target.block, target.handle)
}
})
} else if (typeof targets === 'object' && targets?.block) {
addEdgeForTarget(targets.block, targets.handle)
}
})
}

View File

@@ -326,32 +326,32 @@ export const env = createEnv({
NEXT_PUBLIC_E2B_ENABLED: z.string().optional(),
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(),
NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground
NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground
NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL
NEXT_PUBLIC_TERMS_URL: z.string().url().optional(), // Custom terms of service URL
NEXT_PUBLIC_PRIVACY_URL: z.string().url().optional(), // Custom privacy policy URL
// Theme Customization
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand color (hex format, e.g., "#701ffc")
NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand hover state (hex format)
NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand hover state (hex format)
NEXT_PUBLIC_BRAND_ACCENT_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand color (hex format)
NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand hover state (hex format)
NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Brand background color (hex format)
// Feature Flags
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
},
// Variables available on both server and client
shared: {
NODE_ENV: z.enum(['development', 'test', 'production']).optional(), // Runtime environment
NEXT_TELEMETRY_DISABLED: z.string().optional(), // Disable Next.js telemetry collection
NEXT_TELEMETRY_DISABLED: z.string().optional(), // Disable Next.js telemetry collection
},
experimental__runtimeEnv: {

View File

@@ -152,15 +152,20 @@ function addUnsubscribeData(
): UnsubscribeData {
const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType)
const baseUrl = getBaseUrl()
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(recipientEmail)}`
const encodedEmail = encodeURIComponent(recipientEmail)
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodedEmail}`
return {
headers: {
'List-Unsubscribe': `<${unsubscribeUrl}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
html: html?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
text: text?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
html: html
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
text: text
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
}
}
@@ -361,15 +366,15 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
subject: email.subject,
}
if (email.html) emailData.html = email.html
if (email.text) emailData.text = email.text
if (includeUnsubscribe && emailType !== 'transactional') {
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text)
emailData.headers = unsubData.headers
if (unsubData.html) emailData.html = unsubData.html
if (unsubData.text) emailData.text = unsubData.text
} else {
if (email.html) emailData.html = email.html
if (email.text) emailData.text = email.text
}
batchEmails.push(emailData)

View File

@@ -114,17 +114,15 @@ describe('unsubscribe utilities', () => {
})
it.concurrent('should handle legacy tokens (2 parts) and default to marketing', () => {
// Generate a real legacy token using the actual hashing logic to ensure backward compatibility
const salt = 'abc123'
const secret = 'test-secret-key'
const { createHash } = require('crypto')
const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex')
const legacyToken = `${salt}:${hash}`
// This should return valid since we're using the actual legacy format properly
const result = verifyUnsubscribeToken(testEmail, legacyToken)
expect(result.valid).toBe(true)
expect(result.emailType).toBe('marketing') // Should default to marketing for legacy tokens
expect(result.emailType).toBe('marketing')
})
it.concurrent('should reject malformed tokens', () => {
@@ -226,7 +224,6 @@ describe('unsubscribe utilities', () => {
it('should update email preferences for existing user', async () => {
const userId = 'user-123'
// Mock finding the user
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
@@ -235,7 +232,6 @@ describe('unsubscribe utilities', () => {
}),
})
// Mock getting existing settings
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
@@ -244,7 +240,6 @@ describe('unsubscribe utilities', () => {
}),
})
// Mock insert with upsert
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
@@ -300,7 +295,6 @@ describe('unsubscribe utilities', () => {
await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
// Verify that the merged preferences are passed
expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
emailPreferences: {

View File

@@ -38,7 +38,6 @@ export function verifyUnsubscribeToken(
const parts = token.split(':')
if (parts.length < 2) return { valid: false }
// Handle legacy tokens (without email type)
if (parts.length === 2) {
const [salt, expectedHash] = parts
const hash = createHash('sha256')
@@ -48,7 +47,6 @@ export function verifyUnsubscribeToken(
return { valid: hash === expectedHash, emailType: 'marketing' }
}
// Handle new tokens (with email type)
const [salt, expectedHash, emailType] = parts
if (!salt || !expectedHash || !emailType) return { valid: false }
@@ -101,7 +99,6 @@ export async function updateEmailPreferences(
preferences: EmailPreferences
): Promise<boolean> {
try {
// First, find the user
const userResult = await db
.select({ id: user.id })
.from(user)
@@ -115,7 +112,6 @@ export async function updateEmailPreferences(
const userId = userResult[0].id
// Get existing email preferences
const existingSettings = await db
.select({ emailPreferences: settings.emailPreferences })
.from(settings)
@@ -127,13 +123,11 @@ export async function updateEmailPreferences(
currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {}
}
// Merge email preferences
const updatedEmailPreferences = {
...currentEmailPreferences,
...preferences,
}
// Upsert settings
await db
.insert(settings)
.values({
@@ -168,10 +162,8 @@ export async function isUnsubscribed(
const preferences = await getEmailPreferences(email)
if (!preferences) return false
// Check unsubscribe all first
if (preferences.unsubscribeAll) return true
// Check specific type
switch (emailType) {
case 'marketing':
return preferences.unsubscribeMarketing || false

View File

@@ -0,0 +1,193 @@
import type { ComponentType } from 'react'
import { getAllBlocks } from '@/blocks'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
/**
* Represents a searchable tool operation extracted from block configurations.
* Each operation maps to a specific tool that can be invoked when the block
* is configured with that operation selected.
*/
export interface ToolOperationItem {
/** Unique identifier combining block type and operation ID (e.g., "slack_send") */
id: string
/** The block type this operation belongs to (e.g., "slack") */
blockType: string
/** The operation dropdown value (e.g., "send") */
operationId: string
/** Human-readable service name from the block (e.g., "Slack") */
serviceName: string
/** Human-readable operation name from the dropdown label (e.g., "Send Message") */
operationName: string
/** The block's icon component */
icon: ComponentType<{ className?: string }>
/** The block's background color */
bgColor: string
/** Search aliases for common synonyms */
aliases: string[]
}
/**
* Maps common action verbs to their synonyms for better search matching.
* When a user searches for "post message", it should match "send message".
* Based on analysis of 1000+ tool operations in the codebase.
*/
const ACTION_VERB_ALIASES: Record<string, string[]> = {
get: ['read', 'fetch', 'retrieve', 'load', 'obtain'],
read: ['get', 'fetch', 'retrieve', 'load'],
create: ['make', 'new', 'add', 'generate', 'insert'],
add: ['create', 'insert', 'append', 'include'],
update: ['edit', 'modify', 'change', 'patch', 'set'],
set: ['update', 'configure', 'assign'],
delete: ['remove', 'trash', 'destroy', 'erase'],
remove: ['delete', 'clear', 'drop', 'unset'],
list: ['show', 'display', 'view', 'browse', 'enumerate'],
search: ['find', 'query', 'lookup', 'locate'],
query: ['search', 'find', 'lookup'],
send: ['post', 'write', 'deliver', 'transmit', 'publish'],
write: ['send', 'post', 'compose'],
download: ['export', 'save', 'pull', 'fetch'],
upload: ['import', 'push', 'transfer', 'attach'],
execute: ['run', 'invoke', 'trigger', 'perform', 'start'],
check: ['verify', 'validate', 'test', 'inspect'],
cancel: ['abort', 'stop', 'terminate', 'revoke'],
archive: ['store', 'backup', 'preserve'],
copy: ['duplicate', 'clone', 'replicate'],
move: ['transfer', 'relocate', 'migrate'],
share: ['publish', 'distribute', 'broadcast'],
}
/**
* Generates search aliases for an operation name by finding synonyms
* for action verbs in the operation name.
*/
function generateAliases(operationName: string): string[] {
const aliases: string[] = []
const lowerName = operationName.toLowerCase()
for (const [verb, synonyms] of Object.entries(ACTION_VERB_ALIASES)) {
if (lowerName.includes(verb)) {
for (const synonym of synonyms) {
aliases.push(lowerName.replace(verb, synonym))
}
}
}
return aliases
}
/**
* Extracts the operation dropdown subblock from a block's configuration.
* Returns null if no operation dropdown exists.
*/
function findOperationDropdown(block: BlockConfig): SubBlockConfig | null {
return (
block.subBlocks.find(
(sb) => sb.id === 'operation' && sb.type === 'dropdown' && Array.isArray(sb.options)
) ?? null
)
}
/**
* Resolves the tool ID for a given operation using the block's tool config.
* Falls back to checking tools.access if no config.tool function exists.
*/
function resolveToolId(block: BlockConfig, operationId: string): string | null {
if (!block.tools) return null
if (block.tools.config?.tool) {
try {
return block.tools.config.tool({ operation: operationId })
} catch {
return null
}
}
if (block.tools.access?.length === 1) {
return block.tools.access[0]
}
return null
}
/**
* Builds an index of all tool operations from the block registry.
* This index is used by the search modal to enable operation-level discovery.
*
* The function iterates through all blocks that have:
* 1. A tools.access array (indicating they use tools)
* 2. An "operation" dropdown subblock with options
*
* For each operation option, it creates a ToolOperationItem that maps
* the operation to its corresponding tool.
*/
export function buildToolOperationsIndex(): ToolOperationItem[] {
const operations: ToolOperationItem[] = []
const allBlocks = getAllBlocks()
for (const block of allBlocks) {
if (!block.tools?.access?.length || block.hideFromToolbar) {
continue
}
if (block.category !== 'tools') {
continue
}
const operationDropdown = findOperationDropdown(block)
if (!operationDropdown) {
continue
}
const options =
typeof operationDropdown.options === 'function'
? operationDropdown.options()
: operationDropdown.options
if (!options) continue
for (const option of options) {
if (!resolveToolId(block, option.id)) continue
const operationName = option.label
const aliases = generateAliases(operationName)
operations.push({
id: `${block.type}_${option.id}`,
blockType: block.type,
operationId: option.id,
serviceName: block.name,
operationName,
icon: block.icon,
bgColor: block.bgColor,
aliases,
})
}
}
return operations
}
/**
* Cached operations index to avoid rebuilding on every search.
* The index is built lazily on first access.
*/
let cachedOperations: ToolOperationItem[] | null = null
/**
* Returns the tool operations index, building it if necessary.
* The index is cached after first build since block registry is static.
*/
export function getToolOperationsIndex(): ToolOperationItem[] {
if (!cachedOperations) {
cachedOperations = buildToolOperationsIndex()
}
return cachedOperations
}
/**
* Clears the cached operations index.
* Useful for testing or if blocks are dynamically modified.
*/
export function clearToolOperationsCache(): void {
cachedOperations = null
}

View File

@@ -269,11 +269,12 @@ function sanitizeSubBlocks(
}
/**
* Convert internal condition handle (condition-{uuid}) to semantic format (condition-{blockId}-if)
* Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else)
* Uses 0-indexed numbering for else-if conditions
*/
function convertConditionHandleToSemantic(
function convertConditionHandleToSimple(
handle: string,
blockId: string,
_blockId: string,
block: BlockState
): string {
if (!handle.startsWith('condition-')) {
@@ -300,27 +301,24 @@ function convertConditionHandleToSemantic(
return handle
}
// Find the condition by ID and generate semantic handle
let elseIfCount = 0
// Find the condition by ID and generate simple handle
let elseIfIndex = 0
for (const condition of conditions) {
const title = condition.title?.toLowerCase()
if (condition.id === conditionId) {
if (title === 'if') {
return `condition-${blockId}-if`
return 'if'
}
if (title === 'else if') {
elseIfCount++
return elseIfCount === 1
? `condition-${blockId}-else-if`
: `condition-${blockId}-else-if-${elseIfCount}`
return `else-if-${elseIfIndex}`
}
if (title === 'else') {
return `condition-${blockId}-else`
return 'else'
}
}
// Count else-ifs as we iterate
// Count else-ifs as we iterate (for index tracking)
if (title === 'else if') {
elseIfCount++
elseIfIndex++
}
}
@@ -329,9 +327,10 @@ function convertConditionHandleToSemantic(
}
/**
* Convert internal router handle (router-{uuid}) to semantic format (router-{blockId}-route-N)
* Convert internal router handle (router-{uuid}) to simple format (route-0, route-1)
* Uses 0-indexed numbering for routes
*/
function convertRouterHandleToSemantic(handle: string, blockId: string, block: BlockState): string {
function convertRouterHandleToSimple(handle: string, _blockId: string, block: BlockState): string {
if (!handle.startsWith('router-')) {
return handle
}
@@ -356,10 +355,10 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
return handle
}
// Find the route by ID and generate semantic handle (1-indexed)
// Find the route by ID and generate simple handle (0-indexed)
for (let i = 0; i < routes.length; i++) {
if (routes[i].id === routeId) {
return `router-${blockId}-route-${i + 1}`
return `route-${i}`
}
}
@@ -368,15 +367,16 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
}
/**
* Convert source handle to semantic format for condition and router blocks
* Convert source handle to simple format for condition and router blocks
* Outputs: if, else-if-0, else (for conditions) and route-0, route-1 (for routers)
*/
function convertToSemanticHandle(handle: string, blockId: string, block: BlockState): string {
function convertToSimpleHandle(handle: string, blockId: string, block: BlockState): string {
if (handle.startsWith('condition-') && block.type === 'condition') {
return convertConditionHandleToSemantic(handle, blockId, block)
return convertConditionHandleToSimple(handle, blockId, block)
}
if (handle.startsWith('router-') && block.type === 'router_v2') {
return convertRouterHandleToSemantic(handle, blockId, block)
return convertRouterHandleToSimple(handle, blockId, block)
}
return handle
@@ -400,12 +400,12 @@ function extractConnectionsForBlock(
return undefined
}
// Group by source handle (converting to semantic format)
// Group by source handle (converting to simple format)
for (const edge of outgoingEdges) {
let handle = edge.sourceHandle || 'source'
// Convert internal UUID handles to semantic format
handle = convertToSemanticHandle(handle, blockId, block)
// Convert internal UUID handles to simple format (if, else-if-0, route-0, etc.)
handle = convertToSimpleHandle(handle, blockId, block)
if (!connections[handle]) {
connections[handle] = []

View File

@@ -163,12 +163,13 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
try {
const errorMessage = String(newEntry.error)
const blockName = newEntry.blockName || 'Unknown Block'
const displayMessage = `${blockName}: ${errorMessage}`
const copilotMessage = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
useNotificationStore.getState().addNotification({
level: 'error',
message: errorMessage,
message: displayMessage,
workflowId: entry.workflowId,
action: {
type: 'copilot',

View File

@@ -0,0 +1,394 @@
/**
* @vitest-environment node
*/
import type { Edge } from 'reactflow'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import type { BlockState } from '@/stores/workflows/workflow/types'
vi.mock('@/stores/workflows/utils', () => ({
mergeSubblockState: vi.fn(),
}))
import { mergeSubblockState } from '@/stores/workflows/utils'
import { captureLatestEdges, captureLatestSubBlockValues } from './utils'
const mockMergeSubblockState = mergeSubblockState as Mock
describe('captureLatestEdges', () => {
const createEdge = (id: string, source: string, target: string): Edge => ({
id,
source,
target,
})
it('should return edges where blockId is the source', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-1'])
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
})
it('should return edges where blockId is the target', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-2'])
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
})
it('should return edges for multiple blocks', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
createEdge('edge-3', 'block-2', 'block-5'),
]
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
expect(result).toHaveLength(2)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
expect(result).toContainEqual(createEdge('edge-3', 'block-2', 'block-5'))
})
it('should return empty array when no edges match', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-99'])
expect(result).toEqual([])
})
it('should return empty array when blockIds is empty', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, [])
expect(result).toEqual([])
})
it('should return edge when block has both source and target edges', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-2', 'block-3'),
createEdge('edge-3', 'block-4', 'block-2'),
]
const result = captureLatestEdges(edges, ['block-2'])
expect(result).toHaveLength(3)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
expect(result).toContainEqual(createEdge('edge-2', 'block-2', 'block-3'))
expect(result).toContainEqual(createEdge('edge-3', 'block-4', 'block-2'))
})
it('should handle empty edges array', () => {
const result = captureLatestEdges([], ['block-1'])
expect(result).toEqual([])
})
it('should not duplicate edges when block appears in multiple blockIds', () => {
const edges = [createEdge('edge-1', 'block-1', 'block-2')]
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
expect(result).toHaveLength(1)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
})
})
describe('captureLatestSubBlockValues', () => {
const workflowId = 'wf-test'
const createBlockState = (
id: string,
subBlocks: Record<string, { id: string; type: string; value: unknown }>
): BlockState =>
({
id,
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
subBlocks: Object.fromEntries(
Object.entries(subBlocks).map(([subId, sb]) => [
subId,
{ id: sb.id, type: sb.type, value: sb.value },
])
),
outputs: {},
enabled: true,
}) as BlockState
beforeEach(() => {
vi.clearAllMocks()
})
it('should capture single block with single subblock value', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'console.log("hello")' },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'console.log("hello")' },
})
})
it('should capture single block with multiple subblock values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test code' },
model: { id: 'model', type: 'dropdown', value: 'gpt-4' },
temperature: { id: 'temperature', type: 'slider', value: 0.7 },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': {
code: 'test code',
model: 'gpt-4',
temperature: 0.7,
},
})
})
it('should capture multiple blocks with values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'code 1' },
}),
'block-2': createBlockState('block-2', {
prompt: { id: 'prompt', type: 'long-input', value: 'hello world' },
}),
}
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
if (blockId === 'block-2') return { 'block-2': blocks['block-2'] }
return {}
})
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-2'])
expect(result).toEqual({
'block-1': { code: 'code 1' },
'block-2': { prompt: 'hello world' },
})
})
it('should skip null values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'valid code' },
empty: { id: 'empty', type: 'short-input', value: null },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'valid code' },
})
expect(result['block-1']).not.toHaveProperty('empty')
})
it('should skip undefined values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'valid code' },
empty: { id: 'empty', type: 'short-input', value: undefined },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'valid code' },
})
})
it('should return empty object for block with no subBlocks', () => {
const blocks: Record<string, BlockState> = {
'block-1': {
id: 'block-1',
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
} as BlockState,
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({})
})
it('should return empty object for non-existent blockId', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test' },
}),
}
mockMergeSubblockState.mockReturnValue({})
const result = captureLatestSubBlockValues(blocks, workflowId, ['non-existent'])
expect(result).toEqual({})
})
it('should return empty object when blockIds is empty', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test' },
}),
}
const result = captureLatestSubBlockValues(blocks, workflowId, [])
expect(result).toEqual({})
expect(mockMergeSubblockState).not.toHaveBeenCalled()
})
it('should handle various value types (string, number, array)', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
text: { id: 'text', type: 'short-input', value: 'string value' },
number: { id: 'number', type: 'slider', value: 42 },
array: {
id: 'array',
type: 'table',
value: [
['a', 'b'],
['c', 'd'],
],
},
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': {
text: 'string value',
number: 42,
array: [
['a', 'b'],
['c', 'd'],
],
},
})
})
it('should only capture values for blockIds in the list', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'code 1' },
}),
'block-2': createBlockState('block-2', {
code: { id: 'code', type: 'code', value: 'code 2' },
}),
'block-3': createBlockState('block-3', {
code: { id: 'code', type: 'code', value: 'code 3' },
}),
}
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
if (blockId === 'block-3') return { 'block-3': blocks['block-3'] }
return {}
})
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-3'])
expect(result).toEqual({
'block-1': { code: 'code 1' },
'block-3': { code: 'code 3' },
})
expect(result).not.toHaveProperty('block-2')
})
it('should handle block without subBlocks property', () => {
const blocks: Record<string, BlockState> = {
'block-1': {
id: 'block-1',
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
outputs: {},
enabled: true,
} as BlockState,
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({})
})
it('should handle empty string values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: '' },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: '' },
})
})
it('should handle zero numeric values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
temperature: { id: 'temperature', type: 'slider', value: 0 },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { temperature: 0 },
})
})
})

View File

@@ -1,3 +1,4 @@
import type { Edge } from 'reactflow'
import { UNDO_REDO_OPERATIONS } from '@/socket/constants'
import type {
BatchAddBlocksOperation,
@@ -9,6 +10,8 @@ import type {
Operation,
OperationEntry,
} from '@/stores/undo-redo/types'
import { mergeSubblockState } from '@/stores/workflows/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
return {
@@ -170,3 +173,31 @@ export function createInverseOperation(operation: Operation): Operation {
}
}
}
export function captureLatestEdges(edges: Edge[], blockIds: string[]): Edge[] {
return edges.filter((e) => blockIds.includes(e.source) || blockIds.includes(e.target))
}
export function captureLatestSubBlockValues(
blocks: Record<string, BlockState>,
workflowId: string,
blockIds: string[]
): Record<string, Record<string, unknown>> {
const values: Record<string, Record<string, unknown>> = {}
blockIds.forEach((blockId) => {
const merged = mergeSubblockState(blocks, workflowId, blockId)
const block = merged[blockId]
if (block?.subBlocks) {
const blockValues: Record<string, unknown> = {}
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
blockValues[subBlockId] = subBlock.value
}
})
if (Object.keys(blockValues).length > 0) {
values[blockId] = blockValues
}
}
})
return values
}

View File

@@ -46,11 +46,11 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
},
},
request: {
url: 'https://api.browser-use.com/api/v1/run-task',
url: 'https://api.browser-use.com/api/v2/tasks',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Bearer ${params.apiKey}`,
'X-Browser-Use-API-Key': params.apiKey,
}),
body: (params) => {
const requestBody: Record<string, any> = {
@@ -121,12 +121,15 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
let liveUrlLogged = false
try {
const initialTaskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${params.apiKey}`,
},
})
const initialTaskResponse = await fetch(
`https://api.browser-use.com/api/v2/tasks/${taskId}`,
{
method: 'GET',
headers: {
'X-Browser-Use-API-Key': params.apiKey,
},
}
)
if (initialTaskResponse.ok) {
const initialTaskData = await initialTaskResponse.json()
@@ -145,60 +148,36 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
while (elapsedTime < MAX_POLL_TIME_MS) {
try {
const statusResponse = await fetch(
`https://api.browser-use.com/api/v1/task/${taskId}/status`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${params.apiKey}`,
},
}
)
const statusResponse = await fetch(`https://api.browser-use.com/api/v2/tasks/${taskId}`, {
method: 'GET',
headers: {
'X-Browser-Use-API-Key': params.apiKey,
},
})
if (!statusResponse.ok) {
throw new Error(`Failed to get task status: ${statusResponse.statusText}`)
}
const status = await statusResponse.json()
const taskData = await statusResponse.json()
const status = taskData.status
logger.info(`BrowserUse task ${taskId} status: ${status}`)
if (['finished', 'failed', 'stopped'].includes(status)) {
const taskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${params.apiKey}`,
},
})
if (taskResponse.ok) {
const taskData = await taskResponse.json()
result.output = {
id: taskId,
success: status === 'finished',
output: taskData.output,
steps: taskData.steps || [],
}
result.output = {
id: taskId,
success: status === 'finished',
output: taskData.output ?? null,
steps: taskData.steps || [],
}
return result
}
if (!liveUrlLogged && status === 'running') {
const taskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${params.apiKey}`,
},
})
if (taskResponse.ok) {
const taskData = await taskResponse.json()
if (taskData.live_url) {
logger.info(`BrowserUse task ${taskId} running with live URL: ${taskData.live_url}`)
liveUrlLogged = true
}
}
if (!liveUrlLogged && status === 'running' && taskData.live_url) {
logger.info(`BrowserUse task ${taskId} running with live URL: ${taskData.live_url}`)
liveUrlLogged = true
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))

View File

@@ -59,7 +59,7 @@ export const calendlyInviteeCanceledTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'The webhook will be automatically created in Calendly when you deploy the workflow.',
'This webhook triggers when an invitee cancels an event. The payload includes cancellation details and reason.',
]
.map(

View File

@@ -68,7 +68,7 @@ export const calendlyInviteeCreatedTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'The webhook will be automatically created in Calendly when you deploy the workflow.',
'This webhook triggers when an invitee schedules a new event. Rescheduling triggers both cancellation and creation events.',
]
.map(

View File

@@ -59,7 +59,7 @@ export const calendlyRoutingFormSubmittedTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'The webhook will be automatically created in Calendly when you deploy the workflow.',
'This webhook triggers when someone submits a routing form, regardless of whether they book an event.',
]
.map(

View File

@@ -58,7 +58,7 @@ export const calendlyWebhookTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'The webhook will be automatically created in Calendly when you deploy the workflow.',
'This webhook subscribes to all Calendly events (invitee created, invitee canceled, and routing form submitted). Use the <code>event</code> field in the payload to determine the event type.',
]
.map(

View File

@@ -186,7 +186,7 @@ export const stripeWebhookTrigger: TriggerConfig = {
'Click "Create Destination" to save',
'After creating the endpoint, click "Reveal" next to "Signing secret" and copy it',
'Paste the signing secret into the <strong>Webhook Signing Secret</strong> field above',
'Click "Save" to activate your webhook trigger',
'Deploy your workflow to activate the webhook trigger',
]
.map(
(instruction, index) =>

View File

@@ -46,7 +46,7 @@ export const telegramWebhookTrigger: TriggerConfig = {
defaultValue: [
'Message "/newbot" to <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">@BotFather</a> in Telegram to create a bot and copy its token.',
'Enter your Bot Token above.',
'Save settings and any message sent to your bot will trigger the workflow.',
'Any message sent to your bot will trigger the workflow once deployed.',
]
.map(
(instruction, index) =>

View File

@@ -79,7 +79,7 @@ export const typeformWebhookTrigger: TriggerConfig = {
'Find your Form ID in the URL when editing your form (e.g., <code>https://admin.typeform.com/form/ABC123/create</code> → Form ID is <code>ABC123</code>)',
'Fill in the form above with your Form ID and Personal Access Token',
'Optionally add a Webhook Secret for enhanced security - Sim will verify all incoming webhooks match this secret',
'Click "Save" above - Sim will automatically register the webhook with Typeform',
'Sim will automatically register the webhook with Typeform when you deploy the workflow',
'<strong>Note:</strong> Requires a Typeform PRO or PRO+ account to use webhooks',
]
.map(

View File

@@ -10,6 +10,7 @@
"@octokit/rest": "^21.0.0",
"@tailwindcss/typography": "0.5.19",
"drizzle-kit": "^0.31.4",
"glob": "13.0.0",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.7.4",
@@ -2237,7 +2238,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@@ -2539,7 +2540,7 @@
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"lru.min": ["lru.min@1.1.3", "", {}, "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q=="],
@@ -2699,7 +2700,7 @@
"minimal-polyfills": ["minimal-polyfills@2.2.3", "", {}, "sha512-oxdmJ9cL+xV72h0xYxp4tP2d5/fTBpP45H8DIOn9pASuF8a3IYTf+25fMGDYGiWW+MFsuog6KD6nfmhZJQ+uUw=="],
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -3691,6 +3692,8 @@
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@better-auth/sso/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
@@ -3953,6 +3956,8 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"e2b/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"engine.io/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
"engine.io/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
@@ -3993,8 +3998,6 @@
"get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"groq-sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
@@ -4043,8 +4046,6 @@
"log-update/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
@@ -4083,8 +4084,6 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"pdf-lib/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"pino/thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
@@ -4113,6 +4112,8 @@
"react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"react-email/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
@@ -4171,6 +4172,8 @@
"test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"thriftrw/long": ["long@2.4.0", "", {}, "sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
@@ -4249,6 +4252,8 @@
"@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.969.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-BSe4Lx/qdRQQdX8cSSI7Et20vqBspzAjBy8ZmXVoyLkol3y4sXBXzn+BiLtR+oh60ExQn6o2DU4QjdOZbXaKIQ=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@browserbasehq/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@browserbasehq/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
@@ -4579,6 +4584,8 @@
"rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"rimraf/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"sim/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],

View File

@@ -39,6 +39,7 @@
"@octokit/rest": "^21.0.0",
"@tailwindcss/typography": "0.5.19",
"drizzle-kit": "^0.31.4",
"glob": "13.0.0",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.7.4"