mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-20 04:17:57 -05:00
Compare commits
13 Commits
fix/termin
...
feat/super
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
526b7a64f6 | ||
|
|
9da689bc8e | ||
|
|
e1bea05de0 | ||
|
|
5f45db4343 | ||
|
|
81cbfe7af4 | ||
|
|
739341b08e | ||
|
|
3c43779ba3 | ||
|
|
1861f77283 | ||
|
|
72c2ba7443 | ||
|
|
037dad6975 | ||
|
|
408597e12b | ||
|
|
932f8fd654 | ||
|
|
b4c2294e67 |
@@ -86,27 +86,112 @@ export async function GET(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
.limit(candidateLimit)
|
.limit(candidateLimit)
|
||||||
|
|
||||||
const seenIds = new Set<string>()
|
const knownLocales = ['en', 'es', 'fr', 'de', 'ja', 'zh']
|
||||||
const mergedResults = []
|
|
||||||
|
|
||||||
for (let i = 0; i < Math.max(vectorResults.length, keywordResults.length); i++) {
|
const vectorRankMap = new Map<string, number>()
|
||||||
if (i < vectorResults.length && !seenIds.has(vectorResults[i].chunkId)) {
|
vectorResults.forEach((r, idx) => vectorRankMap.set(r.chunkId, idx + 1))
|
||||||
mergedResults.push(vectorResults[i])
|
|
||||||
seenIds.add(vectorResults[i].chunkId)
|
const keywordRankMap = new Map<string, number>()
|
||||||
}
|
keywordResults.forEach((r, idx) => keywordRankMap.set(r.chunkId, idx + 1))
|
||||||
if (i < keywordResults.length && !seenIds.has(keywordResults[i].chunkId)) {
|
|
||||||
mergedResults.push(keywordResults[i])
|
const allChunkIds = new Set([
|
||||||
seenIds.add(keywordResults[i].chunkId)
|
...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)
|
scoredResults.sort((a, b) => b.rrfScore - a.rrfScore)
|
||||||
const searchResults = filteredResults.map((result) => {
|
|
||||||
|
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 title = result.headerText || result.sourceDocument.replace('.mdx', '')
|
||||||
|
|
||||||
const pathParts = result.sourceDocument
|
const pathParts = result.sourceDocument
|
||||||
.replace('.mdx', '')
|
.replace('.mdx', '')
|
||||||
.split('/')
|
.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 {
|
return {
|
||||||
id: result.chunkId,
|
id: result.chunkId,
|
||||||
|
|||||||
@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
{...props}
|
{...props}
|
||||||
version='1.0'
|
version='1.0'
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
width='150pt'
|
width='28'
|
||||||
height='150pt'
|
height='28'
|
||||||
viewBox='0 0 150 150'
|
viewBox='0 0 150 150'
|
||||||
preserveAspectRatio='xMidYMid meet'
|
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
|
<path
|
||||||
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
|
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
|
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
|||||||
|
|
||||||
<BlockInfoCard
|
<BlockInfoCard
|
||||||
type="browser_use"
|
type="browser_use"
|
||||||
color="#E0E0E0"
|
color="#181C1E"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MANUAL-CONTENT-START:intro */}
|
{/* MANUAL-CONTENT-START:intro */}
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ Read content from a Google Slides presentation
|
|||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `slides` | json | Array of slides with their content |
|
| `slides` | json | Array of slides with their content |
|
||||||
| `metadata` | json | Presentation metadata including ID, title, and URL |
|
| `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`
|
### `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 |
|
| `updatedContent` | boolean | Indicates if presentation content was updated successfully |
|
||||||
| `metadata` | json | Updated presentation metadata including ID, title, and URL |
|
| `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`
|
### `google_slides_create`
|
||||||
|
|
||||||
@@ -90,6 +103,10 @@ Create a new Google Slides presentation
|
|||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `metadata` | json | Created presentation metadata including ID, title, and URL |
|
| `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`
|
### `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 |
|
| `occurrencesChanged` | number | Number of text occurrences that were replaced |
|
||||||
| `metadata` | json | Operation metadata including presentation ID and URL |
|
| `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`
|
### `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 |
|
| `slideId` | string | The object ID of the newly created slide |
|
||||||
| `metadata` | json | Operation metadata including presentation ID, layout, and URL |
|
| `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`
|
### `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 |
|
| `imageId` | string | The object ID of the newly created image |
|
||||||
| `metadata` | json | Operation metadata including presentation ID and image URL |
|
| `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`
|
### `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 |
|
| `width` | number | Width of the thumbnail in pixels |
|
||||||
| `height` | number | Height of the thumbnail in pixels |
|
| `height` | number | Height of the thumbnail in pixels |
|
||||||
| `metadata` | json | Operation metadata including presentation ID and page object ID |
|
| `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`
|
### `google_slides_get_page`
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { templateCreators, user } from '@sim/db/schema'
|
import { templateCreators } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
||||||
|
|
||||||
const logger = createLogger('CreatorVerificationAPI')
|
const logger = createLogger('CreatorVerificationAPI')
|
||||||
|
|
||||||
@@ -23,9 +24,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is a super user
|
// Check if user is a super user
|
||||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
|
if (!effectiveSuperUser) {
|
||||||
if (!currentUser[0]?.isSuperUser) {
|
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 })
|
||||||
}
|
}
|
||||||
@@ -76,9 +76,8 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is a super user
|
// Check if user is a super user
|
||||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
|
if (!effectiveSuperUser) {
|
||||||
if (!currentUser[0]?.isSuperUser) {
|
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|||||||
193
apps/sim/app/api/superuser/import-workflow/route.ts
Normal file
193
apps/sim/app/api/superuser/import-workflow/route.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { copilotChats, workflow, workspace } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
||||||
|
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
|
||||||
|
import {
|
||||||
|
loadWorkflowFromNormalizedTables,
|
||||||
|
saveWorkflowToNormalizedTables,
|
||||||
|
} from '@/lib/workflows/persistence/utils'
|
||||||
|
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||||
|
|
||||||
|
const logger = createLogger('SuperUserImportWorkflow')
|
||||||
|
|
||||||
|
interface ImportWorkflowRequest {
|
||||||
|
workflowId: string
|
||||||
|
targetWorkspaceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/superuser/import-workflow
|
||||||
|
*
|
||||||
|
* Superuser endpoint to import a workflow by ID along with its copilot chats.
|
||||||
|
* This creates a copy of the workflow in the target workspace with new IDs.
|
||||||
|
* Only the workflow structure and copilot chats are copied - no deployments,
|
||||||
|
* webhooks, triggers, or other sensitive data.
|
||||||
|
*
|
||||||
|
* Requires both isSuperUser flag AND superUserModeEnabled setting.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { effectiveSuperUser, isSuperUser, superUserModeEnabled } =
|
||||||
|
await verifyEffectiveSuperUser(session.user.id)
|
||||||
|
|
||||||
|
if (!effectiveSuperUser) {
|
||||||
|
logger.warn('Non-effective-superuser attempted to access import-workflow endpoint', {
|
||||||
|
userId: session.user.id,
|
||||||
|
isSuperUser,
|
||||||
|
superUserModeEnabled,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ error: 'Forbidden: Superuser access required' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: ImportWorkflowRequest = await request.json()
|
||||||
|
const { workflowId, targetWorkspaceId } = body
|
||||||
|
|
||||||
|
if (!workflowId) {
|
||||||
|
return NextResponse.json({ error: 'workflowId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetWorkspaceId) {
|
||||||
|
return NextResponse.json({ error: 'targetWorkspaceId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify target workspace exists
|
||||||
|
const [targetWorkspace] = await db
|
||||||
|
.select({ id: workspace.id, ownerId: workspace.ownerId })
|
||||||
|
.from(workspace)
|
||||||
|
.where(eq(workspace.id, targetWorkspaceId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!targetWorkspace) {
|
||||||
|
return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the source workflow
|
||||||
|
const [sourceWorkflow] = await db
|
||||||
|
.select()
|
||||||
|
.from(workflow)
|
||||||
|
.where(eq(workflow.id, workflowId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!sourceWorkflow) {
|
||||||
|
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the workflow state from normalized tables
|
||||||
|
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||||
|
|
||||||
|
if (!normalizedData) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Workflow has no normalized data - cannot import' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing export logic to create export format
|
||||||
|
const workflowState = {
|
||||||
|
blocks: normalizedData.blocks,
|
||||||
|
edges: normalizedData.edges,
|
||||||
|
loops: normalizedData.loops,
|
||||||
|
parallels: normalizedData.parallels,
|
||||||
|
metadata: {
|
||||||
|
name: sourceWorkflow.name,
|
||||||
|
description: sourceWorkflow.description ?? undefined,
|
||||||
|
color: sourceWorkflow.color,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportData = sanitizeForExport(workflowState)
|
||||||
|
|
||||||
|
// Use existing import logic (parseWorkflowJson regenerates IDs automatically)
|
||||||
|
const { data: importedData, errors } = parseWorkflowJson(JSON.stringify(exportData))
|
||||||
|
|
||||||
|
if (!importedData || errors.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to parse workflow: ${errors.join(', ')}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new workflow record
|
||||||
|
const newWorkflowId = crypto.randomUUID()
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
await db.insert(workflow).values({
|
||||||
|
id: newWorkflowId,
|
||||||
|
userId: session.user.id,
|
||||||
|
workspaceId: targetWorkspaceId,
|
||||||
|
folderId: null, // Don't copy folder association
|
||||||
|
name: `[Debug Import] ${sourceWorkflow.name}`,
|
||||||
|
description: sourceWorkflow.description,
|
||||||
|
color: sourceWorkflow.color,
|
||||||
|
lastSynced: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
isDeployed: false, // Never copy deployment status
|
||||||
|
runCount: 0,
|
||||||
|
variables: sourceWorkflow.variables || {},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save using existing persistence logic
|
||||||
|
const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData)
|
||||||
|
|
||||||
|
if (!saveResult.success) {
|
||||||
|
// Clean up the workflow record if save failed
|
||||||
|
await db.delete(workflow).where(eq(workflow.id, newWorkflowId))
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to save workflow state: ${saveResult.error}` },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy copilot chats associated with the source workflow
|
||||||
|
const sourceCopilotChats = await db
|
||||||
|
.select()
|
||||||
|
.from(copilotChats)
|
||||||
|
.where(eq(copilotChats.workflowId, workflowId))
|
||||||
|
|
||||||
|
let copilotChatsImported = 0
|
||||||
|
|
||||||
|
for (const chat of sourceCopilotChats) {
|
||||||
|
await db.insert(copilotChats).values({
|
||||||
|
userId: session.user.id,
|
||||||
|
workflowId: newWorkflowId,
|
||||||
|
title: chat.title ? `[Import] ${chat.title}` : null,
|
||||||
|
messages: chat.messages,
|
||||||
|
model: chat.model,
|
||||||
|
conversationId: null, // Don't copy conversation ID
|
||||||
|
previewYaml: chat.previewYaml,
|
||||||
|
planArtifact: chat.planArtifact,
|
||||||
|
config: chat.config,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
copilotChatsImported++
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Superuser imported workflow', {
|
||||||
|
userId: session.user.id,
|
||||||
|
sourceWorkflowId: workflowId,
|
||||||
|
newWorkflowId,
|
||||||
|
targetWorkspaceId,
|
||||||
|
copilotChatsImported,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
newWorkflowId,
|
||||||
|
copilotChatsImported,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error importing workflow', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
||||||
|
|
||||||
const logger = createLogger('TemplateApprovalAPI')
|
const logger = createLogger('TemplateApprovalAPI')
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
if (!isSuperUser) {
|
if (!effectiveSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
@@ -71,8 +71,8 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
if (!isSuperUser) {
|
if (!effectiveSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
||||||
|
|
||||||
const logger = createLogger('TemplateRejectionAPI')
|
const logger = createLogger('TemplateRejectionAPI')
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
if (!isSuperUser) {
|
if (!effectiveSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
templateCreators,
|
templateCreators,
|
||||||
templateStars,
|
templateStars,
|
||||||
templates,
|
templates,
|
||||||
user,
|
|
||||||
workflow,
|
workflow,
|
||||||
workflowDeploymentVersion,
|
workflowDeploymentVersion,
|
||||||
} from '@sim/db/schema'
|
} from '@sim/db/schema'
|
||||||
@@ -14,6 +13,7 @@ import { v4 as uuidv4 } from 'uuid'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
||||||
import {
|
import {
|
||||||
extractRequiredCredentials,
|
extractRequiredCredentials,
|
||||||
sanitizeCredentials,
|
sanitizeCredentials,
|
||||||
@@ -70,8 +70,8 @@ export async function GET(request: NextRequest) {
|
|||||||
logger.debug(`[${requestId}] Fetching templates with params:`, params)
|
logger.debug(`[${requestId}] Fetching templates with params:`, params)
|
||||||
|
|
||||||
// Check if user is a super user
|
// Check if user is a super user
|
||||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
const isSuperUser = effectiveSuperUser
|
||||||
|
|
||||||
// Build query conditions
|
// Build query conditions
|
||||||
const conditions = []
|
const conditions = []
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, useEffect, useState } from 'react'
|
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 { useSearchParams } from 'next/navigation'
|
||||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { useBrandConfig } from '@/lib/branding/branding'
|
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 {
|
interface UnsubscribeData {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -27,7 +30,6 @@ function UnsubscribeContent() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [processing, setProcessing] = useState(false)
|
const [processing, setProcessing] = useState(false)
|
||||||
const [unsubscribed, setUnsubscribed] = useState(false)
|
const [unsubscribed, setUnsubscribed] = useState(false)
|
||||||
const brand = useBrandConfig()
|
|
||||||
|
|
||||||
const email = searchParams.get('email')
|
const email = searchParams.get('email')
|
||||||
const token = searchParams.get('token')
|
const token = searchParams.get('token')
|
||||||
@@ -109,7 +111,7 @@ function UnsubscribeContent() {
|
|||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Failed to unsubscribe')
|
setError(result.error || 'Failed to unsubscribe')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
setError('Failed to process unsubscribe request')
|
setError('Failed to process unsubscribe request')
|
||||||
} finally {
|
} finally {
|
||||||
setProcessing(false)
|
setProcessing(false)
|
||||||
@@ -118,272 +120,171 @@ function UnsubscribeContent() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
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'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardContent className='flex items-center justify-center p-8'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
Loading
|
||||||
</CardContent>
|
</h1>
|
||||||
</Card>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
</div>
|
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) {
|
if (error) {
|
||||||
return (
|
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'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardHeader className='text-center'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
Invalid Unsubscribe Link
|
||||||
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
|
</h1>
|
||||||
<CardDescription className='text-muted-foreground'>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
This unsubscribe link is invalid or has expired
|
{error}
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className='space-y-3'>
|
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||||
<p className='text-muted-foreground text-sm'>This could happen if:</p>
|
<BrandedButton onClick={() => window.history.back()}>Go Back</BrandedButton>
|
||||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
</div>
|
||||||
<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='mt-6 flex flex-col gap-3'>
|
<SupportFooter position='absolute' />
|
||||||
<Button
|
</InviteLayout>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.isTransactional) {
|
if (data?.isTransactional) {
|
||||||
return (
|
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'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardHeader className='text-center'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
|
Important Account Emails
|
||||||
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
|
</h1>
|
||||||
<CardDescription className='text-muted-foreground'>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
This email contains important information about your account
|
Transactional emails like password resets, account confirmations, and security alerts
|
||||||
</CardDescription>
|
cannot be unsubscribed from as they contain essential information for your account.
|
||||||
</CardHeader>
|
</p>
|
||||||
<CardContent className='space-y-4'>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className='space-y-3'>
|
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||||
<p className='text-foreground text-sm'>
|
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||||
If you no longer wish to receive these emails, you can:
|
</div>
|
||||||
</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='mt-6 flex flex-col gap-3'>
|
<SupportFooter position='absolute' />
|
||||||
<Button
|
</InviteLayout>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unsubscribed) {
|
if (unsubscribed) {
|
||||||
return (
|
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'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardHeader className='text-center'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
|
Successfully Unsubscribed
|
||||||
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
|
</h1>
|
||||||
<CardDescription className='text-muted-foreground'>
|
<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
|
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||||
hours.
|
hours.
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className='text-center'>
|
|
||||||
<p className='text-muted-foreground text-sm'>
|
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||||
If you change your mind, you can always update your email preferences in your account
|
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||||
settings or contact us at{' '}
|
</div>
|
||||||
<a
|
|
||||||
href={`mailto:${brand.supportEmail}`}
|
<SupportFooter position='absolute' />
|
||||||
className='text-muted-foreground hover:underline'
|
</InviteLayout>
|
||||||
>
|
|
||||||
{brand.supportEmail}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAlreadyUnsubscribedFromAll = data?.currentPreferences.unsubscribeAll
|
||||||
|
|
||||||
return (
|
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'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardHeader className='text-center'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
Email Preferences
|
||||||
<CardTitle className='text-foreground'>We're sorry to see you go!</CardTitle>
|
</h1>
|
||||||
<CardDescription className='text-muted-foreground'>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
We understand email preferences are personal. Choose which emails you'd like to
|
Choose which emails you'd like to stop receiving.
|
||||||
stop receiving from Sim.
|
</p>
|
||||||
</CardDescription>
|
<p className={`${inter.className} mt-2 font-[380] text-[14px] text-muted-foreground`}>
|
||||||
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
|
{data?.email}
|
||||||
<p className='text-muted-foreground text-xs'>
|
</p>
|
||||||
Email: <span className='font-medium text-foreground'>{data?.email}</span>
|
</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className='text-center text-muted-foreground text-sm'>
|
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||||
or choose specific types:
|
<BrandedButton
|
||||||
</div>
|
onClick={() => handleUnsubscribe('all')}
|
||||||
|
disabled={processing || isAlreadyUnsubscribedFromAll}
|
||||||
|
loading={processing}
|
||||||
|
loadingText='Unsubscribing'
|
||||||
|
>
|
||||||
|
{isAlreadyUnsubscribedFromAll
|
||||||
|
? 'Unsubscribed from All Emails'
|
||||||
|
: 'Unsubscribe from All Marketing Emails'}
|
||||||
|
</BrandedButton>
|
||||||
|
|
||||||
<Button
|
<div className='py-2 text-center'>
|
||||||
onClick={() => handleUnsubscribe('marketing')}
|
<span className={`${inter.className} font-[380] text-[14px] text-muted-foreground`}>
|
||||||
disabled={
|
or choose specific types
|
||||||
processing ||
|
</span>
|
||||||
data?.currentPreferences.unsubscribeAll ||
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
onClick={() => handleUnsubscribe('updates')}
|
onClick={() => handleUnsubscribe('marketing')}
|
||||||
disabled={
|
disabled={
|
||||||
processing ||
|
processing ||
|
||||||
data?.currentPreferences.unsubscribeAll ||
|
isAlreadyUnsubscribedFromAll ||
|
||||||
data?.currentPreferences.unsubscribeUpdates
|
data?.currentPreferences.unsubscribeMarketing
|
||||||
}
|
}
|
||||||
variant='outline'
|
>
|
||||||
className='w-full'
|
{data?.currentPreferences.unsubscribeMarketing
|
||||||
>
|
? 'Unsubscribed from Marketing'
|
||||||
{data?.currentPreferences.unsubscribeUpdates ? (
|
: 'Unsubscribe from Marketing Emails'}
|
||||||
<CheckCircle className='mr-2 h-4 w-4' />
|
</BrandedButton>
|
||||||
) : null}
|
|
||||||
{data?.currentPreferences.unsubscribeUpdates
|
|
||||||
? 'Unsubscribed from Updates'
|
|
||||||
: 'Unsubscribe from Product Updates'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
onClick={() => handleUnsubscribe('notifications')}
|
onClick={() => handleUnsubscribe('updates')}
|
||||||
disabled={
|
disabled={
|
||||||
processing ||
|
processing ||
|
||||||
data?.currentPreferences.unsubscribeAll ||
|
isAlreadyUnsubscribedFromAll ||
|
||||||
data?.currentPreferences.unsubscribeNotifications
|
data?.currentPreferences.unsubscribeUpdates
|
||||||
}
|
}
|
||||||
variant='outline'
|
>
|
||||||
className='w-full'
|
{data?.currentPreferences.unsubscribeUpdates
|
||||||
>
|
? 'Unsubscribed from Updates'
|
||||||
{data?.currentPreferences.unsubscribeNotifications ? (
|
: 'Unsubscribe from Product Updates'}
|
||||||
<CheckCircle className='mr-2 h-4 w-4' />
|
</BrandedButton>
|
||||||
) : null}
|
|
||||||
{data?.currentPreferences.unsubscribeNotifications
|
|
||||||
? 'Unsubscribed from Notifications'
|
|
||||||
: 'Unsubscribe from Notifications'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-6 space-y-3'>
|
<BrandedButton
|
||||||
<div className='rounded-lg border bg-muted/50 p-3'>
|
onClick={() => handleUnsubscribe('notifications')}
|
||||||
<p className='text-center text-muted-foreground text-xs'>
|
disabled={
|
||||||
<strong>Note:</strong> You'll continue receiving important account emails like
|
processing ||
|
||||||
password resets and security alerts.
|
isAlreadyUnsubscribedFromAll ||
|
||||||
</p>
|
data?.currentPreferences.unsubscribeNotifications
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
|
{data?.currentPreferences.unsubscribeNotifications
|
||||||
|
? 'Unsubscribed from Notifications'
|
||||||
|
: 'Unsubscribe from Notifications'}
|
||||||
|
</BrandedButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className='text-center text-muted-foreground text-xs'>
|
<div className={`${inter.className} mt-6 max-w-[410px] text-center`}>
|
||||||
Questions? Contact us at{' '}
|
<p className='font-[380] text-[13px] text-muted-foreground'>
|
||||||
<a
|
You'll continue receiving important account emails like password resets and security
|
||||||
href={`mailto:${brand.supportEmail}`}
|
alerts.
|
||||||
className='text-muted-foreground hover:underline'
|
</p>
|
||||||
>
|
</div>
|
||||||
{brand.supportEmail}
|
|
||||||
</a>
|
<SupportFooter position='absolute' />
|
||||||
</p>
|
</InviteLayout>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,13 +292,20 @@ export default function Unsubscribe() {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
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'>
|
<InviteLayout>
|
||||||
<Card className='w-full max-w-md border shadow-sm'>
|
<div className='space-y-1 text-center'>
|
||||||
<CardContent className='flex items-center justify-center p-8'>
|
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
Loading
|
||||||
</CardContent>
|
</h1>
|
||||||
</Card>
|
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||||
</div>
|
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 />
|
<UnsubscribeContent />
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
|
|||||||
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
|
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
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
|
* Plan step can be either a string or an object with title and plan
|
||||||
*/
|
*/
|
||||||
@@ -47,6 +44,56 @@ interface ParsedTags {
|
|||||||
cleanContent: string
|
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.
|
* Try to parse partial JSON for streaming options.
|
||||||
* Attempts to extract complete key-value pairs from incomplete JSON.
|
* 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)
|
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 (
|
return (
|
||||||
<div className='space-y-1.5'>
|
<div className='space-y-1.5'>
|
||||||
@@ -670,9 +726,7 @@ function SubAgentThinkingContent({
|
|||||||
hasSpecialTags={hasSpecialTags}
|
hasSpecialTags={hasSpecialTags}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{allParsed.plan && Object.keys(allParsed.plan).length > 0 && (
|
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
|
||||||
<PlanSteps steps={allParsed.plan} streaming={isStreaming} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -744,8 +798,19 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allParsed = parseSpecialTags(allRawText)
|
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 = !!(
|
const hasSpecialTags = !!(
|
||||||
(allParsed.plan && Object.keys(allParsed.plan).length > 0) ||
|
hasPlan ||
|
||||||
(allParsed.options && Object.keys(allParsed.options).length > 0)
|
(allParsed.options && Object.keys(allParsed.options).length > 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -757,8 +822,6 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
const outerLabel = getSubagentCompletionLabel(toolCall.name)
|
const outerLabel = getSubagentCompletionLabel(toolCall.name)
|
||||||
const durationText = `${outerLabel} for ${formatDuration(duration)}`
|
const durationText = `${outerLabel} for ${formatDuration(duration)}`
|
||||||
|
|
||||||
const hasPlan = allParsed.plan && Object.keys(allParsed.plan).length > 0
|
|
||||||
|
|
||||||
const renderCollapsibleContent = () => (
|
const renderCollapsibleContent = () => (
|
||||||
<>
|
<>
|
||||||
{segments.map((segment, index) => {
|
{segments.map((segment, index) => {
|
||||||
@@ -800,7 +863,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
return (
|
return (
|
||||||
<div className='w-full space-y-1.5'>
|
<div className='w-full space-y-1.5'>
|
||||||
{renderCollapsibleContent()}
|
{renderCollapsibleContent()}
|
||||||
{hasPlan && <PlanSteps steps={allParsed.plan!} streaming={isStreaming} />}
|
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -832,7 +895,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plan stays outside the collapsible */}
|
{/* Plan stays outside the collapsible */}
|
||||||
{hasPlan && <PlanSteps steps={allParsed.plan!} />}
|
{hasPlan && planToRender && <PlanSteps steps={planToRender} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1412,7 +1475,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
|||||||
if (
|
if (
|
||||||
toolCall.name === 'checkoff_todo' ||
|
toolCall.name === 'checkoff_todo' ||
|
||||||
toolCall.name === 'mark_todo_in_progress' ||
|
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_respond' ||
|
||||||
|
toolCall.name === 'debug_respond' ||
|
||||||
|
toolCall.name === 'plan_respond'
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
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 { useParams } from 'next/navigation'
|
||||||
import Editor from 'react-simple-code-editor'
|
import Editor from 'react-simple-code-editor'
|
||||||
import { useUpdateNodeInternals } from 'reactflow'
|
import { useUpdateNodeInternals } from 'reactflow'
|
||||||
@@ -39,6 +39,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
|||||||
|
|
||||||
const logger = createLogger('ConditionInput')
|
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).
|
* Represents a single conditional block (if/else if/else).
|
||||||
*/
|
*/
|
||||||
@@ -743,6 +753,61 @@ export function ConditionInput({
|
|||||||
}
|
}
|
||||||
}, [conditionalBlocks, isRouterMode])
|
}, [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
|
// Show loading or empty state if not ready or no blocks
|
||||||
if (!isReady || conditionalBlocks.length === 0) {
|
if (!isReady || conditionalBlocks.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -907,10 +972,24 @@ export function ConditionInput({
|
|||||||
}}
|
}}
|
||||||
placeholder='Describe when this route should be taken...'
|
placeholder='Describe when this route should be taken...'
|
||||||
disabled={disabled || isPreview}
|
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'
|
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={2}
|
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 && (
|
{block.showEnvVars && (
|
||||||
<EnvVarDropdown
|
<EnvVarDropdown
|
||||||
visible={block.showEnvVars}
|
visible={block.showEnvVars}
|
||||||
|
|||||||
@@ -234,48 +234,45 @@ export function LongInput({
|
|||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
// Handle resize functionality
|
// Handle resize functionality
|
||||||
const startResize = useCallback(
|
const startResize = (e: React.MouseEvent) => {
|
||||||
(e: React.MouseEvent) => {
|
e.preventDefault()
|
||||||
e.preventDefault()
|
e.stopPropagation()
|
||||||
e.stopPropagation()
|
isResizing.current = true
|
||||||
isResizing.current = true
|
|
||||||
|
|
||||||
const startY = e.clientY
|
const startY = e.clientY
|
||||||
const startHeight = height
|
const startHeight = height
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
if (!isResizing.current) return
|
if (!isResizing.current) return
|
||||||
|
|
||||||
const deltaY = moveEvent.clientY - startY
|
const deltaY = moveEvent.clientY - startY
|
||||||
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
|
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
|
||||||
|
|
||||||
if (textareaRef.current && overlayRef.current) {
|
if (textareaRef.current && overlayRef.current) {
|
||||||
textareaRef.current.style.height = `${newHeight}px`
|
textareaRef.current.style.height = `${newHeight}px`
|
||||||
overlayRef.current.style.height = `${newHeight}px`
|
overlayRef.current.style.height = `${newHeight}px`
|
||||||
}
|
}
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
containerRef.current.style.height = `${newHeight}px`
|
containerRef.current.style.height = `${newHeight}px`
|
||||||
}
|
}
|
||||||
// Keep React state in sync so parent layouts (e.g., Editor) update during drag
|
// Keep React state in sync so parent layouts (e.g., Editor) update during drag
|
||||||
setHeight(newHeight)
|
setHeight(newHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
|
||||||
|
setHeight(finalHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
isResizing.current = false
|
||||||
if (textareaRef.current) {
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
setHeight(finalHeight)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
isResizing.current = false
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
document.removeEventListener('mouseup', handleMouseUp)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
|
||||||
},
|
|
||||||
[height]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Expose wand control handlers to parent via ref
|
// Expose wand control handlers to parent via ref
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
|
|||||||
@@ -1,281 +1,17 @@
|
|||||||
import type { RefObject } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { useParams } from 'next/navigation'
|
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 { 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 { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
|
||||||
import {
|
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
|
||||||
checkTagTrigger,
|
|
||||||
TagDropdown,
|
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
|
||||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
import { 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 { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||||
import { formatParameterLabel } from '@/tools/params'
|
import { formatParameterLabel } from '@/tools/params'
|
||||||
|
|
||||||
const logger = createLogger('McpDynamicArgs')
|
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 {
|
interface McpDynamicArgsProps {
|
||||||
blockId: string
|
blockId: string
|
||||||
subBlockId: string
|
subBlockId: string
|
||||||
@@ -284,6 +20,27 @@ interface McpDynamicArgsProps {
|
|||||||
previewValue?: any
|
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({
|
export function McpDynamicArgs({
|
||||||
blockId,
|
blockId,
|
||||||
subBlockId,
|
subBlockId,
|
||||||
@@ -297,7 +54,6 @@ export function McpDynamicArgs({
|
|||||||
const [selectedTool] = useSubBlockValue(blockId, 'tool')
|
const [selectedTool] = useSubBlockValue(blockId, 'tool')
|
||||||
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
|
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
|
||||||
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
|
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
|
||||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
|
||||||
|
|
||||||
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
|
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
|
||||||
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
|
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
|
||||||
@@ -308,7 +64,7 @@ export function McpDynamicArgs({
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(previewValue)
|
return JSON.parse(previewValue)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to parse preview value as JSON:', error)
|
logger.warn('Failed to parse preview value as JSON:', { error })
|
||||||
return previewValue
|
return previewValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +74,7 @@ export function McpDynamicArgs({
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(toolArgs)
|
return JSON.parse(toolArgs)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to parse toolArgs as JSON:', error)
|
logger.warn('Failed to parse toolArgs as JSON:', { error })
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,24 +216,23 @@ export function McpDynamicArgs({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'long-input':
|
case 'long-input': {
|
||||||
|
const config = createParamConfig(paramName, paramSchema, 'long-input')
|
||||||
return (
|
return (
|
||||||
<McpTextareaWithTags
|
<LongInput
|
||||||
key={`${paramName}-long`}
|
key={`${paramName}-long`}
|
||||||
|
blockId={blockId}
|
||||||
|
subBlockId={`_mcp_${paramName}`}
|
||||||
|
config={config}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
rows={4}
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={(newValue) => updateParameter(paramName, newValue)}
|
onChange={(newValue) => updateParameter(paramName, newValue)}
|
||||||
placeholder={
|
isPreview={isPreview}
|
||||||
paramSchema.type === 'array'
|
|
||||||
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
|
||||||
: paramSchema.description ||
|
|
||||||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
blockId={blockId}
|
|
||||||
accessiblePrefixes={accessiblePrefixes}
|
|
||||||
rows={4}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
const isPassword =
|
const isPassword =
|
||||||
@@ -485,10 +240,16 @@ export function McpDynamicArgs({
|
|||||||
paramName.toLowerCase().includes('password') ||
|
paramName.toLowerCase().includes('password') ||
|
||||||
paramName.toLowerCase().includes('token')
|
paramName.toLowerCase().includes('token')
|
||||||
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
|
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
|
||||||
|
const config = createParamConfig(paramName, paramSchema, 'short-input')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<McpInputWithTags
|
<ShortInput
|
||||||
key={`${paramName}-short`}
|
key={`${paramName}-short`}
|
||||||
|
blockId={blockId}
|
||||||
|
subBlockId={`_mcp_${paramName}`}
|
||||||
|
config={config}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
password={isPassword}
|
||||||
value={value?.toString() || ''}
|
value={value?.toString() || ''}
|
||||||
onChange={(newValue) => {
|
onChange={(newValue) => {
|
||||||
let processedValue: any = newValue
|
let processedValue: any = newValue
|
||||||
@@ -506,16 +267,8 @@ export function McpDynamicArgs({
|
|||||||
}
|
}
|
||||||
updateParameter(paramName, processedValue)
|
updateParameter(paramName, processedValue)
|
||||||
}}
|
}}
|
||||||
placeholder={
|
isPreview={isPreview}
|
||||||
paramSchema.type === 'array'
|
|
||||||
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
|
||||||
: paramSchema.description ||
|
|
||||||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isPassword={isPassword}
|
|
||||||
blockId={blockId}
|
|
||||||
accessiblePrefixes={accessiblePrefixes}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -578,26 +331,40 @@ export function McpDynamicArgs({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<div className='space-y-4'>
|
<div>
|
||||||
{toolSchema.properties &&
|
{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 inputType = getInputType(paramSchema as any)
|
||||||
const showLabel = inputType !== 'switch'
|
const showLabel = inputType !== 'switch'
|
||||||
|
const showDivider = index < entries.length - 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={paramName} className='space-y-2'>
|
<div key={paramName} className='subblock-row'>
|
||||||
{showLabel && (
|
<div className='subblock-content flex flex-col gap-[10px]'>
|
||||||
<Label
|
{showLabel && (
|
||||||
className={cn(
|
<Label
|
||||||
'font-medium text-sm',
|
className={cn(
|
||||||
toolSchema.required?.includes(paramName) &&
|
'font-medium text-sm',
|
||||||
'after:ml-1 after:text-red-500 after:content-["*"]'
|
toolSchema.required?.includes(paramName) &&
|
||||||
)}
|
'after:ml-1 after:text-red-500 after:content-["*"]'
|
||||||
>
|
)}
|
||||||
{formatParameterLabel(paramName)}
|
>
|
||||||
</Label>
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2069,6 +2069,7 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
placeholder: uiComponent.placeholder,
|
placeholder: uiComponent.placeholder,
|
||||||
requiredScopes: uiComponent.requiredScopes,
|
requiredScopes: uiComponent.requiredScopes,
|
||||||
dependsOn: uiComponent.dependsOn,
|
dependsOn: uiComponent.dependsOn,
|
||||||
|
canonicalParamId: uiComponent.canonicalParamId ?? param.id,
|
||||||
}}
|
}}
|
||||||
onProjectSelect={onChange}
|
onProjectSelect={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface LogRowContextMenuProps {
|
|||||||
onCopyRunId: (runId: string) => void
|
onCopyRunId: (runId: string) => void
|
||||||
onClearFilters: () => void
|
onClearFilters: () => void
|
||||||
onClearConsole: () => void
|
onClearConsole: () => void
|
||||||
|
onFixInCopilot: (entry: ConsoleEntry) => void
|
||||||
hasActiveFilters: boolean
|
hasActiveFilters: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ export function LogRowContextMenu({
|
|||||||
onCopyRunId,
|
onCopyRunId,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
onClearConsole,
|
onClearConsole,
|
||||||
|
onFixInCopilot,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
}: LogRowContextMenuProps) {
|
}: LogRowContextMenuProps) {
|
||||||
const hasRunId = entry?.executionId != null
|
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 */}
|
{/* Filter actions */}
|
||||||
{entry && (
|
{entry && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { useShowTrainingControls } from '@/hooks/queries/general-settings'
|
|||||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||||
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
|
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
|
||||||
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||||
|
import { openCopilotWithMessage } from '@/stores/notifications/utils'
|
||||||
import type { ConsoleEntry } from '@/stores/terminal'
|
import type { ConsoleEntry } from '@/stores/terminal'
|
||||||
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
|
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
@@ -226,7 +227,6 @@ const isEventFromEditableElement = (e: KeyboardEvent): boolean => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check target and walk up ancestors in case editors render nested elements
|
|
||||||
let el: HTMLElement | null = target
|
let el: HTMLElement | null = target
|
||||||
while (el) {
|
while (el) {
|
||||||
if (isEditable(el)) return true
|
if (isEditable(el)) return true
|
||||||
@@ -1159,6 +1159,17 @@ export const Terminal = memo(function Terminal() {
|
|||||||
clearCurrentWorkflowConsole()
|
clearCurrentWorkflowConsole()
|
||||||
}, [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(
|
const handleTrainingClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -1949,6 +1960,7 @@ export const Terminal = memo(function Terminal() {
|
|||||||
closeLogRowMenu()
|
closeLogRowMenu()
|
||||||
}}
|
}}
|
||||||
onClearConsole={handleClearConsoleFromMenu}
|
onClearConsole={handleClearConsoleFromMenu}
|
||||||
|
onFixInCopilot={handleFixInCopilot}
|
||||||
hasActiveFilters={hasActiveFilters}
|
hasActiveFilters={hasActiveFilters}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -692,7 +692,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
parentId?: string,
|
parentId?: string,
|
||||||
extent?: 'parent',
|
extent?: 'parent',
|
||||||
autoConnectEdge?: Edge,
|
autoConnectEdge?: Edge,
|
||||||
triggerMode?: boolean
|
triggerMode?: boolean,
|
||||||
|
presetSubBlockValues?: Record<string, unknown>
|
||||||
) => {
|
) => {
|
||||||
setPendingSelection([id])
|
setPendingSelection([id])
|
||||||
setSelectedEdges(new Map())
|
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(
|
collaborativeBatchAddBlocks(
|
||||||
[block],
|
[block],
|
||||||
autoConnectEdge ? [autoConnectEdge] : [],
|
autoConnectEdge ? [autoConnectEdge] : [],
|
||||||
@@ -1489,7 +1498,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, enableTriggerMode } = event.detail
|
const { type, enableTriggerMode, presetOperation } = event.detail
|
||||||
|
|
||||||
if (!type) return
|
if (!type) return
|
||||||
if (type === 'connectionBlock') return
|
if (type === 'connectionBlock') return
|
||||||
@@ -1552,7 +1561,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
autoConnectEdge,
|
autoConnectEdge,
|
||||||
enableTriggerMode
|
enableTriggerMode,
|
||||||
|
presetOperation ? { operation: presetOperation } : undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useParams, useRouter } from 'next/navigation'
|
|||||||
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { useBrandConfig } from '@/lib/branding/branding'
|
import { useBrandConfig } from '@/lib/branding/branding'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
|
||||||
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||||
import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-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'
|
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||||
@@ -81,10 +82,12 @@ type SearchItem = {
|
|||||||
color?: string
|
color?: string
|
||||||
href?: string
|
href?: string
|
||||||
shortcut?: string
|
shortcut?: string
|
||||||
type: 'block' | 'trigger' | 'tool' | 'workflow' | 'workspace' | 'page' | 'doc'
|
type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc'
|
||||||
isCurrent?: boolean
|
isCurrent?: boolean
|
||||||
blockType?: string
|
blockType?: string
|
||||||
config?: any
|
config?: any
|
||||||
|
operationId?: string
|
||||||
|
aliases?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchResultItemProps {
|
interface SearchResultItemProps {
|
||||||
@@ -101,7 +104,11 @@ const SearchResultItem = memo(function SearchResultItem({
|
|||||||
onItemClick,
|
onItemClick,
|
||||||
}: SearchResultItemProps) {
|
}: SearchResultItemProps) {
|
||||||
const Icon = item.icon
|
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 isWorkflow = item.type === 'workflow'
|
||||||
const isWorkspace = item.type === 'workspace'
|
const isWorkspace = item.type === 'workspace'
|
||||||
|
|
||||||
@@ -278,6 +285,24 @@ export const SearchModal = memo(function SearchModal({
|
|||||||
)
|
)
|
||||||
}, [open, isOnWorkflowPage, filterBlocks])
|
}, [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(
|
const pages = useMemo(
|
||||||
(): PageItem[] => [
|
(): 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) => {
|
docs.forEach((doc) => {
|
||||||
items.push({
|
items.push({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
@@ -407,10 +445,10 @@ export const SearchModal = memo(function SearchModal({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return items
|
return items
|
||||||
}, [workspaces, workflows, pages, blocks, triggers, tools, docs])
|
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
|
||||||
|
|
||||||
const sectionOrder = useMemo<SearchItem['type'][]>(
|
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: [],
|
page: [],
|
||||||
trigger: [],
|
trigger: [],
|
||||||
block: [],
|
block: [],
|
||||||
|
'tool-operation': [],
|
||||||
tool: [],
|
tool: [],
|
||||||
doc: [],
|
doc: [],
|
||||||
}
|
}
|
||||||
@@ -512,6 +551,17 @@ export const SearchModal = memo(function SearchModal({
|
|||||||
window.dispatchEvent(event)
|
window.dispatchEvent(event)
|
||||||
}
|
}
|
||||||
break
|
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':
|
case 'workspace':
|
||||||
if (item.isCurrent) {
|
if (item.isCurrent) {
|
||||||
break
|
break
|
||||||
@@ -592,6 +642,7 @@ export const SearchModal = memo(function SearchModal({
|
|||||||
page: 'Pages',
|
page: 'Pages',
|
||||||
trigger: 'Triggers',
|
trigger: 'Triggers',
|
||||||
block: 'Blocks',
|
block: 'Blocks',
|
||||||
|
'tool-operation': 'Tool Operations',
|
||||||
tool: 'Tools',
|
tool: 'Tools',
|
||||||
doc: 'Docs',
|
doc: 'Docs',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,19 @@ export interface SearchableItem {
|
|||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
type: string
|
type: string
|
||||||
|
aliases?: string[]
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResult<T extends SearchableItem> {
|
export interface SearchResult<T extends SearchableItem> {
|
||||||
item: T
|
item: T
|
||||||
score: number
|
score: number
|
||||||
matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | 'description'
|
matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description'
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCORE_EXACT_MATCH = 10000
|
const SCORE_EXACT_MATCH = 10000
|
||||||
const SCORE_PREFIX_MATCH = 5000
|
const SCORE_PREFIX_MATCH = 5000
|
||||||
|
const SCORE_ALIAS_MATCH = 3000
|
||||||
const SCORE_WORD_BOUNDARY = 1000
|
const SCORE_WORD_BOUNDARY = 1000
|
||||||
const SCORE_SUBSTRING_MATCH = 100
|
const SCORE_SUBSTRING_MATCH = 100
|
||||||
const DESCRIPTION_WEIGHT = 0.3
|
const DESCRIPTION_WEIGHT = 0.3
|
||||||
@@ -67,6 +69,39 @@ function calculateFieldScore(
|
|||||||
return { score: 0, matchType: null }
|
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
|
* Search items using tiered matching algorithm
|
||||||
* Returns items sorted by relevance (highest score first)
|
* Returns items sorted by relevance (highest score first)
|
||||||
@@ -90,15 +125,20 @@ export function searchItems<T extends SearchableItem>(
|
|||||||
? calculateFieldScore(normalizedQuery, item.description)
|
? calculateFieldScore(normalizedQuery, item.description)
|
||||||
: { score: 0, matchType: null }
|
: { score: 0, matchType: null }
|
||||||
|
|
||||||
|
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
|
||||||
|
|
||||||
const nameScore = nameMatch.score
|
const nameScore = nameMatch.score
|
||||||
const descScore = descMatch.score * DESCRIPTION_WEIGHT
|
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) {
|
if (bestScore > 0) {
|
||||||
let matchType: SearchResult<T>['matchType'] = 'substring'
|
let matchType: SearchResult<T>['matchType'] = 'substring'
|
||||||
if (nameScore >= descScore) {
|
if (nameScore >= descScore && nameScore >= aliasScore) {
|
||||||
matchType = nameMatch.matchType || 'substring'
|
matchType = nameMatch.matchType || 'substring'
|
||||||
|
} else if (aliasScore >= descScore) {
|
||||||
|
matchType = 'alias'
|
||||||
} else {
|
} else {
|
||||||
matchType = 'description'
|
matchType = 'description'
|
||||||
}
|
}
|
||||||
@@ -125,6 +165,8 @@ export function getMatchTypeLabel(matchType: SearchResult<any>['matchType']): st
|
|||||||
return 'Exact match'
|
return 'Exact match'
|
||||||
case 'prefix':
|
case 'prefix':
|
||||||
return 'Starts with'
|
return 'Starts with'
|
||||||
|
case 'alias':
|
||||||
|
return 'Similar to'
|
||||||
case 'word-boundary':
|
case 'word-boundary':
|
||||||
return 'Word match'
|
return 'Word match'
|
||||||
case 'substring':
|
case 'substring':
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { Button, Input as EmcnInput } from '@/components/emcn'
|
||||||
|
import { workflowKeys } from '@/hooks/queries/workflows'
|
||||||
|
|
||||||
|
const logger = createLogger('DebugSettings')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug settings component for superusers.
|
||||||
|
* Allows importing workflows by ID for debugging purposes.
|
||||||
|
*/
|
||||||
|
export function Debug() {
|
||||||
|
const params = useParams()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const workspaceId = params?.workspaceId as string
|
||||||
|
|
||||||
|
const [workflowId, setWorkflowId] = useState('')
|
||||||
|
const [isImporting, setIsImporting] = useState(false)
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!workflowId.trim()) return
|
||||||
|
|
||||||
|
setIsImporting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/superuser/import-workflow', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workflowId: workflowId.trim(),
|
||||||
|
targetWorkspaceId: workspaceId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
|
||||||
|
setWorkflowId('')
|
||||||
|
logger.info('Workflow imported successfully', {
|
||||||
|
originalWorkflowId: workflowId.trim(),
|
||||||
|
newWorkflowId: data.newWorkflowId,
|
||||||
|
copilotChatsImported: data.copilotChatsImported,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to import workflow', error)
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex h-full flex-col gap-[16px]'>
|
||||||
|
<p className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
|
Import a workflow by ID along with its associated copilot chats.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='flex gap-[8px]'>
|
||||||
|
<EmcnInput
|
||||||
|
value={workflowId}
|
||||||
|
onChange={(e) => setWorkflowId(e.target.value)}
|
||||||
|
placeholder='Enter workflow ID'
|
||||||
|
disabled={isImporting}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant='tertiary'
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={isImporting || !workflowId.trim()}
|
||||||
|
>
|
||||||
|
{isImporting ? 'Importing...' : 'Import'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export { BYOK } from './byok/byok'
|
|||||||
export { Copilot } from './copilot/copilot'
|
export { Copilot } from './copilot/copilot'
|
||||||
export { CredentialSets } from './credential-sets/credential-sets'
|
export { CredentialSets } from './credential-sets/credential-sets'
|
||||||
export { CustomTools } from './custom-tools/custom-tools'
|
export { CustomTools } from './custom-tools/custom-tools'
|
||||||
|
export { Debug } from './debug/debug'
|
||||||
export { EnvironmentVariables } from './environment/environment'
|
export { EnvironmentVariables } from './environment/environment'
|
||||||
export { Files as FileUploads } from './files/files'
|
export { Files as FileUploads } from './files/files'
|
||||||
export { General } from './general/general'
|
export { General } from './general/general'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|||||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
Bug,
|
||||||
Files,
|
Files,
|
||||||
KeySquare,
|
KeySquare,
|
||||||
LogIn,
|
LogIn,
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
Copilot,
|
Copilot,
|
||||||
CredentialSets,
|
CredentialSets,
|
||||||
CustomTools,
|
CustomTools,
|
||||||
|
Debug,
|
||||||
EnvironmentVariables,
|
EnvironmentVariables,
|
||||||
FileUploads,
|
FileUploads,
|
||||||
General,
|
General,
|
||||||
@@ -91,8 +93,15 @@ type SettingsSection =
|
|||||||
| 'mcp'
|
| 'mcp'
|
||||||
| 'custom-tools'
|
| 'custom-tools'
|
||||||
| 'workflow-mcp-servers'
|
| 'workflow-mcp-servers'
|
||||||
|
| 'debug'
|
||||||
|
|
||||||
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system' | 'enterprise'
|
type NavigationSection =
|
||||||
|
| 'account'
|
||||||
|
| 'subscription'
|
||||||
|
| 'tools'
|
||||||
|
| 'system'
|
||||||
|
| 'enterprise'
|
||||||
|
| 'superuser'
|
||||||
|
|
||||||
type NavigationItem = {
|
type NavigationItem = {
|
||||||
id: SettingsSection
|
id: SettingsSection
|
||||||
@@ -104,6 +113,7 @@ type NavigationItem = {
|
|||||||
requiresEnterprise?: boolean
|
requiresEnterprise?: boolean
|
||||||
requiresHosted?: boolean
|
requiresHosted?: boolean
|
||||||
selfHostedOverride?: boolean
|
selfHostedOverride?: boolean
|
||||||
|
requiresSuperUser?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionConfig: { key: NavigationSection; title: string }[] = [
|
const sectionConfig: { key: NavigationSection; title: string }[] = [
|
||||||
@@ -112,6 +122,7 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [
|
|||||||
{ key: 'subscription', title: 'Subscription' },
|
{ key: 'subscription', title: 'Subscription' },
|
||||||
{ key: 'system', title: 'System' },
|
{ key: 'system', title: 'System' },
|
||||||
{ key: 'enterprise', title: 'Enterprise' },
|
{ key: 'enterprise', title: 'Enterprise' },
|
||||||
|
{ key: 'superuser', title: 'Superuser' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const allNavigationItems: NavigationItem[] = [
|
const allNavigationItems: NavigationItem[] = [
|
||||||
@@ -180,15 +191,24 @@ const allNavigationItems: NavigationItem[] = [
|
|||||||
requiresEnterprise: true,
|
requiresEnterprise: true,
|
||||||
selfHostedOverride: isSSOEnabled,
|
selfHostedOverride: isSSOEnabled,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'debug',
|
||||||
|
label: 'Debug',
|
||||||
|
icon: Bug,
|
||||||
|
section: 'superuser',
|
||||||
|
requiresSuperUser: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||||
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
|
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
|
||||||
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
|
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
|
||||||
|
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: organizationsData } = useOrganizations()
|
const { data: organizationsData } = useOrganizations()
|
||||||
|
const { data: generalSettings } = useGeneralSettings()
|
||||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||||
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
||||||
|
|
||||||
@@ -209,6 +229,23 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
||||||
const hasOrganization = !!activeOrganization?.id
|
const hasOrganization = !!activeOrganization?.id
|
||||||
|
|
||||||
|
// Fetch superuser status
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSuperUserStatus = async () => {
|
||||||
|
if (!userId) return
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/super-user')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setIsSuperUser(data.isSuperUser)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsSuperUser(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchSuperUserStatus()
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
// Memoize SSO provider ownership check
|
// Memoize SSO provider ownership check
|
||||||
const isSSOProviderOwner = useMemo(() => {
|
const isSSOProviderOwner = useMemo(() => {
|
||||||
if (isHosted) return null
|
if (isHosted) return null
|
||||||
@@ -268,6 +305,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requiresSuperUser: only show if user is a superuser AND has superuser mode enabled
|
||||||
|
const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false
|
||||||
|
const effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||||
|
if (item.requiresSuperUser && !effectiveSuperUser) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
@@ -280,6 +324,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
isOwner,
|
isOwner,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
permissionConfig,
|
permissionConfig,
|
||||||
|
isSuperUser,
|
||||||
|
generalSettings?.superUserModeEnabled,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Memoized callbacks to prevent infinite loops in child components
|
// Memoized callbacks to prevent infinite loops in child components
|
||||||
@@ -308,9 +354,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
[activeSection]
|
[activeSection]
|
||||||
)
|
)
|
||||||
|
|
||||||
// React Query hook automatically loads and syncs settings
|
|
||||||
useGeneralSettings()
|
|
||||||
|
|
||||||
// Apply initial section from store when modal opens
|
// Apply initial section from store when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && initialSection) {
|
if (open && initialSection) {
|
||||||
@@ -523,6 +566,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||||
{activeSection === 'custom-tools' && <CustomTools />}
|
{activeSection === 'custom-tools' && <CustomTools />}
|
||||||
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||||
|
{activeSection === 'debug' && <Debug />}
|
||||||
</SModalMainBody>
|
</SModalMainBody>
|
||||||
</SModalMain>
|
</SModalMain>
|
||||||
</SModalContent>
|
</SModalContent>
|
||||||
|
|||||||
@@ -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.',
|
'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',
|
docsLink: 'https://docs.sim.ai/tools/browser_use',
|
||||||
category: 'tools',
|
category: 'tools',
|
||||||
bgColor: '#E0E0E0',
|
bgColor: '#181C1E',
|
||||||
icon: BrowserUseIcon,
|
icon: BrowserUseIcon,
|
||||||
subBlocks: [
|
subBlocks: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function OTPVerificationEmail({
|
|||||||
const brand = getBrandConfig()
|
const brand = getBrandConfig()
|
||||||
|
|
||||||
return (
|
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>
|
<Text style={baseStyles.paragraph}>Your verification code:</Text>
|
||||||
|
|
||||||
<Section style={baseStyles.codeContainer}>
|
<Section style={baseStyles.codeContainer}>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw
|
|||||||
const brand = getBrandConfig()
|
const brand = getBrandConfig()
|
||||||
|
|
||||||
return (
|
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}>Hello {username},</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
A password reset was requested for your {brand.name} account. Click below to set a new
|
A password reset was requested for your {brand.name} account. Click below to set a new
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) {
|
|||||||
const baseUrl = getBaseUrl()
|
const baseUrl = getBaseUrl()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={`Welcome to ${brand.name}`}>
|
<EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hey ${userName},` : 'Hey,'}
|
{userName ? `Hey ${userName},` : 'Hey,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function CreditPurchaseEmail({
|
|||||||
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
|
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={false}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export function EnterpriseSubscriptionEmail({
|
|||||||
const effectiveLoginLink = loginLink || `${baseUrl}/login`
|
const effectiveLoginLink = loginLink || `${baseUrl}/login`
|
||||||
|
|
||||||
return (
|
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}>Hello {userName},</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
|
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function FreeTierUpgradeEmail({
|
|||||||
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
|
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function PaymentFailedEmail({
|
|||||||
const previewText = `${brand.name}: Payment Failed - Action Required`
|
const previewText = `${brand.name}: Payment Failed - Action Required`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={false}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE
|
|||||||
const previewText = `${brand.name}: Your ${planName} plan is active`
|
const previewText = `${brand.name}: Your ${planName} plan is active`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function UsageThresholdEmail({
|
|||||||
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
|
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export function CareersConfirmationEmail({
|
|||||||
const baseUrl = getBaseUrl()
|
const baseUrl = getBaseUrl()
|
||||||
|
|
||||||
return (
|
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}>Hello {name},</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
We've received your application for <strong>{position}</strong>. Our team reviews every
|
We've received your application for <strong>{position}</strong>. Our team reviews every
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function CareersSubmissionEmail({
|
|||||||
submittedDate = new Date(),
|
submittedDate = new Date(),
|
||||||
}: CareersSubmissionEmailProps) {
|
}: CareersSubmissionEmailProps) {
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={`New Career Application from ${name}`} hideFooter>
|
<EmailLayout preview={`New Career Application from ${name}`} hideFooter showUnsubscribe={false}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
...baseStyles.paragraph,
|
...baseStyles.paragraph,
|
||||||
|
|||||||
@@ -4,22 +4,29 @@ import { getBrandConfig } from '@/lib/branding/branding'
|
|||||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
|
|
||||||
interface UnsubscribeOptions {
|
|
||||||
unsubscribeToken?: string
|
|
||||||
email?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EmailFooterProps {
|
interface EmailFooterProps {
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
unsubscribe?: UnsubscribeOptions
|
|
||||||
messageId?: string
|
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.
|
* Email footer component styled to match Stripe's email design.
|
||||||
* Sits in the gray area below the main white card.
|
* 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 brand = getBrandConfig()
|
||||||
|
|
||||||
const footerLinkStyle = {
|
const footerLinkStyle = {
|
||||||
@@ -181,19 +188,20 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }:
|
|||||||
•{' '}
|
•{' '}
|
||||||
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
|
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
|
||||||
Terms of Service
|
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>
|
</a>
|
||||||
|
{showUnsubscribe && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
•{' '}
|
||||||
|
<a
|
||||||
|
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
|
||||||
|
style={footerLinkStyle}
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
Unsubscribe
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,23 @@ interface EmailLayoutProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
/** Optional: hide footer for internal emails */
|
/** Optional: hide footer for internal emails */
|
||||||
hideFooter?: boolean
|
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.
|
* Shared email layout wrapper providing consistent structure.
|
||||||
* Includes Html, Head, Body, Container with logo header, and Footer.
|
* 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 brand = getBrandConfig()
|
||||||
const baseUrl = getBaseUrl()
|
const baseUrl = getBaseUrl()
|
||||||
|
|
||||||
@@ -43,7 +53,7 @@ export function EmailLayout({ preview, children, hideFooter = false }: EmailLayo
|
|||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{/* Footer in gray section */}
|
{/* Footer in gray section */}
|
||||||
{!hideFooter && <EmailFooter baseUrl={baseUrl} />}
|
{!hideFooter && <EmailFooter baseUrl={baseUrl} showUnsubscribe={showUnsubscribe} />}
|
||||||
</Body>
|
</Body>
|
||||||
</Html>
|
</Html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function BatchInvitationEmail({
|
|||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
|
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}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ export function InvitationEmail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
|
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export function PollingGroupInvitationEmail({
|
|||||||
const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'
|
const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'
|
||||||
|
|
||||||
return (
|
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}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
<strong>{inviterName}</strong> from <strong>{organizationName}</strong> has invited you to
|
<strong>{inviterName}</strong> from <strong>{organizationName}</strong> has invited you to
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function WorkspaceInvitationEmail({
|
|||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
|
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}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function WorkflowNotificationEmail({
|
|||||||
: 'Your workflow completed successfully.'
|
: 'Your workflow completed successfully.'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview={previewText}>
|
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>Hello,</Text>
|
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>{message}</Text>
|
<Text style={baseStyles.paragraph}>{message}</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export function HelpConfirmationEmail({
|
|||||||
const typeLabel = getTypeLabel(type)
|
const typeLabel = getTypeLabel(type)
|
||||||
|
|
||||||
return (
|
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}>Hello,</Text>
|
||||||
<Text style={baseStyles.paragraph}>
|
<Text style={baseStyles.paragraph}>
|
||||||
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
|
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
|
||||||
|
|||||||
@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
{...props}
|
{...props}
|
||||||
version='1.0'
|
version='1.0'
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
width='150pt'
|
width='28'
|
||||||
height='150pt'
|
height='28'
|
||||||
viewBox='0 0 150 150'
|
viewBox='0 0 150 150'
|
||||||
preserveAspectRatio='xMidYMid meet'
|
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
|
<path
|
||||||
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
|
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
|
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -28,8 +28,6 @@ export class ExecutionEngine {
|
|||||||
private lastCancellationCheck = 0
|
private lastCancellationCheck = 0
|
||||||
private readonly useRedisCancellation: boolean
|
private readonly useRedisCancellation: boolean
|
||||||
private readonly CANCELLATION_CHECK_INTERVAL_MS = 500
|
private readonly CANCELLATION_CHECK_INTERVAL_MS = 500
|
||||||
private abortPromise: Promise<void> | null = null
|
|
||||||
private abortResolve: (() => void) | null = null
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private context: ExecutionContext,
|
private context: ExecutionContext,
|
||||||
@@ -39,34 +37,6 @@ export class ExecutionEngine {
|
|||||||
) {
|
) {
|
||||||
this.allowResumeTriggers = this.context.metadata.resumeFromSnapshot === true
|
this.allowResumeTriggers = this.context.metadata.resumeFromSnapshot === true
|
||||||
this.useRedisCancellation = isRedisCancellationEnabled() && !!this.context.executionId
|
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> {
|
private async checkCancellation(): Promise<boolean> {
|
||||||
@@ -103,15 +73,12 @@ export class ExecutionEngine {
|
|||||||
this.initializeQueue(triggerBlockId)
|
this.initializeQueue(triggerBlockId)
|
||||||
|
|
||||||
while (this.hasWork()) {
|
while (this.hasWork()) {
|
||||||
if (await this.checkCancellation()) {
|
if ((await this.checkCancellation()) && this.executing.size === 0) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
await this.processQueue()
|
await this.processQueue()
|
||||||
}
|
}
|
||||||
|
await this.waitForAllExecutions()
|
||||||
if (!this.cancelledFlag) {
|
|
||||||
await this.waitForAllExecutions()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pausedBlocks.size > 0) {
|
if (this.pausedBlocks.size > 0) {
|
||||||
return this.buildPausedResult(startTime)
|
return this.buildPausedResult(startTime)
|
||||||
@@ -197,7 +164,11 @@ export class ExecutionEngine {
|
|||||||
|
|
||||||
private trackExecution(promise: Promise<void>): void {
|
private trackExecution(promise: Promise<void>): void {
|
||||||
this.executing.add(promise)
|
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(() => {
|
promise.finally(() => {
|
||||||
this.executing.delete(promise)
|
this.executing.delete(promise)
|
||||||
})
|
})
|
||||||
@@ -205,30 +176,12 @@ export class ExecutionEngine {
|
|||||||
|
|
||||||
private async waitForAnyExecution(): Promise<void> {
|
private async waitForAnyExecution(): Promise<void> {
|
||||||
if (this.executing.size > 0) {
|
if (this.executing.size > 0) {
|
||||||
const abortPromise = this.getAbortPromise()
|
await Promise.race(this.executing)
|
||||||
if (abortPromise) {
|
|
||||||
await Promise.race([...this.executing, abortPromise])
|
|
||||||
} else {
|
|
||||||
await Promise.race(this.executing)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForAllExecutions(): Promise<void> {
|
private async waitForAllExecutions(): Promise<void> {
|
||||||
const abortPromise = this.getAbortPromise()
|
await Promise.all(Array.from(this.executing))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async withQueueLock<T>(fn: () => Promise<T> | T): Promise<T> {
|
private async withQueueLock<T>(fn: () => Promise<T> | T): Promise<T> {
|
||||||
@@ -324,7 +277,7 @@ export class ExecutionEngine {
|
|||||||
this.trackExecution(promise)
|
this.trackExecution(promise)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.executing.size > 0 && !this.cancelledFlag) {
|
if (this.executing.size > 0) {
|
||||||
await this.waitForAnyExecution()
|
await this.waitForAnyExecution()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,6 +336,7 @@ export class ExecutionEngine {
|
|||||||
|
|
||||||
this.addMultipleToQueue(readyNodes)
|
this.addMultipleToQueue(readyNodes)
|
||||||
|
|
||||||
|
// Check for dynamically added nodes (e.g., from parallel expansion)
|
||||||
if (this.context.pendingDynamicNodes && this.context.pendingDynamicNodes.length > 0) {
|
if (this.context.pendingDynamicNodes && this.context.pendingDynamicNodes.length > 0) {
|
||||||
const dynamicNodes = this.context.pendingDynamicNodes
|
const dynamicNodes = this.context.pendingDynamicNodes
|
||||||
this.context.pendingDynamicNodes = []
|
this.context.pendingDynamicNodes = []
|
||||||
|
|||||||
@@ -203,10 +203,11 @@ function resolveProjectSelector(
|
|||||||
): SelectorResolution {
|
): SelectorResolution {
|
||||||
const serviceId = subBlock.serviceId
|
const serviceId = subBlock.serviceId
|
||||||
const context = buildBaseContext(args)
|
const context = buildBaseContext(args)
|
||||||
|
const selectorId = subBlock.canonicalParamId ?? subBlock.id
|
||||||
|
|
||||||
switch (serviceId) {
|
switch (serviceId) {
|
||||||
case 'linear': {
|
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 }
|
return { key, context, allowSearch: true }
|
||||||
}
|
}
|
||||||
case 'jira':
|
case 'jira':
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
type BatchToggleEnabledOperation,
|
type BatchToggleEnabledOperation,
|
||||||
type BatchToggleHandlesOperation,
|
type BatchToggleHandlesOperation,
|
||||||
type BatchUpdateParentOperation,
|
type BatchUpdateParentOperation,
|
||||||
|
captureLatestEdges,
|
||||||
|
captureLatestSubBlockValues,
|
||||||
createOperationEntry,
|
createOperationEntry,
|
||||||
runWithUndoRedoRecordingSuspended,
|
runWithUndoRedoRecordingSuspended,
|
||||||
type UpdateParentOperation,
|
type UpdateParentOperation,
|
||||||
@@ -28,7 +30,6 @@ import {
|
|||||||
} from '@/stores/undo-redo'
|
} from '@/stores/undo-redo'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||||
|
|
||||||
@@ -445,34 +446,19 @@ export function useUndoRedo() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestEdges = useWorkflowStore
|
const latestEdges = captureLatestEdges(
|
||||||
.getState()
|
useWorkflowStore.getState().edges,
|
||||||
.edges.filter(
|
existingBlockIds
|
||||||
(e) => existingBlockIds.includes(e.source) || existingBlockIds.includes(e.target)
|
)
|
||||||
)
|
|
||||||
batchRemoveOp.data.edgeSnapshots = latestEdges
|
batchRemoveOp.data.edgeSnapshots = latestEdges
|
||||||
|
|
||||||
const latestSubBlockValues: Record<string, Record<string, unknown>> = {}
|
const latestSubBlockValues = captureLatestSubBlockValues(
|
||||||
existingBlockIds.forEach((blockId) => {
|
useWorkflowStore.getState().blocks,
|
||||||
const merged = mergeSubblockState(
|
activeWorkflowId,
|
||||||
useWorkflowStore.getState().blocks,
|
existingBlockIds
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
batchRemoveOp.data.subBlockValues = latestSubBlockValues
|
batchRemoveOp.data.subBlockValues = latestSubBlockValues
|
||||||
|
;(entry.operation as BatchAddBlocksOperation).data.subBlockValues = latestSubBlockValues
|
||||||
|
|
||||||
addToQueue({
|
addToQueue({
|
||||||
id: opId,
|
id: opId,
|
||||||
@@ -1153,6 +1139,20 @@ export function useUndoRedo() {
|
|||||||
break
|
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({
|
addToQueue({
|
||||||
id: opId,
|
id: opId,
|
||||||
operation: {
|
operation: {
|
||||||
|
|||||||
@@ -29,13 +29,11 @@ export class DocsChunker {
|
|||||||
private readonly baseUrl: string
|
private readonly baseUrl: string
|
||||||
|
|
||||||
constructor(options: DocsChunkerOptions = {}) {
|
constructor(options: DocsChunkerOptions = {}) {
|
||||||
// Use the existing TextChunker for chunking logic
|
|
||||||
this.textChunker = new TextChunker({
|
this.textChunker = new TextChunker({
|
||||||
chunkSize: options.chunkSize ?? 300, // Max 300 tokens per chunk
|
chunkSize: options.chunkSize ?? 300, // Max 300 tokens per chunk
|
||||||
minCharactersPerChunk: options.minCharactersPerChunk ?? 1,
|
minCharactersPerChunk: options.minCharactersPerChunk ?? 1,
|
||||||
chunkOverlap: options.chunkOverlap ?? 50,
|
chunkOverlap: options.chunkOverlap ?? 50,
|
||||||
})
|
})
|
||||||
// Use localhost docs in development, production docs otherwise
|
|
||||||
this.baseUrl = options.baseUrl ?? 'https://docs.sim.ai'
|
this.baseUrl = options.baseUrl ?? 'https://docs.sim.ai'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,24 +72,18 @@ export class DocsChunker {
|
|||||||
const content = await fs.readFile(filePath, 'utf-8')
|
const content = await fs.readFile(filePath, 'utf-8')
|
||||||
const relativePath = path.relative(basePath, filePath)
|
const relativePath = path.relative(basePath, filePath)
|
||||||
|
|
||||||
// Parse frontmatter and content
|
|
||||||
const { data: frontmatter, content: markdownContent } = this.parseFrontmatter(content)
|
const { data: frontmatter, content: markdownContent } = this.parseFrontmatter(content)
|
||||||
|
|
||||||
// Extract headers from the content
|
|
||||||
const headers = this.extractHeaders(markdownContent)
|
const headers = this.extractHeaders(markdownContent)
|
||||||
|
|
||||||
// Generate document URL
|
|
||||||
const documentUrl = this.generateDocumentUrl(relativePath)
|
const documentUrl = this.generateDocumentUrl(relativePath)
|
||||||
|
|
||||||
// Split content into chunks
|
|
||||||
const textChunks = await this.splitContent(markdownContent)
|
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}`)
|
logger.info(`Generating embeddings for ${textChunks.length} chunks in ${relativePath}`)
|
||||||
const embeddings = textChunks.length > 0 ? await generateEmbeddings(textChunks) : []
|
const embeddings = textChunks.length > 0 ? await generateEmbeddings(textChunks) : []
|
||||||
const embeddingModel = 'text-embedding-3-small'
|
const embeddingModel = 'text-embedding-3-small'
|
||||||
|
|
||||||
// Convert to DocChunk objects with header context and embeddings
|
|
||||||
const chunks: DocChunk[] = []
|
const chunks: DocChunk[] = []
|
||||||
let currentPosition = 0
|
let currentPosition = 0
|
||||||
|
|
||||||
@@ -100,7 +92,6 @@ export class DocsChunker {
|
|||||||
const chunkStart = currentPosition
|
const chunkStart = currentPosition
|
||||||
const chunkEnd = currentPosition + chunkText.length
|
const chunkEnd = currentPosition + chunkText.length
|
||||||
|
|
||||||
// Find the most relevant header for this chunk
|
|
||||||
const relevantHeader = this.findRelevantHeader(headers, chunkStart)
|
const relevantHeader = this.findRelevantHeader(headers, chunkStart)
|
||||||
|
|
||||||
const chunk: DocChunk = {
|
const chunk: DocChunk = {
|
||||||
@@ -186,11 +177,21 @@ export class DocsChunker {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate document URL from relative path
|
* Generate document URL from relative path
|
||||||
|
* Handles index.mdx files specially - they are served at the parent directory path
|
||||||
*/
|
*/
|
||||||
private generateDocumentUrl(relativePath: string): string {
|
private generateDocumentUrl(relativePath: string): string {
|
||||||
// Convert file path to URL path
|
// Convert file path to URL path
|
||||||
// e.g., "tools/knowledge.mdx" -> "/tools/knowledge"
|
// 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}`
|
return `${this.baseUrl}/${urlPath}`
|
||||||
}
|
}
|
||||||
@@ -201,7 +202,6 @@ export class DocsChunker {
|
|||||||
private findRelevantHeader(headers: HeaderInfo[], position: number): HeaderInfo | null {
|
private findRelevantHeader(headers: HeaderInfo[], position: number): HeaderInfo | null {
|
||||||
if (headers.length === 0) return null
|
if (headers.length === 0) return null
|
||||||
|
|
||||||
// Find the last header that comes before this position
|
|
||||||
let relevantHeader: HeaderInfo | null = null
|
let relevantHeader: HeaderInfo | null = null
|
||||||
|
|
||||||
for (const header of headers) {
|
for (const header of headers) {
|
||||||
@@ -219,23 +219,18 @@ export class DocsChunker {
|
|||||||
* Split content into chunks using the existing TextChunker with table awareness
|
* Split content into chunks using the existing TextChunker with table awareness
|
||||||
*/
|
*/
|
||||||
private async splitContent(content: string): Promise<string[]> {
|
private async splitContent(content: string): Promise<string[]> {
|
||||||
// Clean the content first
|
|
||||||
const cleanedContent = this.cleanContent(content)
|
const cleanedContent = this.cleanContent(content)
|
||||||
|
|
||||||
// Detect table boundaries to avoid splitting them
|
|
||||||
const tableBoundaries = this.detectTableBoundaries(cleanedContent)
|
const tableBoundaries = this.detectTableBoundaries(cleanedContent)
|
||||||
|
|
||||||
// Use the existing TextChunker
|
|
||||||
const chunks = await this.textChunker.chunk(cleanedContent)
|
const chunks = await this.textChunker.chunk(cleanedContent)
|
||||||
|
|
||||||
// Post-process chunks to ensure tables aren't split
|
|
||||||
const processedChunks = this.mergeTableChunks(
|
const processedChunks = this.mergeTableChunks(
|
||||||
chunks.map((chunk) => chunk.text),
|
chunks.map((chunk) => chunk.text),
|
||||||
tableBoundaries,
|
tableBoundaries,
|
||||||
cleanedContent
|
cleanedContent
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure no chunk exceeds 300 tokens
|
|
||||||
const finalChunks = this.enforceSizeLimit(processedChunks)
|
const finalChunks = this.enforceSizeLimit(processedChunks)
|
||||||
|
|
||||||
return finalChunks
|
return finalChunks
|
||||||
@@ -273,7 +268,6 @@ export class DocsChunker {
|
|||||||
const [, frontmatterText, markdownContent] = match
|
const [, frontmatterText, markdownContent] = match
|
||||||
const data: Frontmatter = {}
|
const data: Frontmatter = {}
|
||||||
|
|
||||||
// Simple YAML parsing for title and description
|
|
||||||
const lines = frontmatterText.split('\n')
|
const lines = frontmatterText.split('\n')
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const colonIndex = line.indexOf(':')
|
const colonIndex = line.indexOf(':')
|
||||||
@@ -294,7 +288,6 @@ export class DocsChunker {
|
|||||||
* Estimate token count (rough approximation)
|
* Estimate token count (rough approximation)
|
||||||
*/
|
*/
|
||||||
private estimateTokens(text: string): number {
|
private estimateTokens(text: string): number {
|
||||||
// Rough approximation: 1 token ≈ 4 characters
|
|
||||||
return Math.ceil(text.length / 4)
|
return Math.ceil(text.length / 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,17 +304,13 @@ export class DocsChunker {
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i].trim()
|
const line = lines[i].trim()
|
||||||
|
|
||||||
// Detect table start (markdown table row with pipes)
|
|
||||||
if (line.includes('|') && line.split('|').length >= 3 && !inTable) {
|
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()
|
const nextLine = lines[i + 1]?.trim()
|
||||||
if (nextLine?.includes('|') && nextLine.includes('-')) {
|
if (nextLine?.includes('|') && nextLine.includes('-')) {
|
||||||
inTable = true
|
inTable = true
|
||||||
tableStart = i
|
tableStart = i
|
||||||
}
|
}
|
||||||
}
|
} else if (inTable && (!line.includes('|') || line === '' || line.startsWith('#'))) {
|
||||||
// Detect table end (empty line or non-table content)
|
|
||||||
else if (inTable && (!line.includes('|') || line === '' || line.startsWith('#'))) {
|
|
||||||
tables.push({
|
tables.push({
|
||||||
start: this.getCharacterPosition(lines, tableStart),
|
start: this.getCharacterPosition(lines, tableStart),
|
||||||
end: this.getCharacterPosition(lines, i - 1) + lines[i - 1]?.length || 0,
|
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) {
|
if (inTable && tableStart >= 0) {
|
||||||
tables.push({
|
tables.push({
|
||||||
start: this.getCharacterPosition(lines, tableStart),
|
start: this.getCharacterPosition(lines, tableStart),
|
||||||
@@ -367,7 +355,6 @@ export class DocsChunker {
|
|||||||
const chunkStart = originalContent.indexOf(chunk, currentPosition)
|
const chunkStart = originalContent.indexOf(chunk, currentPosition)
|
||||||
const chunkEnd = chunkStart + chunk.length
|
const chunkEnd = chunkStart + chunk.length
|
||||||
|
|
||||||
// Check if this chunk intersects with any table
|
|
||||||
const intersectsTable = tableBoundaries.some(
|
const intersectsTable = tableBoundaries.some(
|
||||||
(table) =>
|
(table) =>
|
||||||
(chunkStart >= table.start && chunkStart <= table.end) ||
|
(chunkStart >= table.start && chunkStart <= table.end) ||
|
||||||
@@ -376,7 +363,6 @@ export class DocsChunker {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (intersectsTable) {
|
if (intersectsTable) {
|
||||||
// Find which table(s) this chunk intersects with
|
|
||||||
const affectedTables = tableBoundaries.filter(
|
const affectedTables = tableBoundaries.filter(
|
||||||
(table) =>
|
(table) =>
|
||||||
(chunkStart >= table.start && chunkStart <= table.end) ||
|
(chunkStart >= table.start && chunkStart <= table.end) ||
|
||||||
@@ -384,12 +370,10 @@ export class DocsChunker {
|
|||||||
(chunkStart <= table.start && chunkEnd >= table.end)
|
(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 minStart = Math.min(chunkStart, ...affectedTables.map((t) => t.start))
|
||||||
const maxEnd = Math.max(chunkEnd, ...affectedTables.map((t) => t.end))
|
const maxEnd = Math.max(chunkEnd, ...affectedTables.map((t) => t.end))
|
||||||
const completeChunk = originalContent.slice(minStart, maxEnd)
|
const completeChunk = originalContent.slice(minStart, maxEnd)
|
||||||
|
|
||||||
// Only add if we haven't already included this content
|
|
||||||
if (!mergedChunks.some((existing) => existing.includes(completeChunk.trim()))) {
|
if (!mergedChunks.some((existing) => existing.includes(completeChunk.trim()))) {
|
||||||
mergedChunks.push(completeChunk.trim())
|
mergedChunks.push(completeChunk.trim())
|
||||||
}
|
}
|
||||||
@@ -400,7 +384,7 @@ export class DocsChunker {
|
|||||||
currentPosition = chunkEnd
|
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)
|
const tokens = this.estimateTokens(chunk)
|
||||||
|
|
||||||
if (tokens <= 300) {
|
if (tokens <= 300) {
|
||||||
// Chunk is within limit
|
|
||||||
finalChunks.push(chunk)
|
finalChunks.push(chunk)
|
||||||
} else {
|
} else {
|
||||||
// Chunk is too large - split it
|
|
||||||
const lines = chunk.split('\n')
|
const lines = chunk.split('\n')
|
||||||
let currentChunk = ''
|
let currentChunk = ''
|
||||||
|
|
||||||
@@ -426,7 +408,6 @@ export class DocsChunker {
|
|||||||
if (this.estimateTokens(testChunk) <= 300) {
|
if (this.estimateTokens(testChunk) <= 300) {
|
||||||
currentChunk = testChunk
|
currentChunk = testChunk
|
||||||
} else {
|
} else {
|
||||||
// Adding this line would exceed limit
|
|
||||||
if (currentChunk.trim()) {
|
if (currentChunk.trim()) {
|
||||||
finalChunks.push(currentChunk.trim())
|
finalChunks.push(currentChunk.trim())
|
||||||
}
|
}
|
||||||
@@ -434,7 +415,6 @@ export class DocsChunker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add final chunk if it has content
|
|
||||||
if (currentChunk.trim()) {
|
if (currentChunk.trim()) {
|
||||||
finalChunks.push(currentChunk.trim())
|
finalChunks.push(currentChunk.trim())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`, {
|
const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ variables: variablesArray }),
|
body: JSON.stringify({ variables: variablesRecord }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text().catch(() => '')
|
const txt = await res.text().catch(() => '')
|
||||||
|
|||||||
@@ -817,6 +817,8 @@ function normalizeResponseFormat(value: any): string {
|
|||||||
interface EdgeHandleValidationResult {
|
interface EdgeHandleValidationResult {
|
||||||
valid: boolean
|
valid: boolean
|
||||||
error?: string
|
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': {
|
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
|
const conditionsValue = sourceBlock?.subBlocks?.conditions?.value
|
||||||
if (!conditionsValue) {
|
if (!conditionsValue) {
|
||||||
return {
|
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)
|
return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,13 +876,6 @@ function validateSourceHandleForBlock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'router_v2': {
|
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
|
const routesValue = sourceBlock?.subBlocks?.routes?.value
|
||||||
if (!routesValue) {
|
if (!routesValue) {
|
||||||
return {
|
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)
|
return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,7 +902,12 @@ function validateSourceHandleForBlock(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates condition handle references a valid condition in the block.
|
* 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(
|
function validateConditionHandle(
|
||||||
sourceHandle: string,
|
sourceHandle: string,
|
||||||
@@ -943,48 +940,80 @@ function validateConditionHandle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const validHandles = new Set<string>()
|
// Build a map of all valid handle formats -> normalized handle (condition-{conditionId})
|
||||||
const semanticPrefix = `condition-${blockId}-`
|
const handleToNormalized = new Map<string, string>()
|
||||||
let elseIfCount = 0
|
const legacySemanticPrefix = `condition-${blockId}-`
|
||||||
|
let elseIfIndex = 0
|
||||||
|
|
||||||
for (const condition of conditions) {
|
for (const condition of conditions) {
|
||||||
if (condition.id) {
|
if (!condition.id) continue
|
||||||
validHandles.add(`condition-${condition.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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()
|
const title = condition.title?.toLowerCase()
|
||||||
if (title === 'if') {
|
if (title === 'if') {
|
||||||
validHandles.add(`${semanticPrefix}if`)
|
simpleOptions.push('if')
|
||||||
} else if (title === 'else if') {
|
} else if (title === 'else if') {
|
||||||
elseIfCount++
|
simpleOptions.push(`else-if-${elseIfIndex}`)
|
||||||
validHandles.add(
|
elseIfIndex++
|
||||||
elseIfCount === 1 ? `${semanticPrefix}else-if` : `${semanticPrefix}else-if-${elseIfCount}`
|
|
||||||
)
|
|
||||||
} else if (title === 'else') {
|
} 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 {
|
return {
|
||||||
valid: false,
|
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.
|
* 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(
|
function validateRouterHandle(
|
||||||
sourceHandle: string,
|
sourceHandle: string,
|
||||||
@@ -1017,47 +1046,48 @@ function validateRouterHandle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const validHandles = new Set<string>()
|
// Build a map of all valid handle formats -> normalized handle (router-{routeId})
|
||||||
const semanticPrefix = `router-${blockId}-`
|
const handleToNormalized = new Map<string, string>()
|
||||||
|
const legacySemanticPrefix = `router-${blockId}-`
|
||||||
|
|
||||||
for (let i = 0; i < routes.length; i++) {
|
for (let i = 0; i < routes.length; i++) {
|
||||||
const route = routes[i]
|
const route = routes[i]
|
||||||
|
if (!route.id) continue
|
||||||
|
|
||||||
// Accept internal ID format: router-{uuid}
|
const normalizedHandle = `router-${route.id}`
|
||||||
if (route.id) {
|
|
||||||
validHandles.add(`router-${route.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept 1-indexed route number format: router-{blockId}-route-1, router-{blockId}-route-2, etc.
|
// Always accept internal ID format: router-{uuid}
|
||||||
validHandles.add(`${semanticPrefix}route-${i + 1}`)
|
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}
|
// Accept normalized title format: router-{blockId}-{normalized-title}
|
||||||
// Normalize: lowercase, replace spaces with dashes, remove special chars
|
|
||||||
if (route.title && typeof route.title === 'string') {
|
if (route.title && typeof route.title === 'string') {
|
||||||
const normalizedTitle = route.title
|
const normalizedTitle = route.title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/[^a-z0-9-]/g, '')
|
.replace(/[^a-z0-9-]/g, '')
|
||||||
if (normalizedTitle) {
|
if (normalizedTitle) {
|
||||||
validHandles.add(`${semanticPrefix}${normalizedTitle}`)
|
handleToNormalized.set(`${legacySemanticPrefix}${normalizedTitle}`, normalizedHandle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validHandles.has(sourceHandle)) {
|
const normalizedHandle = handleToNormalized.get(sourceHandle)
|
||||||
return { valid: true }
|
if (normalizedHandle) {
|
||||||
|
return { valid: true, normalizedHandle }
|
||||||
}
|
}
|
||||||
|
|
||||||
const validOptions = Array.from(validHandles).slice(0, 5)
|
// Build list of valid simple format options for error message
|
||||||
const moreCount = validHandles.size - validOptions.length
|
const simpleOptions = routes.map((_, i) => `route-${i}`)
|
||||||
let validOptionsStr = validOptions.join(', ')
|
|
||||||
if (moreCount > 0) {
|
|
||||||
validOptionsStr += `, ... and ${moreCount} more`
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: false,
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}')
|
||||||
|
const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle
|
||||||
|
|
||||||
modifiedState.edges.push({
|
modifiedState.edges.push({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
source: sourceBlockId,
|
source: sourceBlockId,
|
||||||
sourceHandle,
|
sourceHandle: finalSourceHandle,
|
||||||
target: targetBlockId,
|
target: targetBlockId,
|
||||||
targetHandle,
|
targetHandle,
|
||||||
type: 'default',
|
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(
|
function addConnectionsAsEdges(
|
||||||
modifiedState: any,
|
modifiedState: any,
|
||||||
@@ -1194,19 +1231,34 @@ function addConnectionsAsEdges(
|
|||||||
skippedItems?: SkippedItem[]
|
skippedItems?: SkippedItem[]
|
||||||
): void {
|
): void {
|
||||||
Object.entries(connections).forEach(([sourceHandle, targets]) => {
|
Object.entries(connections).forEach(([sourceHandle, targets]) => {
|
||||||
const targetArray = Array.isArray(targets) ? targets : [targets]
|
if (targets === null) return
|
||||||
targetArray.forEach((targetId: string) => {
|
|
||||||
|
const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => {
|
||||||
createValidatedEdge(
|
createValidatedEdge(
|
||||||
modifiedState,
|
modifiedState,
|
||||||
blockId,
|
blockId,
|
||||||
targetId,
|
targetBlock,
|
||||||
sourceHandle,
|
sourceHandle,
|
||||||
'target',
|
targetHandle || 'target',
|
||||||
'add_edge',
|
'add_edge',
|
||||||
logger,
|
logger,
|
||||||
skippedItems
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,32 +326,32 @@ export const env = createEnv({
|
|||||||
|
|
||||||
NEXT_PUBLIC_E2B_ENABLED: z.string().optional(),
|
NEXT_PUBLIC_E2B_ENABLED: z.string().optional(),
|
||||||
NEXT_PUBLIC_COPILOT_TRAINING_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_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL
|
||||||
NEXT_PUBLIC_TERMS_URL: z.string().url().optional(), // Custom terms of service 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
|
NEXT_PUBLIC_PRIVACY_URL: z.string().url().optional(), // Custom privacy policy URL
|
||||||
|
|
||||||
// Theme Customization
|
// 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_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_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_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)
|
NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Brand background color (hex format)
|
||||||
|
|
||||||
// Feature Flags
|
// Feature Flags
|
||||||
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
|
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_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_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_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_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_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
|
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
|
// Variables available on both server and client
|
||||||
shared: {
|
shared: {
|
||||||
NODE_ENV: z.enum(['development', 'test', 'production']).optional(), // Runtime environment
|
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: {
|
experimental__runtimeEnv: {
|
||||||
|
|||||||
@@ -152,15 +152,20 @@ function addUnsubscribeData(
|
|||||||
): UnsubscribeData {
|
): UnsubscribeData {
|
||||||
const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType)
|
const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType)
|
||||||
const baseUrl = getBaseUrl()
|
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 {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
'List-Unsubscribe': `<${unsubscribeUrl}>`,
|
'List-Unsubscribe': `<${unsubscribeUrl}>`,
|
||||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||||
},
|
},
|
||||||
html: html?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
|
html: html
|
||||||
text: text?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
|
?.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,
|
subject: email.subject,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.html) emailData.html = email.html
|
|
||||||
if (email.text) emailData.text = email.text
|
|
||||||
|
|
||||||
if (includeUnsubscribe && emailType !== 'transactional') {
|
if (includeUnsubscribe && emailType !== 'transactional') {
|
||||||
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
|
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
|
||||||
const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text)
|
const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text)
|
||||||
emailData.headers = unsubData.headers
|
emailData.headers = unsubData.headers
|
||||||
if (unsubData.html) emailData.html = unsubData.html
|
if (unsubData.html) emailData.html = unsubData.html
|
||||||
if (unsubData.text) emailData.text = unsubData.text
|
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)
|
batchEmails.push(emailData)
|
||||||
|
|||||||
@@ -114,17 +114,15 @@ describe('unsubscribe utilities', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.concurrent('should handle legacy tokens (2 parts) and default to marketing', () => {
|
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 salt = 'abc123'
|
||||||
const secret = 'test-secret-key'
|
const secret = 'test-secret-key'
|
||||||
const { createHash } = require('crypto')
|
const { createHash } = require('crypto')
|
||||||
const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex')
|
const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex')
|
||||||
const legacyToken = `${salt}:${hash}`
|
const legacyToken = `${salt}:${hash}`
|
||||||
|
|
||||||
// This should return valid since we're using the actual legacy format properly
|
|
||||||
const result = verifyUnsubscribeToken(testEmail, legacyToken)
|
const result = verifyUnsubscribeToken(testEmail, legacyToken)
|
||||||
expect(result.valid).toBe(true)
|
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', () => {
|
it.concurrent('should reject malformed tokens', () => {
|
||||||
@@ -226,7 +224,6 @@ describe('unsubscribe utilities', () => {
|
|||||||
it('should update email preferences for existing user', async () => {
|
it('should update email preferences for existing user', async () => {
|
||||||
const userId = 'user-123'
|
const userId = 'user-123'
|
||||||
|
|
||||||
// Mock finding the user
|
|
||||||
mockDb.select.mockReturnValueOnce({
|
mockDb.select.mockReturnValueOnce({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
@@ -235,7 +232,6 @@ describe('unsubscribe utilities', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock getting existing settings
|
|
||||||
mockDb.select.mockReturnValueOnce({
|
mockDb.select.mockReturnValueOnce({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
@@ -244,7 +240,6 @@ describe('unsubscribe utilities', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock insert with upsert
|
|
||||||
mockDb.insert.mockReturnValue({
|
mockDb.insert.mockReturnValue({
|
||||||
values: vi.fn().mockReturnValue({
|
values: vi.fn().mockReturnValue({
|
||||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -300,7 +295,6 @@ describe('unsubscribe utilities', () => {
|
|||||||
|
|
||||||
await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
|
await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
|
||||||
|
|
||||||
// Verify that the merged preferences are passed
|
|
||||||
expect(mockInsertValues).toHaveBeenCalledWith(
|
expect(mockInsertValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
emailPreferences: {
|
emailPreferences: {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export function verifyUnsubscribeToken(
|
|||||||
const parts = token.split(':')
|
const parts = token.split(':')
|
||||||
if (parts.length < 2) return { valid: false }
|
if (parts.length < 2) return { valid: false }
|
||||||
|
|
||||||
// Handle legacy tokens (without email type)
|
|
||||||
if (parts.length === 2) {
|
if (parts.length === 2) {
|
||||||
const [salt, expectedHash] = parts
|
const [salt, expectedHash] = parts
|
||||||
const hash = createHash('sha256')
|
const hash = createHash('sha256')
|
||||||
@@ -48,7 +47,6 @@ export function verifyUnsubscribeToken(
|
|||||||
return { valid: hash === expectedHash, emailType: 'marketing' }
|
return { valid: hash === expectedHash, emailType: 'marketing' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new tokens (with email type)
|
|
||||||
const [salt, expectedHash, emailType] = parts
|
const [salt, expectedHash, emailType] = parts
|
||||||
if (!salt || !expectedHash || !emailType) return { valid: false }
|
if (!salt || !expectedHash || !emailType) return { valid: false }
|
||||||
|
|
||||||
@@ -101,7 +99,6 @@ export async function updateEmailPreferences(
|
|||||||
preferences: EmailPreferences
|
preferences: EmailPreferences
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// First, find the user
|
|
||||||
const userResult = await db
|
const userResult = await db
|
||||||
.select({ id: user.id })
|
.select({ id: user.id })
|
||||||
.from(user)
|
.from(user)
|
||||||
@@ -115,7 +112,6 @@ export async function updateEmailPreferences(
|
|||||||
|
|
||||||
const userId = userResult[0].id
|
const userId = userResult[0].id
|
||||||
|
|
||||||
// Get existing email preferences
|
|
||||||
const existingSettings = await db
|
const existingSettings = await db
|
||||||
.select({ emailPreferences: settings.emailPreferences })
|
.select({ emailPreferences: settings.emailPreferences })
|
||||||
.from(settings)
|
.from(settings)
|
||||||
@@ -127,13 +123,11 @@ export async function updateEmailPreferences(
|
|||||||
currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {}
|
currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge email preferences
|
|
||||||
const updatedEmailPreferences = {
|
const updatedEmailPreferences = {
|
||||||
...currentEmailPreferences,
|
...currentEmailPreferences,
|
||||||
...preferences,
|
...preferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert settings
|
|
||||||
await db
|
await db
|
||||||
.insert(settings)
|
.insert(settings)
|
||||||
.values({
|
.values({
|
||||||
@@ -168,10 +162,8 @@ export async function isUnsubscribed(
|
|||||||
const preferences = await getEmailPreferences(email)
|
const preferences = await getEmailPreferences(email)
|
||||||
if (!preferences) return false
|
if (!preferences) return false
|
||||||
|
|
||||||
// Check unsubscribe all first
|
|
||||||
if (preferences.unsubscribeAll) return true
|
if (preferences.unsubscribeAll) return true
|
||||||
|
|
||||||
// Check specific type
|
|
||||||
switch (emailType) {
|
switch (emailType) {
|
||||||
case 'marketing':
|
case 'marketing':
|
||||||
return preferences.unsubscribeMarketing || false
|
return preferences.unsubscribeMarketing || false
|
||||||
|
|||||||
193
apps/sim/lib/search/tool-operations.ts
Normal file
193
apps/sim/lib/search/tool-operations.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,18 +1,41 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { member, templateCreators, templates, user } from '@sim/db/schema'
|
import { member, settings, templateCreators, templates, user } from '@sim/db/schema'
|
||||||
import { and, eq, or } from 'drizzle-orm'
|
import { and, eq, or } from 'drizzle-orm'
|
||||||
|
|
||||||
export type CreatorPermissionLevel = 'member' | 'admin'
|
export type CreatorPermissionLevel = 'member' | 'admin'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies if a user is a super user.
|
* Verifies if a user is an effective super user (database flag AND settings toggle).
|
||||||
|
* This should be used for features that can be disabled by the user's settings toggle.
|
||||||
*
|
*
|
||||||
* @param userId - The ID of the user to check
|
* @param userId - The ID of the user to check
|
||||||
* @returns Object with isSuperUser boolean
|
* @returns Object with effectiveSuperUser boolean and component values
|
||||||
*/
|
*/
|
||||||
export async function verifySuperUser(userId: string): Promise<{ isSuperUser: boolean }> {
|
export async function verifyEffectiveSuperUser(userId: string): Promise<{
|
||||||
const [currentUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
|
effectiveSuperUser: boolean
|
||||||
return { isSuperUser: currentUser?.isSuperUser || false }
|
isSuperUser: boolean
|
||||||
|
superUserModeEnabled: boolean
|
||||||
|
}> {
|
||||||
|
const [currentUser] = await db
|
||||||
|
.select({ isSuperUser: user.isSuperUser })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const [userSettings] = await db
|
||||||
|
.select({ superUserModeEnabled: settings.superUserModeEnabled })
|
||||||
|
.from(settings)
|
||||||
|
.where(eq(settings.userId, userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const isSuperUser = currentUser?.isSuperUser || false
|
||||||
|
const superUserModeEnabled = userSettings?.superUserModeEnabled ?? false
|
||||||
|
|
||||||
|
return {
|
||||||
|
effectiveSuperUser: isSuperUser && superUserModeEnabled,
|
||||||
|
isSuperUser,
|
||||||
|
superUserModeEnabled,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
handle: string,
|
||||||
blockId: string,
|
_blockId: string,
|
||||||
block: BlockState
|
block: BlockState
|
||||||
): string {
|
): string {
|
||||||
if (!handle.startsWith('condition-')) {
|
if (!handle.startsWith('condition-')) {
|
||||||
@@ -300,27 +301,24 @@ function convertConditionHandleToSemantic(
|
|||||||
return handle
|
return handle
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the condition by ID and generate semantic handle
|
// Find the condition by ID and generate simple handle
|
||||||
let elseIfCount = 0
|
let elseIfIndex = 0
|
||||||
for (const condition of conditions) {
|
for (const condition of conditions) {
|
||||||
const title = condition.title?.toLowerCase()
|
const title = condition.title?.toLowerCase()
|
||||||
if (condition.id === conditionId) {
|
if (condition.id === conditionId) {
|
||||||
if (title === 'if') {
|
if (title === 'if') {
|
||||||
return `condition-${blockId}-if`
|
return 'if'
|
||||||
}
|
}
|
||||||
if (title === 'else if') {
|
if (title === 'else if') {
|
||||||
elseIfCount++
|
return `else-if-${elseIfIndex}`
|
||||||
return elseIfCount === 1
|
|
||||||
? `condition-${blockId}-else-if`
|
|
||||||
: `condition-${blockId}-else-if-${elseIfCount}`
|
|
||||||
}
|
}
|
||||||
if (title === 'else') {
|
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') {
|
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-')) {
|
if (!handle.startsWith('router-')) {
|
||||||
return handle
|
return handle
|
||||||
}
|
}
|
||||||
@@ -356,10 +355,10 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
|
|||||||
return handle
|
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++) {
|
for (let i = 0; i < routes.length; i++) {
|
||||||
if (routes[i].id === routeId) {
|
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') {
|
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') {
|
if (handle.startsWith('router-') && block.type === 'router_v2') {
|
||||||
return convertRouterHandleToSemantic(handle, blockId, block)
|
return convertRouterHandleToSimple(handle, blockId, block)
|
||||||
}
|
}
|
||||||
|
|
||||||
return handle
|
return handle
|
||||||
@@ -400,12 +400,12 @@ function extractConnectionsForBlock(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group by source handle (converting to semantic format)
|
// Group by source handle (converting to simple format)
|
||||||
for (const edge of outgoingEdges) {
|
for (const edge of outgoingEdges) {
|
||||||
let handle = edge.sourceHandle || 'source'
|
let handle = edge.sourceHandle || 'source'
|
||||||
|
|
||||||
// Convert internal UUID handles to semantic format
|
// Convert internal UUID handles to simple format (if, else-if-0, route-0, etc.)
|
||||||
handle = convertToSemanticHandle(handle, blockId, block)
|
handle = convertToSimpleHandle(handle, blockId, block)
|
||||||
|
|
||||||
if (!connections[handle]) {
|
if (!connections[handle]) {
|
||||||
connections[handle] = []
|
connections[handle] = []
|
||||||
|
|||||||
@@ -163,12 +163,13 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
|||||||
try {
|
try {
|
||||||
const errorMessage = String(newEntry.error)
|
const errorMessage = String(newEntry.error)
|
||||||
const blockName = newEntry.blockName || 'Unknown Block'
|
const blockName = newEntry.blockName || 'Unknown Block'
|
||||||
|
const displayMessage = `${blockName}: ${errorMessage}`
|
||||||
|
|
||||||
const copilotMessage = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
|
const copilotMessage = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
|
||||||
|
|
||||||
useNotificationStore.getState().addNotification({
|
useNotificationStore.getState().addNotification({
|
||||||
level: 'error',
|
level: 'error',
|
||||||
message: errorMessage,
|
message: displayMessage,
|
||||||
workflowId: entry.workflowId,
|
workflowId: entry.workflowId,
|
||||||
action: {
|
action: {
|
||||||
type: 'copilot',
|
type: 'copilot',
|
||||||
|
|||||||
394
apps/sim/stores/undo-redo/utils.test.ts
Normal file
394
apps/sim/stores/undo-redo/utils.test.ts
Normal 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 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Edge } from 'reactflow'
|
||||||
import { UNDO_REDO_OPERATIONS } from '@/socket/constants'
|
import { UNDO_REDO_OPERATIONS } from '@/socket/constants'
|
||||||
import type {
|
import type {
|
||||||
BatchAddBlocksOperation,
|
BatchAddBlocksOperation,
|
||||||
@@ -9,6 +10,8 @@ import type {
|
|||||||
Operation,
|
Operation,
|
||||||
OperationEntry,
|
OperationEntry,
|
||||||
} from '@/stores/undo-redo/types'
|
} 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 {
|
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
|
||||||
return {
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
url: 'https://api.browser-use.com/api/v1/run-task',
|
url: 'https://api.browser-use.com/api/v2/tasks',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: (params) => ({
|
headers: (params) => ({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${params.apiKey}`,
|
'X-Browser-Use-API-Key': params.apiKey,
|
||||||
}),
|
}),
|
||||||
body: (params) => {
|
body: (params) => {
|
||||||
const requestBody: Record<string, any> = {
|
const requestBody: Record<string, any> = {
|
||||||
@@ -121,12 +121,15 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
|
|||||||
let liveUrlLogged = false
|
let liveUrlLogged = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const initialTaskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, {
|
const initialTaskResponse = await fetch(
|
||||||
method: 'GET',
|
`https://api.browser-use.com/api/v2/tasks/${taskId}`,
|
||||||
headers: {
|
{
|
||||||
Authorization: `Bearer ${params.apiKey}`,
|
method: 'GET',
|
||||||
},
|
headers: {
|
||||||
})
|
'X-Browser-Use-API-Key': params.apiKey,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (initialTaskResponse.ok) {
|
if (initialTaskResponse.ok) {
|
||||||
const initialTaskData = await initialTaskResponse.json()
|
const initialTaskData = await initialTaskResponse.json()
|
||||||
@@ -145,60 +148,36 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
|
|||||||
|
|
||||||
while (elapsedTime < MAX_POLL_TIME_MS) {
|
while (elapsedTime < MAX_POLL_TIME_MS) {
|
||||||
try {
|
try {
|
||||||
const statusResponse = await fetch(
|
const statusResponse = await fetch(`https://api.browser-use.com/api/v2/tasks/${taskId}`, {
|
||||||
`https://api.browser-use.com/api/v1/task/${taskId}/status`,
|
method: 'GET',
|
||||||
{
|
headers: {
|
||||||
method: 'GET',
|
'X-Browser-Use-API-Key': params.apiKey,
|
||||||
headers: {
|
},
|
||||||
Authorization: `Bearer ${params.apiKey}`,
|
})
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!statusResponse.ok) {
|
if (!statusResponse.ok) {
|
||||||
throw new Error(`Failed to get task status: ${statusResponse.statusText}`)
|
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}`)
|
logger.info(`BrowserUse task ${taskId} status: ${status}`)
|
||||||
|
|
||||||
if (['finished', 'failed', 'stopped'].includes(status)) {
|
if (['finished', 'failed', 'stopped'].includes(status)) {
|
||||||
const taskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, {
|
result.output = {
|
||||||
method: 'GET',
|
id: taskId,
|
||||||
headers: {
|
success: status === 'finished',
|
||||||
Authorization: `Bearer ${params.apiKey}`,
|
output: taskData.output ?? null,
|
||||||
},
|
steps: taskData.steps || [],
|
||||||
})
|
|
||||||
|
|
||||||
if (taskResponse.ok) {
|
|
||||||
const taskData = await taskResponse.json()
|
|
||||||
result.output = {
|
|
||||||
id: taskId,
|
|
||||||
success: status === 'finished',
|
|
||||||
output: taskData.output,
|
|
||||||
steps: taskData.steps || [],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!liveUrlLogged && status === 'running') {
|
if (!liveUrlLogged && status === 'running' && taskData.live_url) {
|
||||||
const taskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, {
|
logger.info(`BrowserUse task ${taskId} running with live URL: ${taskData.live_url}`)
|
||||||
method: 'GET',
|
liveUrlLogged = true
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const calendlyInviteeCanceledTrigger: TriggerConfig = {
|
|||||||
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
'<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.',
|
'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.',
|
'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.',
|
'This webhook triggers when an invitee cancels an event. The payload includes cancellation details and reason.',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const calendlyInviteeCreatedTrigger: TriggerConfig = {
|
|||||||
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
'<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.',
|
'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.',
|
'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.',
|
'This webhook triggers when an invitee schedules a new event. Rescheduling triggers both cancellation and creation events.',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const calendlyRoutingFormSubmittedTrigger: TriggerConfig = {
|
|||||||
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
'<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.',
|
'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.',
|
'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.',
|
'This webhook triggers when someone submits a routing form, regardless of whether they book an event.',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const calendlyWebhookTrigger: TriggerConfig = {
|
|||||||
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
|
'<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.',
|
'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.',
|
'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.',
|
'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(
|
.map(
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export const stripeWebhookTrigger: TriggerConfig = {
|
|||||||
'Click "Create Destination" to save',
|
'Click "Create Destination" to save',
|
||||||
'After creating the endpoint, click "Reveal" next to "Signing secret" and copy it',
|
'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',
|
'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(
|
.map(
|
||||||
(instruction, index) =>
|
(instruction, index) =>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const telegramWebhookTrigger: TriggerConfig = {
|
|||||||
defaultValue: [
|
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.',
|
'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.',
|
'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(
|
.map(
|
||||||
(instruction, index) =>
|
(instruction, index) =>
|
||||||
|
|||||||
@@ -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>)',
|
'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',
|
'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',
|
'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',
|
'<strong>Note:</strong> Requires a Typeform PRO or PRO+ account to use webhooks',
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
25
bun.lock
25
bun.lock
@@ -10,6 +10,7 @@
|
|||||||
"@octokit/rest": "^21.0.0",
|
"@octokit/rest": "^21.0.0",
|
||||||
"@tailwindcss/typography": "0.5.19",
|
"@tailwindcss/typography": "0.5.19",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
|
"glob": "13.0.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "16.0.0",
|
"lint-staged": "16.0.0",
|
||||||
"turbo": "2.7.4",
|
"turbo": "2.7.4",
|
||||||
@@ -2237,7 +2238,7 @@
|
|||||||
|
|
||||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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=="],
|
"@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=="],
|
"@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=="],
|
"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/@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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=="],
|
"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=="],
|
"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/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=="],
|
"thriftrw/long": ["long@2.4.0", "", {}, "sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ=="],
|
||||||
|
|
||||||
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
"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=="],
|
"@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/@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=="],
|
"@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/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=="],
|
"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=="],
|
"sim/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"@octokit/rest": "^21.0.0",
|
"@octokit/rest": "^21.0.0",
|
||||||
"@tailwindcss/typography": "0.5.19",
|
"@tailwindcss/typography": "0.5.19",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
|
"glob": "13.0.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "16.0.0",
|
"lint-staged": "16.0.0",
|
||||||
"turbo": "2.7.4"
|
"turbo": "2.7.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user