mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 20:08:04 -05:00
Compare commits
3 Commits
feat/api
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65f4d3da72 | ||
|
|
c8280cdfff | ||
|
|
f336395f98 |
@@ -19,7 +19,7 @@ export interface RateLimitResult {
|
||||
|
||||
export async function checkRateLimit(
|
||||
request: NextRequest,
|
||||
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs'
|
||||
endpoint: 'logs' | 'logs-detail' = 'logs'
|
||||
): Promise<RateLimitResult> {
|
||||
try {
|
||||
const auth = await authenticateV1Request(request)
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflow, workflowBlocks } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1WorkflowDetailsAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'workflow-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { id } = await params
|
||||
|
||||
logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId })
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
color: workflow.color,
|
||||
folderId: workflow.folderId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
isDeployed: workflow.isDeployed,
|
||||
deployedAt: workflow.deployedAt,
|
||||
runCount: workflow.runCount,
|
||||
lastRunAt: workflow.lastRunAt,
|
||||
variables: workflow.variables,
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
})
|
||||
.from(workflow)
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(eq(workflow.id, id))
|
||||
.limit(1)
|
||||
|
||||
const workflowData = rows[0]
|
||||
if (!workflowData) {
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const blockRows = await db
|
||||
.select({
|
||||
id: workflowBlocks.id,
|
||||
type: workflowBlocks.type,
|
||||
subBlocks: workflowBlocks.subBlocks,
|
||||
})
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.workflowId, id))
|
||||
|
||||
const blocksRecord = Object.fromEntries(
|
||||
blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }])
|
||||
)
|
||||
const inputs = extractInputFieldsFromBlocks(blocksRecord)
|
||||
|
||||
const response = {
|
||||
id: workflowData.id,
|
||||
name: workflowData.name,
|
||||
description: workflowData.description,
|
||||
color: workflowData.color,
|
||||
folderId: workflowData.folderId,
|
||||
workspaceId: workflowData.workspaceId,
|
||||
isDeployed: workflowData.isDeployed,
|
||||
deployedAt: workflowData.deployedAt?.toISOString() || null,
|
||||
runCount: workflowData.runCount,
|
||||
lastRunAt: workflowData.lastRunAt?.toISOString() || null,
|
||||
variables: workflowData.variables || {},
|
||||
inputs,
|
||||
createdAt: workflowData.createdAt.toISOString(),
|
||||
updatedAt: workflowData.updatedAt.toISOString(),
|
||||
}
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
|
||||
|
||||
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] Workflow details fetch error`, { error: message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, eq, gt, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1WorkflowsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
const QueryParamsSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
folderId: z.string().optional(),
|
||||
deployedOnly: z.coerce.boolean().optional().default(false),
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||
cursor: z.string().optional(),
|
||||
})
|
||||
|
||||
interface CursorData {
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
id: string
|
||||
}
|
||||
|
||||
function encodeCursor(data: CursorData): string {
|
||||
return Buffer.from(JSON.stringify(data)).toString('base64')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): CursorData | null {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(cursor, 'base64').toString())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'workflows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const { searchParams } = new URL(request.url)
|
||||
const rawParams = Object.fromEntries(searchParams.entries())
|
||||
|
||||
const validationResult = QueryParamsSchema.safeParse(rawParams)
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid parameters', details: validationResult.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const params = validationResult.data
|
||||
|
||||
logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, {
|
||||
userId,
|
||||
filters: {
|
||||
folderId: params.folderId,
|
||||
deployedOnly: params.deployedOnly,
|
||||
},
|
||||
})
|
||||
|
||||
const conditions = [
|
||||
eq(workflow.workspaceId, params.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, params.workspaceId),
|
||||
eq(permissions.userId, userId),
|
||||
]
|
||||
|
||||
if (params.folderId) {
|
||||
conditions.push(eq(workflow.folderId, params.folderId))
|
||||
}
|
||||
|
||||
if (params.deployedOnly) {
|
||||
conditions.push(eq(workflow.isDeployed, true))
|
||||
}
|
||||
|
||||
if (params.cursor) {
|
||||
const cursorData = decodeCursor(params.cursor)
|
||||
if (cursorData) {
|
||||
const cursorCondition = or(
|
||||
gt(workflow.sortOrder, cursorData.sortOrder),
|
||||
and(
|
||||
eq(workflow.sortOrder, cursorData.sortOrder),
|
||||
gt(workflow.createdAt, new Date(cursorData.createdAt))
|
||||
),
|
||||
and(
|
||||
eq(workflow.sortOrder, cursorData.sortOrder),
|
||||
eq(workflow.createdAt, new Date(cursorData.createdAt)),
|
||||
gt(workflow.id, cursorData.id)
|
||||
)
|
||||
)
|
||||
if (cursorCondition) {
|
||||
conditions.push(cursorCondition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
color: workflow.color,
|
||||
folderId: workflow.folderId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
isDeployed: workflow.isDeployed,
|
||||
deployedAt: workflow.deployedAt,
|
||||
runCount: workflow.runCount,
|
||||
lastRunAt: workflow.lastRunAt,
|
||||
sortOrder: workflow.sortOrder,
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
})
|
||||
.from(workflow)
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, params.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(...orderByClause)
|
||||
.limit(params.limit + 1)
|
||||
|
||||
const hasMore = rows.length > params.limit
|
||||
const data = rows.slice(0, params.limit)
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (hasMore && data.length > 0) {
|
||||
const lastWorkflow = data[data.length - 1]
|
||||
nextCursor = encodeCursor({
|
||||
sortOrder: lastWorkflow.sortOrder,
|
||||
createdAt: lastWorkflow.createdAt.toISOString(),
|
||||
id: lastWorkflow.id,
|
||||
})
|
||||
}
|
||||
|
||||
const formattedWorkflows = data.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
description: w.description,
|
||||
color: w.color,
|
||||
folderId: w.folderId,
|
||||
workspaceId: w.workspaceId,
|
||||
isDeployed: w.isDeployed,
|
||||
deployedAt: w.deployedAt?.toISOString() || null,
|
||||
runCount: w.runCount,
|
||||
lastRunAt: w.lastRunAt?.toISOString() || null,
|
||||
createdAt: w.createdAt.toISOString(),
|
||||
updatedAt: w.updatedAt.toISOString(),
|
||||
}))
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
const response = createApiResponse(
|
||||
{
|
||||
data: formattedWorkflows,
|
||||
nextCursor,
|
||||
},
|
||||
limits,
|
||||
rateLimit
|
||||
)
|
||||
|
||||
return NextResponse.json(response.body, { headers: response.headers })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] Workflows fetch error`, { error: message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,6 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
|
||||
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Parse special tags from content
|
||||
*/
|
||||
/**
|
||||
* Plan step can be either a string or an object with title and plan
|
||||
*/
|
||||
@@ -47,6 +44,56 @@ interface ParsedTags {
|
||||
cleanContent: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plan steps from plan_respond tool calls in subagent blocks.
|
||||
* Returns { steps, isComplete } where steps is in the format expected by PlanSteps component.
|
||||
*/
|
||||
function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
||||
steps: Record<string, PlanStep> | undefined
|
||||
isComplete: boolean
|
||||
} {
|
||||
if (!blocks) return { steps: undefined, isComplete: false }
|
||||
|
||||
// Find the plan_respond tool call
|
||||
const planRespondBlock = blocks.find(
|
||||
(b) => b.type === 'subagent_tool_call' && b.toolCall?.name === 'plan_respond'
|
||||
)
|
||||
|
||||
if (!planRespondBlock?.toolCall) {
|
||||
return { steps: undefined, isComplete: false }
|
||||
}
|
||||
|
||||
// Tool call arguments can be in different places depending on the source
|
||||
// Also handle nested data.arguments structure from the schema
|
||||
const tc = planRespondBlock.toolCall as any
|
||||
const args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {}
|
||||
const stepsArray = args.steps
|
||||
|
||||
if (!Array.isArray(stepsArray) || stepsArray.length === 0) {
|
||||
return { steps: undefined, isComplete: false }
|
||||
}
|
||||
|
||||
// Convert array format to Record<string, PlanStep> format
|
||||
// From: [{ number: 1, title: "..." }, { number: 2, title: "..." }]
|
||||
// To: { "1": "...", "2": "..." }
|
||||
const steps: Record<string, PlanStep> = {}
|
||||
for (const step of stepsArray) {
|
||||
if (step.number !== undefined && step.title) {
|
||||
steps[String(step.number)] = step.title
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the tool call is complete (not pending/executing)
|
||||
const isComplete =
|
||||
planRespondBlock.toolCall.state === ClientToolCallState.success ||
|
||||
planRespondBlock.toolCall.state === ClientToolCallState.error
|
||||
|
||||
return {
|
||||
steps: Object.keys(steps).length > 0 ? steps : undefined,
|
||||
isComplete,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse partial JSON for streaming options.
|
||||
* Attempts to extract complete key-value pairs from incomplete JSON.
|
||||
@@ -654,11 +701,20 @@ function SubAgentThinkingContent({
|
||||
}
|
||||
}
|
||||
|
||||
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
|
||||
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(blocks)
|
||||
const allParsed = parseSpecialTags(allRawText)
|
||||
|
||||
if (!cleanText.trim() && !allParsed.plan) return null
|
||||
// Prefer plan_respond tool data over <plan> tags
|
||||
const hasPlan =
|
||||
!!(planSteps && Object.keys(planSteps).length > 0) ||
|
||||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
|
||||
const planToRender = planSteps || allParsed.plan
|
||||
const isPlanStreaming = planSteps ? !planComplete : isStreaming
|
||||
|
||||
const hasSpecialTags = !!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
|
||||
if (!cleanText.trim() && !hasPlan) return null
|
||||
|
||||
const hasSpecialTags = hasPlan
|
||||
|
||||
return (
|
||||
<div className='space-y-1.5'>
|
||||
@@ -670,9 +726,7 @@ function SubAgentThinkingContent({
|
||||
hasSpecialTags={hasSpecialTags}
|
||||
/>
|
||||
)}
|
||||
{allParsed.plan && Object.keys(allParsed.plan).length > 0 && (
|
||||
<PlanSteps steps={allParsed.plan} streaming={isStreaming} />
|
||||
)}
|
||||
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -744,8 +798,19 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
}
|
||||
|
||||
const allParsed = parseSpecialTags(allRawText)
|
||||
|
||||
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
|
||||
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(
|
||||
toolCall.subAgentBlocks
|
||||
)
|
||||
const hasPlan =
|
||||
!!(planSteps && Object.keys(planSteps).length > 0) ||
|
||||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
|
||||
const planToRender = planSteps || allParsed.plan
|
||||
const isPlanStreaming = planSteps ? !planComplete : isStreaming
|
||||
|
||||
const hasSpecialTags = !!(
|
||||
(allParsed.plan && Object.keys(allParsed.plan).length > 0) ||
|
||||
hasPlan ||
|
||||
(allParsed.options && Object.keys(allParsed.options).length > 0)
|
||||
)
|
||||
|
||||
@@ -757,8 +822,6 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
const outerLabel = getSubagentCompletionLabel(toolCall.name)
|
||||
const durationText = `${outerLabel} for ${formatDuration(duration)}`
|
||||
|
||||
const hasPlan = allParsed.plan && Object.keys(allParsed.plan).length > 0
|
||||
|
||||
const renderCollapsibleContent = () => (
|
||||
<>
|
||||
{segments.map((segment, index) => {
|
||||
@@ -800,7 +863,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
return (
|
||||
<div className='w-full space-y-1.5'>
|
||||
{renderCollapsibleContent()}
|
||||
{hasPlan && <PlanSteps steps={allParsed.plan!} streaming={isStreaming} />}
|
||||
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -832,7 +895,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
</div>
|
||||
|
||||
{/* Plan stays outside the collapsible */}
|
||||
{hasPlan && <PlanSteps steps={allParsed.plan!} />}
|
||||
{hasPlan && planToRender && <PlanSteps steps={planToRender} />}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1412,7 +1475,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
if (
|
||||
toolCall.name === 'checkoff_todo' ||
|
||||
toolCall.name === 'mark_todo_in_progress' ||
|
||||
toolCall.name === 'tool_search_tool_regex'
|
||||
toolCall.name === 'tool_search_tool_regex' ||
|
||||
toolCall.name === 'user_memory' ||
|
||||
toolCall.name === 'edit_responsd' ||
|
||||
toolCall.name === 'debug_respond' ||
|
||||
toolCall.name === 'plan_respond'
|
||||
)
|
||||
return null
|
||||
|
||||
|
||||
@@ -452,6 +452,39 @@ console.log(limits);`
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div>
|
||||
<div className='mb-[6.5px] flex items-center justify-between'>
|
||||
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
URL
|
||||
</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleCopy('endpoint', info.endpoint)}
|
||||
aria-label='Copy endpoint'
|
||||
className='!p-1.5 -my-1.5'
|
||||
>
|
||||
{copied.endpoint ? (
|
||||
<Check className='h-3 w-3' />
|
||||
) : (
|
||||
<Clipboard className='h-3 w-3' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{copied.endpoint ? 'Copied' : 'Copy'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Code.Viewer
|
||||
code={info.endpoint}
|
||||
language='javascript'
|
||||
wrapText
|
||||
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
<div>
|
||||
<div className='mb-[6.5px] flex items-center justify-between'>
|
||||
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
type NormalizedField = InputFormatField & { name: string }
|
||||
|
||||
interface ApiInfoModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalProps) {
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const setValue = useSubBlockStore((state) => state.setValue)
|
||||
const subBlockValues = useSubBlockStore((state) =>
|
||||
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
|
||||
)
|
||||
|
||||
const workflowMetadata = useWorkflowRegistry((state) =>
|
||||
workflowId ? state.workflows[workflowId] : undefined
|
||||
)
|
||||
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
|
||||
|
||||
const [description, setDescription] = useState('')
|
||||
const [paramDescriptions, setParamDescriptions] = useState<Record<string, string>>({})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
|
||||
const initialDescriptionRef = useRef('')
|
||||
const initialParamDescriptionsRef = useRef<Record<string, string>>({})
|
||||
|
||||
const starterBlockId = useMemo(() => {
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const blockType = (block as { type?: string }).type
|
||||
if (blockType && isValidStartBlockType(blockType)) {
|
||||
return blockId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [blocks])
|
||||
|
||||
const inputFormat = useMemo((): NormalizedField[] => {
|
||||
if (!starterBlockId) return []
|
||||
|
||||
const storeValue = subBlockValues[starterBlockId]?.inputFormat
|
||||
const normalized = normalizeInputFormatValue(storeValue) as NormalizedField[]
|
||||
if (normalized.length > 0) return normalized
|
||||
|
||||
const startBlock = blocks[starterBlockId]
|
||||
const blockValue = startBlock?.subBlocks?.inputFormat?.value
|
||||
return normalizeInputFormatValue(blockValue) as NormalizedField[]
|
||||
}, [starterBlockId, subBlockValues, blocks])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim()
|
||||
const isDefaultDescription =
|
||||
!workflowMetadata?.description ||
|
||||
workflowMetadata.description === workflowMetadata.name ||
|
||||
normalizedDesc === 'new workflow' ||
|
||||
normalizedDesc === 'your first workflow - start building here!'
|
||||
|
||||
const initialDescription = isDefaultDescription ? '' : workflowMetadata?.description || ''
|
||||
setDescription(initialDescription)
|
||||
initialDescriptionRef.current = initialDescription
|
||||
|
||||
const descriptions: Record<string, string> = {}
|
||||
for (const field of inputFormat) {
|
||||
if (field.description) {
|
||||
descriptions[field.name] = field.description
|
||||
}
|
||||
}
|
||||
setParamDescriptions(descriptions)
|
||||
initialParamDescriptionsRef.current = { ...descriptions }
|
||||
}
|
||||
}, [open, workflowMetadata, inputFormat])
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (description !== initialDescriptionRef.current) return true
|
||||
|
||||
for (const field of inputFormat) {
|
||||
const currentValue = (paramDescriptions[field.name] || '').trim()
|
||||
const initialValue = (initialParamDescriptionsRef.current[field.name] || '').trim()
|
||||
if (currentValue !== initialValue) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, [description, paramDescriptions, inputFormat])
|
||||
|
||||
const handleParamDescriptionChange = (fieldName: string, value: string) => {
|
||||
setParamDescriptions((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleCloseAttempt = useCallback(() => {
|
||||
if (hasChanges && !isSaving) {
|
||||
setShowUnsavedChangesAlert(true)
|
||||
} else {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}, [hasChanges, isSaving, onOpenChange])
|
||||
|
||||
const handleDiscardChanges = useCallback(() => {
|
||||
setShowUnsavedChangesAlert(false)
|
||||
setDescription(initialDescriptionRef.current)
|
||||
setParamDescriptions({ ...initialParamDescriptionsRef.current })
|
||||
onOpenChange(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!workflowId) return
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (activeWorkflowId !== workflowId) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (description.trim() !== (workflowMetadata?.description || '')) {
|
||||
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
|
||||
}
|
||||
|
||||
if (starterBlockId) {
|
||||
const updatedValue = inputFormat.map((field) => ({
|
||||
...field,
|
||||
description: paramDescriptions[field.name]?.trim() || undefined,
|
||||
}))
|
||||
setValue(starterBlockId, 'inputFormat', updatedValue)
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [
|
||||
workflowId,
|
||||
description,
|
||||
workflowMetadata,
|
||||
updateWorkflow,
|
||||
starterBlockId,
|
||||
inputFormat,
|
||||
paramDescriptions,
|
||||
setValue,
|
||||
onOpenChange,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
|
||||
<ModalContent className='max-w-[480px]'>
|
||||
<ModalHeader>
|
||||
<span>Edit API Info</span>
|
||||
</ModalHeader>
|
||||
<ModalBody className='space-y-[12px]'>
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder='Describe what this workflow API does...'
|
||||
className='min-h-[80px] resize-none'
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inputFormat.length > 0 && (
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Parameters ({inputFormat.length})
|
||||
</Label>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{inputFormat.map((field) => (
|
||||
<div
|
||||
key={field.name}
|
||||
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
|
||||
>
|
||||
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{field.name}
|
||||
</span>
|
||||
<Badge size='sm'>{field.type || 'string'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<Label className='text-[13px]'>Description</Label>
|
||||
<Input
|
||||
value={paramDescriptions[field.name] || ''}
|
||||
onChange={(e) =>
|
||||
handleParamDescriptionChange(field.name, e.target.value)
|
||||
}
|
||||
placeholder={`Enter description for ${field.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={handleCloseAttempt} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={handleSave} disabled={isSaving || !hasChanges}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
||||
<ModalContent className='max-w-[400px]'>
|
||||
<ModalHeader>
|
||||
<span>Unsaved Changes</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[14px] text-[var(--text-secondary)]'>
|
||||
You have unsaved changes. Are you sure you want to discard them?
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
|
||||
Keep Editing
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDiscardChanges}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -43,7 +43,6 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { A2aDeploy } from './components/a2a/a2a'
|
||||
import { ApiDeploy } from './components/api/api'
|
||||
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
||||
import { ApiInfoModal } from './components/general/components/api-info-modal'
|
||||
import { GeneralDeploy } from './components/general/general'
|
||||
import { McpDeploy } from './components/mcp/mcp'
|
||||
import { TemplateDeploy } from './components/template/template'
|
||||
@@ -111,7 +110,6 @@ export function DeployModal({
|
||||
const [chatSuccess, setChatSuccess] = useState(false)
|
||||
|
||||
const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false)
|
||||
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
@@ -391,6 +389,11 @@ export function DeployModal({
|
||||
form?.requestSubmit()
|
||||
}, [])
|
||||
|
||||
const handleA2aFormSubmit = useCallback(() => {
|
||||
const form = document.getElementById('a2a-deploy-form') as HTMLFormElement
|
||||
form?.requestSubmit()
|
||||
}, [])
|
||||
|
||||
const handleA2aPublish = useCallback(() => {
|
||||
const form = document.getElementById('a2a-deploy-form')
|
||||
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
|
||||
@@ -591,11 +594,7 @@ export function DeployModal({
|
||||
)}
|
||||
{activeTab === 'api' && (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div>
|
||||
<Button variant='default' onClick={() => setIsApiInfoModalOpen(true)}>
|
||||
Edit API Info
|
||||
</Button>
|
||||
</div>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
@@ -881,14 +880,6 @@ export function DeployModal({
|
||||
canManageWorkspaceKeys={canManageWorkspaceKeys}
|
||||
defaultKeyType={defaultKeyType}
|
||||
/>
|
||||
|
||||
{workflowId && (
|
||||
<ApiInfoModal
|
||||
open={isApiInfoModalOpen}
|
||||
onOpenChange={setIsApiInfoModalOpen}
|
||||
workflowId={workflowId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -209,13 +209,17 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
|
||||
}
|
||||
}
|
||||
|
||||
const variablesArray = Object.values(byName)
|
||||
// Convert byName (keyed by name) to record keyed by ID for the API
|
||||
const variablesRecord: Record<string, any> = {}
|
||||
for (const v of Object.values(byName)) {
|
||||
variablesRecord[v.id] = v
|
||||
}
|
||||
|
||||
// POST full variables array to persist
|
||||
// POST full variables record to persist
|
||||
const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ variables: variablesArray }),
|
||||
body: JSON.stringify({ variables: variablesRecord }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
|
||||
@@ -817,6 +817,8 @@ function normalizeResponseFormat(value: any): string {
|
||||
interface EdgeHandleValidationResult {
|
||||
valid: boolean
|
||||
error?: string
|
||||
/** The normalized handle to use (e.g., simple 'if' normalized to 'condition-{uuid}') */
|
||||
normalizedHandle?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -851,13 +853,6 @@ function validateSourceHandleForBlock(
|
||||
}
|
||||
|
||||
case 'condition': {
|
||||
if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "${EDGE.CONDITION_PREFIX}"`,
|
||||
}
|
||||
}
|
||||
|
||||
const conditionsValue = sourceBlock?.subBlocks?.conditions?.value
|
||||
if (!conditionsValue) {
|
||||
return {
|
||||
@@ -866,6 +861,8 @@ function validateSourceHandleForBlock(
|
||||
}
|
||||
}
|
||||
|
||||
// validateConditionHandle accepts simple format (if, else-if-0, else),
|
||||
// legacy format (condition-{blockId}-if), and internal ID format (condition-{uuid})
|
||||
return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue)
|
||||
}
|
||||
|
||||
@@ -879,13 +876,6 @@ function validateSourceHandleForBlock(
|
||||
}
|
||||
|
||||
case 'router_v2': {
|
||||
if (!sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid source handle "${sourceHandle}" for router_v2 block. Must start with "${EDGE.ROUTER_PREFIX}"`,
|
||||
}
|
||||
}
|
||||
|
||||
const routesValue = sourceBlock?.subBlocks?.routes?.value
|
||||
if (!routesValue) {
|
||||
return {
|
||||
@@ -894,6 +884,8 @@ function validateSourceHandleForBlock(
|
||||
}
|
||||
}
|
||||
|
||||
// validateRouterHandle accepts simple format (route-0, route-1),
|
||||
// legacy format (router-{blockId}-route-1), and internal ID format (router-{uuid})
|
||||
return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue)
|
||||
}
|
||||
|
||||
@@ -910,7 +902,12 @@ function validateSourceHandleForBlock(
|
||||
|
||||
/**
|
||||
* Validates condition handle references a valid condition in the block.
|
||||
* Accepts both internal IDs (condition-blockId-if) and semantic keys (condition-blockId-else-if)
|
||||
* Accepts multiple formats:
|
||||
* - Simple format: "if", "else-if-0", "else-if-1", "else"
|
||||
* - Legacy semantic format: "condition-{blockId}-if", "condition-{blockId}-else-if"
|
||||
* - Internal ID format: "condition-{conditionId}"
|
||||
*
|
||||
* Returns the normalized handle (condition-{conditionId}) for storage.
|
||||
*/
|
||||
function validateConditionHandle(
|
||||
sourceHandle: string,
|
||||
@@ -943,48 +940,80 @@ function validateConditionHandle(
|
||||
}
|
||||
}
|
||||
|
||||
const validHandles = new Set<string>()
|
||||
const semanticPrefix = `condition-${blockId}-`
|
||||
let elseIfCount = 0
|
||||
// Build a map of all valid handle formats -> normalized handle (condition-{conditionId})
|
||||
const handleToNormalized = new Map<string, string>()
|
||||
const legacySemanticPrefix = `condition-${blockId}-`
|
||||
let elseIfIndex = 0
|
||||
|
||||
for (const condition of conditions) {
|
||||
if (condition.id) {
|
||||
validHandles.add(`condition-${condition.id}`)
|
||||
}
|
||||
if (!condition.id) continue
|
||||
|
||||
const normalizedHandle = `condition-${condition.id}`
|
||||
const title = condition.title?.toLowerCase()
|
||||
|
||||
// Always accept internal ID format
|
||||
handleToNormalized.set(normalizedHandle, normalizedHandle)
|
||||
|
||||
if (title === 'if') {
|
||||
// Simple format: "if"
|
||||
handleToNormalized.set('if', normalizedHandle)
|
||||
// Legacy format: "condition-{blockId}-if"
|
||||
handleToNormalized.set(`${legacySemanticPrefix}if`, normalizedHandle)
|
||||
} else if (title === 'else if') {
|
||||
// Simple format: "else-if-0", "else-if-1", etc. (0-indexed)
|
||||
handleToNormalized.set(`else-if-${elseIfIndex}`, normalizedHandle)
|
||||
// Legacy format: "condition-{blockId}-else-if" for first, "condition-{blockId}-else-if-2" for second
|
||||
if (elseIfIndex === 0) {
|
||||
handleToNormalized.set(`${legacySemanticPrefix}else-if`, normalizedHandle)
|
||||
} else {
|
||||
handleToNormalized.set(
|
||||
`${legacySemanticPrefix}else-if-${elseIfIndex + 1}`,
|
||||
normalizedHandle
|
||||
)
|
||||
}
|
||||
elseIfIndex++
|
||||
} else if (title === 'else') {
|
||||
// Simple format: "else"
|
||||
handleToNormalized.set('else', normalizedHandle)
|
||||
// Legacy format: "condition-{blockId}-else"
|
||||
handleToNormalized.set(`${legacySemanticPrefix}else`, normalizedHandle)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedHandle = handleToNormalized.get(sourceHandle)
|
||||
if (normalizedHandle) {
|
||||
return { valid: true, normalizedHandle }
|
||||
}
|
||||
|
||||
// Build list of valid simple format options for error message
|
||||
const simpleOptions: string[] = []
|
||||
elseIfIndex = 0
|
||||
for (const condition of conditions) {
|
||||
const title = condition.title?.toLowerCase()
|
||||
if (title === 'if') {
|
||||
validHandles.add(`${semanticPrefix}if`)
|
||||
simpleOptions.push('if')
|
||||
} else if (title === 'else if') {
|
||||
elseIfCount++
|
||||
validHandles.add(
|
||||
elseIfCount === 1 ? `${semanticPrefix}else-if` : `${semanticPrefix}else-if-${elseIfCount}`
|
||||
)
|
||||
simpleOptions.push(`else-if-${elseIfIndex}`)
|
||||
elseIfIndex++
|
||||
} else if (title === 'else') {
|
||||
validHandles.add(`${semanticPrefix}else`)
|
||||
simpleOptions.push('else')
|
||||
}
|
||||
}
|
||||
|
||||
if (validHandles.has(sourceHandle)) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const validOptions = Array.from(validHandles).slice(0, 5)
|
||||
const moreCount = validHandles.size - validOptions.length
|
||||
let validOptionsStr = validOptions.join(', ')
|
||||
if (moreCount > 0) {
|
||||
validOptionsStr += `, ... and ${moreCount} more`
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
|
||||
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates router handle references a valid route in the block.
|
||||
* Accepts both internal IDs (router-{routeId}) and semantic keys (router-{blockId}-route-1)
|
||||
* Accepts multiple formats:
|
||||
* - Simple format: "route-0", "route-1", "route-2" (0-indexed)
|
||||
* - Legacy semantic format: "router-{blockId}-route-1" (1-indexed)
|
||||
* - Internal ID format: "router-{routeId}"
|
||||
*
|
||||
* Returns the normalized handle (router-{routeId}) for storage.
|
||||
*/
|
||||
function validateRouterHandle(
|
||||
sourceHandle: string,
|
||||
@@ -1017,47 +1046,48 @@ function validateRouterHandle(
|
||||
}
|
||||
}
|
||||
|
||||
const validHandles = new Set<string>()
|
||||
const semanticPrefix = `router-${blockId}-`
|
||||
// Build a map of all valid handle formats -> normalized handle (router-{routeId})
|
||||
const handleToNormalized = new Map<string, string>()
|
||||
const legacySemanticPrefix = `router-${blockId}-`
|
||||
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
const route = routes[i]
|
||||
if (!route.id) continue
|
||||
|
||||
// Accept internal ID format: router-{uuid}
|
||||
if (route.id) {
|
||||
validHandles.add(`router-${route.id}`)
|
||||
}
|
||||
const normalizedHandle = `router-${route.id}`
|
||||
|
||||
// Accept 1-indexed route number format: router-{blockId}-route-1, router-{blockId}-route-2, etc.
|
||||
validHandles.add(`${semanticPrefix}route-${i + 1}`)
|
||||
// Always accept internal ID format: router-{uuid}
|
||||
handleToNormalized.set(normalizedHandle, normalizedHandle)
|
||||
|
||||
// Simple format: route-0, route-1, etc. (0-indexed)
|
||||
handleToNormalized.set(`route-${i}`, normalizedHandle)
|
||||
|
||||
// Legacy 1-indexed route number format: router-{blockId}-route-1
|
||||
handleToNormalized.set(`${legacySemanticPrefix}route-${i + 1}`, normalizedHandle)
|
||||
|
||||
// Accept normalized title format: router-{blockId}-{normalized-title}
|
||||
// Normalize: lowercase, replace spaces with dashes, remove special chars
|
||||
if (route.title && typeof route.title === 'string') {
|
||||
const normalizedTitle = route.title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
if (normalizedTitle) {
|
||||
validHandles.add(`${semanticPrefix}${normalizedTitle}`)
|
||||
handleToNormalized.set(`${legacySemanticPrefix}${normalizedTitle}`, normalizedHandle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validHandles.has(sourceHandle)) {
|
||||
return { valid: true }
|
||||
const normalizedHandle = handleToNormalized.get(sourceHandle)
|
||||
if (normalizedHandle) {
|
||||
return { valid: true, normalizedHandle }
|
||||
}
|
||||
|
||||
const validOptions = Array.from(validHandles).slice(0, 5)
|
||||
const moreCount = validHandles.size - validOptions.length
|
||||
let validOptionsStr = validOptions.join(', ')
|
||||
if (moreCount > 0) {
|
||||
validOptionsStr += `, ... and ${moreCount} more`
|
||||
}
|
||||
// Build list of valid simple format options for error message
|
||||
const simpleOptions = routes.map((_, i) => `route-${i}`)
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid router handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
|
||||
error: `Invalid router handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,10 +1202,13 @@ function createValidatedEdge(
|
||||
return false
|
||||
}
|
||||
|
||||
// Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}')
|
||||
const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle
|
||||
|
||||
modifiedState.edges.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: sourceBlockId,
|
||||
sourceHandle,
|
||||
sourceHandle: finalSourceHandle,
|
||||
target: targetBlockId,
|
||||
targetHandle,
|
||||
type: 'default',
|
||||
@@ -1184,7 +1217,11 @@ function createValidatedEdge(
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds connections as edges for a block
|
||||
* Adds connections as edges for a block.
|
||||
* Supports multiple target formats:
|
||||
* - String: "target-block-id"
|
||||
* - Object: { block: "target-block-id", handle?: "custom-target-handle" }
|
||||
* - Array of strings or objects
|
||||
*/
|
||||
function addConnectionsAsEdges(
|
||||
modifiedState: any,
|
||||
@@ -1194,19 +1231,34 @@ function addConnectionsAsEdges(
|
||||
skippedItems?: SkippedItem[]
|
||||
): void {
|
||||
Object.entries(connections).forEach(([sourceHandle, targets]) => {
|
||||
const targetArray = Array.isArray(targets) ? targets : [targets]
|
||||
targetArray.forEach((targetId: string) => {
|
||||
if (targets === null) return
|
||||
|
||||
const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => {
|
||||
createValidatedEdge(
|
||||
modifiedState,
|
||||
blockId,
|
||||
targetId,
|
||||
targetBlock,
|
||||
sourceHandle,
|
||||
'target',
|
||||
targetHandle || 'target',
|
||||
'add_edge',
|
||||
logger,
|
||||
skippedItems
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof targets === 'string') {
|
||||
addEdgeForTarget(targets)
|
||||
} else if (Array.isArray(targets)) {
|
||||
targets.forEach((target: any) => {
|
||||
if (typeof target === 'string') {
|
||||
addEdgeForTarget(target)
|
||||
} else if (target?.block) {
|
||||
addEdgeForTarget(target.block, target.handle)
|
||||
}
|
||||
})
|
||||
} else if (typeof targets === 'object' && targets?.block) {
|
||||
addEdgeForTarget(targets.block, targets.handle)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { InputFormatField } from '@/lib/workflows/types'
|
||||
export interface WorkflowInputField {
|
||||
name: string
|
||||
type: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,7 +37,7 @@ export function extractInputFieldsFromBlocks(
|
||||
if (Array.isArray(inputFormat)) {
|
||||
return inputFormat
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type?: string; description?: string } =>
|
||||
(field: unknown): field is { name: string; type?: string } =>
|
||||
typeof field === 'object' &&
|
||||
field !== null &&
|
||||
'name' in field &&
|
||||
@@ -48,7 +47,6 @@ export function extractInputFieldsFromBlocks(
|
||||
.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type || 'string',
|
||||
...(field.description && { description: field.description }),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -59,7 +57,7 @@ export function extractInputFieldsFromBlocks(
|
||||
if (Array.isArray(legacyFormat)) {
|
||||
return legacyFormat
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type?: string; description?: string } =>
|
||||
(field: unknown): field is { name: string; type?: string } =>
|
||||
typeof field === 'object' &&
|
||||
field !== null &&
|
||||
'name' in field &&
|
||||
@@ -69,7 +67,6 @@ export function extractInputFieldsFromBlocks(
|
||||
.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type || 'string',
|
||||
...(field.description && { description: field.description }),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -269,11 +269,12 @@ function sanitizeSubBlocks(
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal condition handle (condition-{uuid}) to semantic format (condition-{blockId}-if)
|
||||
* Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else)
|
||||
* Uses 0-indexed numbering for else-if conditions
|
||||
*/
|
||||
function convertConditionHandleToSemantic(
|
||||
function convertConditionHandleToSimple(
|
||||
handle: string,
|
||||
blockId: string,
|
||||
_blockId: string,
|
||||
block: BlockState
|
||||
): string {
|
||||
if (!handle.startsWith('condition-')) {
|
||||
@@ -300,27 +301,24 @@ function convertConditionHandleToSemantic(
|
||||
return handle
|
||||
}
|
||||
|
||||
// Find the condition by ID and generate semantic handle
|
||||
let elseIfCount = 0
|
||||
// Find the condition by ID and generate simple handle
|
||||
let elseIfIndex = 0
|
||||
for (const condition of conditions) {
|
||||
const title = condition.title?.toLowerCase()
|
||||
if (condition.id === conditionId) {
|
||||
if (title === 'if') {
|
||||
return `condition-${blockId}-if`
|
||||
return 'if'
|
||||
}
|
||||
if (title === 'else if') {
|
||||
elseIfCount++
|
||||
return elseIfCount === 1
|
||||
? `condition-${blockId}-else-if`
|
||||
: `condition-${blockId}-else-if-${elseIfCount}`
|
||||
return `else-if-${elseIfIndex}`
|
||||
}
|
||||
if (title === 'else') {
|
||||
return `condition-${blockId}-else`
|
||||
return 'else'
|
||||
}
|
||||
}
|
||||
// Count else-ifs as we iterate
|
||||
// Count else-ifs as we iterate (for index tracking)
|
||||
if (title === 'else if') {
|
||||
elseIfCount++
|
||||
elseIfIndex++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,9 +327,10 @@ function convertConditionHandleToSemantic(
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal router handle (router-{uuid}) to semantic format (router-{blockId}-route-N)
|
||||
* Convert internal router handle (router-{uuid}) to simple format (route-0, route-1)
|
||||
* Uses 0-indexed numbering for routes
|
||||
*/
|
||||
function convertRouterHandleToSemantic(handle: string, blockId: string, block: BlockState): string {
|
||||
function convertRouterHandleToSimple(handle: string, _blockId: string, block: BlockState): string {
|
||||
if (!handle.startsWith('router-')) {
|
||||
return handle
|
||||
}
|
||||
@@ -356,10 +355,10 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
|
||||
return handle
|
||||
}
|
||||
|
||||
// Find the route by ID and generate semantic handle (1-indexed)
|
||||
// Find the route by ID and generate simple handle (0-indexed)
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
if (routes[i].id === routeId) {
|
||||
return `router-${blockId}-route-${i + 1}`
|
||||
return `route-${i}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,15 +367,16 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert source handle to semantic format for condition and router blocks
|
||||
* Convert source handle to simple format for condition and router blocks
|
||||
* Outputs: if, else-if-0, else (for conditions) and route-0, route-1 (for routers)
|
||||
*/
|
||||
function convertToSemanticHandle(handle: string, blockId: string, block: BlockState): string {
|
||||
function convertToSimpleHandle(handle: string, blockId: string, block: BlockState): string {
|
||||
if (handle.startsWith('condition-') && block.type === 'condition') {
|
||||
return convertConditionHandleToSemantic(handle, blockId, block)
|
||||
return convertConditionHandleToSimple(handle, blockId, block)
|
||||
}
|
||||
|
||||
if (handle.startsWith('router-') && block.type === 'router_v2') {
|
||||
return convertRouterHandleToSemantic(handle, blockId, block)
|
||||
return convertRouterHandleToSimple(handle, blockId, block)
|
||||
}
|
||||
|
||||
return handle
|
||||
@@ -400,12 +400,12 @@ function extractConnectionsForBlock(
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Group by source handle (converting to semantic format)
|
||||
// Group by source handle (converting to simple format)
|
||||
for (const edge of outgoingEdges) {
|
||||
let handle = edge.sourceHandle || 'source'
|
||||
|
||||
// Convert internal UUID handles to semantic format
|
||||
handle = convertToSemanticHandle(handle, blockId, block)
|
||||
// Convert internal UUID handles to simple format (if, else-if-0, route-0, etc.)
|
||||
handle = convertToSimpleHandle(handle, blockId, block)
|
||||
|
||||
if (!connections[handle]) {
|
||||
connections[handle] = []
|
||||
|
||||
Reference in New Issue
Block a user