mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-13 16:05:09 -05:00
refactor(tool-input): subblock-first rendering, component extraction, bug fixes (#3207)
* refactor(tool-input): eliminate SyncWrappers, add canonical toggle and dependsOn gating Replace 17+ individual SyncWrapper components with a single centralized ToolSubBlockRenderer that bridges the subblock store with StoredTool.params via synthetic store keys. This reduces ~1000 lines of duplicated wrapper code and ensures tool-input renders subblock components identically to the standalone SubBlock path. - Add ToolSubBlockRenderer with bidirectional store sync - Add basic/advanced mode toggle (ArrowLeftRight) using collaborative functions - Add dependsOn gating via useDependsOnGate (fields disable instead of hiding) - Add paramVisibility field to SubBlockConfig for tool-input visibility control - Pass canonicalModeOverrides through getSubBlocksForToolInput - Show (optional) label for non-user-only fields (LLM can inject at runtime) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): restore optional indicator, fix folder selector and canonical toggle, extract components - Attach resolved paramVisibility to subblocks from getSubBlocksForToolInput - Add labelSuffix prop to SubBlock for "(optional)" badge on user-or-llm params - Fix folder selector missing for tools with canonicalParamId (e.g. Google Drive) - Fix canonical toggle not clickable by letting SubBlock handle dependsOn internally - Extract ParameterWithLabel, ToolSubBlockRenderer, ToolCredentialSelector to components/tools/ - Extract StoredTool interface to types.ts, selection helpers to utils.ts - Remove dead code (mcpError, refreshTools, oldParamIds, initialParams) - Strengthen typing: replace any with proper types on icon components and evaluateParameterCondition * add sibling values to subblock context since subblock store isn't relevant in tool input, and removed unused param * cleanup * fix(tool-input): render uncovered tool params alongside subblocks The SubBlock-first rendering path was hard-returning after rendering subblocks, so tool params without matching subblocks (like inputMapping for workflow tools) were never rendered. Now renders subblocks first, then any remaining displayParams not covered by subblocks via the legacy ParameterWithLabel fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): auto-refresh workflow inputs after redeploy After redeploying a child workflow via the stale badge, the workflow state cache was not invalidated, so WorkflowInputMapperInput kept showing stale input fields until page refresh. Now invalidates workflowKeys.state on deploy success. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): correct workflow selector visibility and tighten (optional) spacing - Set workflowId param to user-only in workflow_executor tool config so "Select Workflow" no longer shows "(optional)" indicator - Tighten (optional) label spacing with -ml-[3px] to counteract parent Label's gap-[6px], making it feel inline with the label text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): align (optional) text to baseline instead of center Use items-baseline instead of items-center on Label flex containers so the smaller (optional) text aligns with the label text baseline rather than sitting slightly below it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): increase top padding of expanded tool body Bump the expanded tool body container's top padding from 8px to 12px for more breathing room between the header bar and the first parameter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): apply extra top padding only to SubBlock-first path Revert container padding to py-[8px] (MCP tools were correct). Wrap SubBlock-first output in a div with pt-[4px] so only registry tools get extra breathing room from the container top. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): increase gap between SubBlock params for visual clarity SubBlock's internal gap (10px between label and input) matched the between-parameter gap (10px), making them indistinguishable. Increase the between-parameter gap to 14px so consecutive parameters are visually distinct, matching the separation seen in ParameterWithLabel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix spacing and optional tag * update styling + move predeploy checks earlier for first time deploys * update change detection to account for synthetic tool ids * fix remaining blocks who had files visibility set to hidden * cleanup * add catch --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('useDeployment')
|
||||
|
||||
@@ -35,6 +38,24 @@ export function useDeployment({
|
||||
return { success: true, shouldOpenModal: true }
|
||||
}
|
||||
|
||||
const { blocks, edges, loops, parallels } = useWorkflowStore.getState()
|
||||
const liveBlocks = mergeSubblockState(blocks, workflowId)
|
||||
const checkResult = runPreDeployChecks({
|
||||
blocks: liveBlocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
workflowId,
|
||||
})
|
||||
if (!checkResult.passed) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: checkResult.error || 'Pre-deploy validation failed',
|
||||
workflowId,
|
||||
})
|
||||
return { success: false, shouldOpenModal: false }
|
||||
}
|
||||
|
||||
setIsDeploying(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Combobox } from '@/components/emcn/components'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceConfigByProviderId,
|
||||
OAUTH_PROVIDERS,
|
||||
type OAuthProvider,
|
||||
type OAuthService,
|
||||
@@ -26,6 +27,11 @@ const getProviderIcon = (providerName: OAuthProvider) => {
|
||||
}
|
||||
|
||||
const getProviderName = (providerName: OAuthProvider) => {
|
||||
const serviceConfig = getServiceConfigByProviderId(providerName)
|
||||
if (serviceConfig) {
|
||||
return serviceConfig.name
|
||||
}
|
||||
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
@@ -54,7 +60,7 @@ export function ToolCredentialSelector({
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select account',
|
||||
label,
|
||||
serviceId,
|
||||
disabled = false,
|
||||
}: ToolCredentialSelectorProps) {
|
||||
@@ -64,6 +70,7 @@ export function ToolCredentialSelector({
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
const selectedId = value || ''
|
||||
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
|
||||
|
||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||
|
||||
@@ -203,7 +210,7 @@ export function ToolCredentialSelector({
|
||||
selectedValue={selectedId}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
placeholder={label}
|
||||
placeholder={effectiveLabel}
|
||||
disabled={disabled}
|
||||
editable={true}
|
||||
filterOptions={!isForeign}
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { ArrowLeftRight, ArrowUp } from 'lucide-react'
|
||||
import { Button, Input, Label, Tooltip } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
|
||||
/**
|
||||
* Props for a generic parameter with label component
|
||||
*/
|
||||
export interface ParameterWithLabelProps {
|
||||
paramId: string
|
||||
title: string
|
||||
isRequired: boolean
|
||||
visibility: string
|
||||
wandConfig?: {
|
||||
enabled: boolean
|
||||
prompt?: string
|
||||
placeholder?: string
|
||||
}
|
||||
canonicalToggle?: {
|
||||
mode: 'basic' | 'advanced'
|
||||
disabled?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
disabled: boolean
|
||||
isPreview: boolean
|
||||
children: (wandControlRef: React.MutableRefObject<WandControlHandlers | null>) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic wrapper component for parameters that manages wand state and renders label + input
|
||||
*/
|
||||
export function ParameterWithLabel({
|
||||
paramId,
|
||||
title,
|
||||
isRequired,
|
||||
visibility,
|
||||
wandConfig,
|
||||
canonicalToggle,
|
||||
disabled,
|
||||
isPreview,
|
||||
children,
|
||||
}: ParameterWithLabelProps) {
|
||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const wandControlRef = useRef<WandControlHandlers | null>(null)
|
||||
|
||||
const isWandEnabled = wandConfig?.enabled ?? false
|
||||
const showWand = isWandEnabled && !isPreview && !disabled
|
||||
|
||||
const handleSearchClick = (): void => {
|
||||
setIsSearchActive(true)
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const handleSearchBlur = (): void => {
|
||||
if (!searchQuery.trim() && !wandControlRef.current?.isWandStreaming) {
|
||||
setIsSearchActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchChange = (value: string): void => {
|
||||
setSearchQuery(value)
|
||||
}
|
||||
|
||||
const handleSearchSubmit = (): void => {
|
||||
if (searchQuery.trim() && wandControlRef.current) {
|
||||
wandControlRef.current.onWandTrigger(searchQuery)
|
||||
setSearchQuery('')
|
||||
setIsSearchActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchCancel = (): void => {
|
||||
setSearchQuery('')
|
||||
setIsSearchActive(false)
|
||||
}
|
||||
|
||||
const isStreaming = wandControlRef.current?.isWandStreaming ?? false
|
||||
|
||||
return (
|
||||
<div key={paramId} className='relative min-w-0 space-y-[6px]'>
|
||||
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||
<Label className='flex items-baseline gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{title}
|
||||
{isRequired && visibility === 'user-only' && <span className='ml-0.5'>*</span>}
|
||||
</Label>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
|
||||
{showWand &&
|
||||
(!isSearchActive ? (
|
||||
<Button
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={handleSearchClick}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
) : (
|
||||
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={isStreaming ? 'Generating...' : searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleSearchChange(e.target.value)
|
||||
}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
if (relatedTarget?.closest('button')) return
|
||||
handleSearchBlur()
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) {
|
||||
handleSearchSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
handleSearchCancel()
|
||||
}
|
||||
}}
|
||||
disabled={isStreaming}
|
||||
className={cn(
|
||||
'h-5 min-w-[80px] flex-1 text-[11px]',
|
||||
isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Generate with AI...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!searchQuery.trim() || isStreaming}
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
handleSearchSubmit()
|
||||
}}
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{canonicalToggle && !isPreview && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
onClick={canonicalToggle.onToggle}
|
||||
disabled={canonicalToggle.disabled || disabled}
|
||||
aria-label={
|
||||
canonicalToggle.mode === 'advanced'
|
||||
? 'Switch to selector'
|
||||
: 'Switch to manual ID'
|
||||
}
|
||||
>
|
||||
<ArrowLeftRight
|
||||
className={cn(
|
||||
'!h-[12px] !w-[12px]',
|
||||
canonicalToggle.mode === 'advanced'
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>
|
||||
{canonicalToggle.mode === 'advanced'
|
||||
? 'Switch to selector'
|
||||
: 'Switch to manual ID'}
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative w-full min-w-0'>{children(wandControlRef)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
|
||||
interface ToolSubBlockRendererProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
toolIndex: number
|
||||
subBlock: BlockSubBlockConfig
|
||||
effectiveParamId: string
|
||||
toolParams: Record<string, string> | undefined
|
||||
onParamChange: (toolIndex: number, paramId: string, value: string) => void
|
||||
disabled: boolean
|
||||
canonicalToggle?: {
|
||||
mode: 'basic' | 'advanced'
|
||||
disabled?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SubBlock types whose store values are objects/arrays/non-strings.
|
||||
* tool.params stores strings (via JSON.stringify), so when syncing
|
||||
* back to the store we parse them to restore the native shape.
|
||||
*/
|
||||
const OBJECT_SUBBLOCK_TYPES = new Set(['file-upload', 'table', 'grouped-checkbox-list'])
|
||||
|
||||
/**
|
||||
* Bridges the subblock store with StoredTool.params via a synthetic store key,
|
||||
* then delegates all rendering to SubBlock for full parity.
|
||||
*/
|
||||
export function ToolSubBlockRenderer({
|
||||
blockId,
|
||||
subBlockId,
|
||||
toolIndex,
|
||||
subBlock,
|
||||
effectiveParamId,
|
||||
toolParams,
|
||||
onParamChange,
|
||||
disabled,
|
||||
canonicalToggle,
|
||||
}: ToolSubBlockRendererProps) {
|
||||
const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}`
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId)
|
||||
|
||||
const toolParamValue = toolParams?.[effectiveParamId] ?? ''
|
||||
const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type)
|
||||
|
||||
const lastPushedToStoreRef = useRef<string | null>(null)
|
||||
const lastPushedToParamsRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!toolParamValue && lastPushedToStoreRef.current === null) {
|
||||
lastPushedToStoreRef.current = toolParamValue
|
||||
lastPushedToParamsRef.current = toolParamValue
|
||||
return
|
||||
}
|
||||
if (toolParamValue !== lastPushedToStoreRef.current) {
|
||||
lastPushedToStoreRef.current = toolParamValue
|
||||
lastPushedToParamsRef.current = toolParamValue
|
||||
|
||||
if (isObjectType && typeof toolParamValue === 'string' && toolParamValue) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolParamValue)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
setStoreValue(parsed)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON — fall through to set as string
|
||||
}
|
||||
}
|
||||
setStoreValue(toolParamValue)
|
||||
}
|
||||
}, [toolParamValue, setStoreValue, isObjectType])
|
||||
|
||||
useEffect(() => {
|
||||
if (storeValue == null) return
|
||||
const stringValue = typeof storeValue === 'string' ? storeValue : JSON.stringify(storeValue)
|
||||
if (stringValue !== lastPushedToParamsRef.current) {
|
||||
lastPushedToParamsRef.current = stringValue
|
||||
lastPushedToStoreRef.current = stringValue
|
||||
onParamChange(toolIndex, effectiveParamId, stringValue)
|
||||
}
|
||||
}, [storeValue, toolIndex, effectiveParamId, onParamChange])
|
||||
|
||||
const visibility = subBlock.paramVisibility ?? 'user-or-llm'
|
||||
const isOptionalForUser = visibility !== 'user-only'
|
||||
|
||||
const config = {
|
||||
...subBlock,
|
||||
id: syntheticId,
|
||||
...(isOptionalForUser && { required: false }),
|
||||
}
|
||||
|
||||
return (
|
||||
<SubBlock
|
||||
blockId={blockId}
|
||||
config={config}
|
||||
isPreview={false}
|
||||
disabled={disabled}
|
||||
canonicalToggle={canonicalToggle}
|
||||
dependencyContext={toolParams}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,37 +2,12 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
interface StoredTool {
|
||||
type: string
|
||||
title?: string
|
||||
toolId?: string
|
||||
params?: Record<string, string>
|
||||
customToolId?: string
|
||||
schema?: any
|
||||
code?: string
|
||||
operation?: string
|
||||
usageControl?: 'auto' | 'force' | 'none'
|
||||
}
|
||||
|
||||
const isMcpToolAlreadySelected = (selectedTools: StoredTool[], mcpToolId: string): boolean => {
|
||||
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
|
||||
}
|
||||
|
||||
const isCustomToolAlreadySelected = (
|
||||
selectedTools: StoredTool[],
|
||||
customToolId: string
|
||||
): boolean => {
|
||||
return selectedTools.some(
|
||||
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
|
||||
)
|
||||
}
|
||||
|
||||
const isWorkflowAlreadySelected = (selectedTools: StoredTool[], workflowId: string): boolean => {
|
||||
return selectedTools.some(
|
||||
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
|
||||
)
|
||||
}
|
||||
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
|
||||
import {
|
||||
isCustomToolAlreadySelected,
|
||||
isMcpToolAlreadySelected,
|
||||
isWorkflowAlreadySelected,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils'
|
||||
|
||||
describe('isMcpToolAlreadySelected', () => {
|
||||
describe('basic functionality', () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Represents a tool selected and configured in the workflow
|
||||
*
|
||||
* @remarks
|
||||
* For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
|
||||
* Everything else (title, schema, code) is loaded dynamically from the database.
|
||||
* Legacy custom tools with inline schema/code are still supported for backwards compatibility.
|
||||
*/
|
||||
export interface StoredTool {
|
||||
/** Block type identifier */
|
||||
type: string
|
||||
/** Display title for the tool (optional for new custom tool format) */
|
||||
title?: string
|
||||
/** Direct tool ID for execution (optional for new custom tool format) */
|
||||
toolId?: string
|
||||
/** Parameter values configured by the user (optional for new custom tool format) */
|
||||
params?: Record<string, string>
|
||||
/** Whether the tool details are expanded in UI */
|
||||
isExpanded?: boolean
|
||||
/** Database ID for custom tools (new format - reference only) */
|
||||
customToolId?: string
|
||||
/** Tool schema for custom tools (legacy format - inline JSON schema) */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schema?: Record<string, any>
|
||||
/** Implementation code for custom tools (legacy format - inline) */
|
||||
code?: string
|
||||
/** Selected operation for multi-operation tools */
|
||||
operation?: string
|
||||
/** Tool usage control mode for LLM */
|
||||
usageControl?: 'auto' | 'force' | 'none'
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
|
||||
|
||||
/**
|
||||
* Checks if an MCP tool is already selected.
|
||||
*/
|
||||
export function isMcpToolAlreadySelected(selectedTools: StoredTool[], mcpToolId: string): boolean {
|
||||
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a custom tool is already selected.
|
||||
*/
|
||||
export function isCustomToolAlreadySelected(
|
||||
selectedTools: StoredTool[],
|
||||
customToolId: string
|
||||
): boolean {
|
||||
return selectedTools.some(
|
||||
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a workflow is already selected.
|
||||
*/
|
||||
export function isWorkflowAlreadySelected(
|
||||
selectedTools: StoredTool[],
|
||||
workflowId: string
|
||||
): boolean {
|
||||
return selectedTools.some(
|
||||
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { isEqual } from 'lodash'
|
||||
import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react'
|
||||
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import {
|
||||
CheckboxList,
|
||||
Code,
|
||||
@@ -69,13 +68,15 @@ interface SubBlockProps {
|
||||
isPreview?: boolean
|
||||
subBlockValues?: Record<string, any>
|
||||
disabled?: boolean
|
||||
fieldDiffStatus?: FieldDiffStatus
|
||||
allowExpandInPreview?: boolean
|
||||
canonicalToggle?: {
|
||||
mode: 'basic' | 'advanced'
|
||||
disabled?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
labelSuffix?: React.ReactNode
|
||||
/** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */
|
||||
dependencyContext?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,16 +163,14 @@ const getPreviewValue = (
|
||||
/**
|
||||
* Renders the label with optional validation and description tooltips.
|
||||
*
|
||||
* @remarks
|
||||
* Handles JSON validation indicators for code blocks and required field markers.
|
||||
* Includes inline AI generate button when wand is enabled.
|
||||
*
|
||||
* @param config - The sub-block configuration defining the label content
|
||||
* @param isValidJson - Whether the JSON content is valid (for code blocks)
|
||||
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
|
||||
* @param wandState - Optional state and handlers for the AI wand feature
|
||||
* @param canonicalToggle - Optional canonical toggle metadata and handlers
|
||||
* @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled
|
||||
* @param wandState - State and handlers for the inline AI generate feature
|
||||
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
|
||||
* @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled (includes dependsOn gating)
|
||||
* @param copyState - State and handler for the copy-to-clipboard button
|
||||
* @param labelSuffix - Additional content rendered after the label text
|
||||
* @returns The label JSX element, or `null` for switch types or when no title is defined
|
||||
*/
|
||||
const renderLabel = (
|
||||
@@ -202,7 +201,8 @@ const renderLabel = (
|
||||
showCopyButton: boolean
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
}
|
||||
},
|
||||
labelSuffix?: React.ReactNode
|
||||
): JSX.Element | null => {
|
||||
if (config.type === 'switch') return null
|
||||
if (!config.title) return null
|
||||
@@ -215,9 +215,10 @@ const renderLabel = (
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||
<Label className='flex items-center gap-[6px] whitespace-nowrap'>
|
||||
<Label className='flex items-baseline gap-[6px] whitespace-nowrap'>
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
{labelSuffix}
|
||||
{config.type === 'code' &&
|
||||
config.language === 'json' &&
|
||||
!isValidJson &&
|
||||
@@ -383,28 +384,25 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
|
||||
prevProps.isPreview === nextProps.isPreview &&
|
||||
valueEqual &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
|
||||
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
|
||||
canonicalToggleEqual
|
||||
canonicalToggleEqual &&
|
||||
prevProps.labelSuffix === nextProps.labelSuffix &&
|
||||
prevProps.dependencyContext === nextProps.dependencyContext
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single workflow sub-block input based on config.type.
|
||||
*
|
||||
* @remarks
|
||||
* Supports multiple input types including short-input, long-input, dropdown,
|
||||
* combobox, slider, table, code, switch, tool-input, and many more.
|
||||
* Handles preview mode, disabled states, and AI wand generation.
|
||||
*
|
||||
* @param blockId - The parent block identifier
|
||||
* @param config - Configuration defining the input type and properties
|
||||
* @param isPreview - Whether to render in preview mode
|
||||
* @param subBlockValues - Current values of all subblocks
|
||||
* @param disabled - Whether the input is disabled
|
||||
* @param fieldDiffStatus - Optional diff status for visual indicators
|
||||
* @param allowExpandInPreview - Whether to allow expanding in preview mode
|
||||
* @returns The rendered sub-block input component
|
||||
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
|
||||
* @param labelSuffix - Additional content rendered after the label text
|
||||
* @param dependencyContext - Sibling values for dependency resolution in non-preview contexts (e.g. tool-input)
|
||||
*/
|
||||
function SubBlockComponent({
|
||||
blockId,
|
||||
@@ -412,9 +410,10 @@ function SubBlockComponent({
|
||||
isPreview = false,
|
||||
subBlockValues,
|
||||
disabled = false,
|
||||
fieldDiffStatus,
|
||||
allowExpandInPreview,
|
||||
canonicalToggle,
|
||||
labelSuffix,
|
||||
dependencyContext,
|
||||
}: SubBlockProps): JSX.Element {
|
||||
const [isValidJson, setIsValidJson] = useState(true)
|
||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||
@@ -423,7 +422,6 @@ function SubBlockComponent({
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const wandControlRef = useRef<WandControlHandlers | null>(null)
|
||||
|
||||
// Use webhook management hook when config has useWebhookUrl enabled
|
||||
const webhookManagement = useWebhookManagement({
|
||||
blockId,
|
||||
triggerId: undefined,
|
||||
@@ -510,10 +508,12 @@ function SubBlockComponent({
|
||||
| null
|
||||
| undefined
|
||||
|
||||
const contextValues = dependencyContext ?? (isPreview ? subBlockValues : undefined)
|
||||
|
||||
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
|
||||
disabled,
|
||||
isPreview,
|
||||
previewContextValues: isPreview ? subBlockValues : undefined,
|
||||
previewContextValues: contextValues,
|
||||
})
|
||||
|
||||
const isDisabled = gatedDisabled
|
||||
@@ -797,7 +797,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -809,7 +809,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -821,7 +821,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -833,7 +833,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -845,7 +845,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -868,7 +868,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as any}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -880,7 +880,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as any}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -892,7 +892,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as any}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -917,7 +917,7 @@ function SubBlockComponent({
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as any}
|
||||
disabled={isDisabled}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -953,7 +953,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -987,7 +987,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as any}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -999,7 +999,7 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
||||
previewContextValues={contextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1059,7 +1059,8 @@ function SubBlockComponent({
|
||||
showCopyButton: Boolean(config.showCopyButton && config.useWebhookUrl),
|
||||
copied,
|
||||
onCopy: handleCopy,
|
||||
}
|
||||
},
|
||||
labelSuffix
|
||||
)}
|
||||
{renderInput()}
|
||||
</div>
|
||||
|
||||
@@ -571,7 +571,6 @@ export function Editor() {
|
||||
isPreview={false}
|
||||
subBlockValues={subBlockState}
|
||||
disabled={!canEditBlock}
|
||||
fieldDiffStatus={undefined}
|
||||
allowExpandInPreview={false}
|
||||
canonicalToggle={
|
||||
isCanonicalSwap && canonicalMode && canonicalId
|
||||
@@ -635,7 +634,6 @@ export function Editor() {
|
||||
isPreview={false}
|
||||
subBlockValues={subBlockState}
|
||||
disabled={!canEditBlock}
|
||||
fieldDiffStatus={undefined}
|
||||
allowExpandInPreview={false}
|
||||
/>
|
||||
{index < advancedOnlySubBlocks.length - 1 && (
|
||||
|
||||
@@ -196,6 +196,8 @@ export interface SubBlockConfig {
|
||||
type: SubBlockType
|
||||
mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode
|
||||
canonicalParamId?: string
|
||||
/** Controls parameter visibility in agent/tool-input context */
|
||||
paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden'
|
||||
required?:
|
||||
| boolean
|
||||
| {
|
||||
|
||||
@@ -62,9 +62,12 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
await validateModelProvider(ctx.userId, model, ctx)
|
||||
|
||||
const providerId = getProviderFromModel(model)
|
||||
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
|
||||
const formattedTools = await this.formatTools(
|
||||
ctx,
|
||||
filteredInputs.tools || [],
|
||||
block.canonicalModes
|
||||
)
|
||||
|
||||
// Resolve skill metadata for progressive disclosure
|
||||
const skillInputs = filteredInputs.skills ?? []
|
||||
let skillMetadata: Array<{ name: string; description: string }> = []
|
||||
if (skillInputs.length > 0 && ctx.workspaceId) {
|
||||
@@ -221,7 +224,11 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
})
|
||||
}
|
||||
|
||||
private async formatTools(ctx: ExecutionContext, inputTools: ToolInput[]): Promise<any[]> {
|
||||
private async formatTools(
|
||||
ctx: ExecutionContext,
|
||||
inputTools: ToolInput[],
|
||||
canonicalModes?: Record<string, 'basic' | 'advanced'>
|
||||
): Promise<any[]> {
|
||||
if (!Array.isArray(inputTools)) return []
|
||||
|
||||
const filtered = inputTools.filter((tool) => {
|
||||
@@ -249,7 +256,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
|
||||
return await this.createCustomTool(ctx, tool)
|
||||
}
|
||||
return this.transformBlockTool(ctx, tool)
|
||||
return this.transformBlockTool(ctx, tool, canonicalModes)
|
||||
} catch (error) {
|
||||
logger.error(`[AgentHandler] Error creating tool:`, { tool, error })
|
||||
return null
|
||||
@@ -720,12 +727,17 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async transformBlockTool(ctx: ExecutionContext, tool: ToolInput) {
|
||||
private async transformBlockTool(
|
||||
ctx: ExecutionContext,
|
||||
tool: ToolInput,
|
||||
canonicalModes?: Record<string, 'basic' | 'advanced'>
|
||||
) {
|
||||
const transformedTool = await transformBlockTool(tool, {
|
||||
selectedOperation: tool.operation,
|
||||
getAllBlocks,
|
||||
getToolAsync: (toolId: string) => getToolAsync(toolId, ctx.workflowId),
|
||||
getTool,
|
||||
canonicalModes,
|
||||
})
|
||||
|
||||
if (transformedTool) {
|
||||
|
||||
@@ -642,6 +642,10 @@ export function useDeployChildWorkflow() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowKeys.deploymentStatus(variables.workflowId),
|
||||
})
|
||||
// Invalidate workflow state so tool input mappings refresh
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowKeys.state(variables.workflowId),
|
||||
})
|
||||
// Also invalidate deployment queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.info(variables.workflowId),
|
||||
|
||||
@@ -645,6 +645,18 @@ describe('Workflow Normalization Utilities', () => {
|
||||
const result = filterSubBlockIds(ids)
|
||||
expect(result).toEqual(['signingSecret'])
|
||||
})
|
||||
|
||||
it.concurrent('should exclude synthetic tool-input subBlock IDs', () => {
|
||||
const ids = [
|
||||
'toolConfig',
|
||||
'toolConfig-tool-0-query',
|
||||
'toolConfig-tool-0-url',
|
||||
'toolConfig-tool-1-status',
|
||||
'systemPrompt',
|
||||
]
|
||||
const result = filterSubBlockIds(ids)
|
||||
expect(result).toEqual(['systemPrompt', 'toolConfig'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeTriggerConfigValues', () => {
|
||||
|
||||
@@ -411,7 +411,14 @@ export function extractBlockFieldsForComparison(block: BlockState): ExtractedBlo
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters subBlock IDs to exclude system and trigger runtime subBlocks.
|
||||
* Pattern matching synthetic subBlock IDs created by ToolSubBlockRenderer.
|
||||
* These IDs follow the format `{subBlockId}-tool-{index}-{paramId}` and are
|
||||
* mirrors of values already stored in toolConfig.value.tools[N].params.
|
||||
*/
|
||||
const SYNTHETIC_TOOL_SUBBLOCK_RE = /-tool-\d+-/
|
||||
|
||||
/**
|
||||
* Filters subBlock IDs to exclude system, trigger runtime, and synthetic tool subBlocks.
|
||||
*
|
||||
* @param subBlockIds - Array of subBlock IDs to filter
|
||||
* @returns Filtered and sorted array of subBlock IDs
|
||||
@@ -422,6 +429,7 @@ export function filterSubBlockIds(subBlockIds: string[]): string[] {
|
||||
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) return false
|
||||
if (SYSTEM_SUBBLOCK_IDS.some((sysId) => id === sysId || id.startsWith(`${sysId}_`)))
|
||||
return false
|
||||
if (SYNTHETIC_TOOL_SUBBLOCK_RE.test(id)) return false
|
||||
return true
|
||||
})
|
||||
.sort()
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface ProviderToolConfig {
|
||||
required: string[]
|
||||
}
|
||||
usageControl?: ToolUsageControl
|
||||
/** Block-level params transformer — converts SubBlock values to tool-ready params */
|
||||
paramsTransform?: (params: Record<string, any>) => Record<string, any>
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||
@@ -4,6 +4,12 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
|
||||
import type { CompletionUsage } from 'openai/resources/completions'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
type CanonicalGroup,
|
||||
getCanonicalValues,
|
||||
isCanonicalPair,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { isCustomTool } from '@/executor/constants'
|
||||
import {
|
||||
getComputerUseModels,
|
||||
@@ -437,9 +443,10 @@ export async function transformBlockTool(
|
||||
getAllBlocks: () => any[]
|
||||
getTool: (toolId: string) => any
|
||||
getToolAsync?: (toolId: string) => Promise<any>
|
||||
canonicalModes?: Record<string, 'basic' | 'advanced'>
|
||||
}
|
||||
): Promise<ProviderToolConfig | null> {
|
||||
const { selectedOperation, getAllBlocks, getTool, getToolAsync } = options
|
||||
const { selectedOperation, getAllBlocks, getTool, getToolAsync, canonicalModes } = options
|
||||
|
||||
const blockDef = getAllBlocks().find((b: any) => b.type === block.type)
|
||||
if (!blockDef) {
|
||||
@@ -516,12 +523,66 @@ export async function transformBlockTool(
|
||||
uniqueToolId = `${toolConfig.id}_${userProvidedParams.knowledgeBaseId}`
|
||||
}
|
||||
|
||||
const blockParamsFn = blockDef?.tools?.config?.params as
|
||||
| ((p: Record<string, any>) => Record<string, any>)
|
||||
| undefined
|
||||
const blockInputDefs = blockDef?.inputs as Record<string, any> | undefined
|
||||
|
||||
const canonicalGroups: CanonicalGroup[] = blockDef?.subBlocks
|
||||
? Object.values(buildCanonicalIndex(blockDef.subBlocks).groupsById).filter(isCanonicalPair)
|
||||
: []
|
||||
|
||||
const needsTransform = blockParamsFn || blockInputDefs || canonicalGroups.length > 0
|
||||
const paramsTransform = needsTransform
|
||||
? (params: Record<string, any>): Record<string, any> => {
|
||||
let result = { ...params }
|
||||
|
||||
for (const group of canonicalGroups) {
|
||||
const { basicValue, advancedValue } = getCanonicalValues(group, result)
|
||||
const scopedKey = `${block.type}:${group.canonicalId}`
|
||||
const pairMode = canonicalModes?.[scopedKey] ?? 'basic'
|
||||
const chosen = pairMode === 'advanced' ? advancedValue : basicValue
|
||||
|
||||
const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[]
|
||||
sourceIds.forEach((id) => delete result[id])
|
||||
|
||||
if (chosen !== undefined) {
|
||||
result[group.canonicalId] = chosen
|
||||
}
|
||||
}
|
||||
|
||||
if (blockParamsFn) {
|
||||
const transformed = blockParamsFn(result)
|
||||
result = { ...result, ...transformed }
|
||||
}
|
||||
|
||||
if (blockInputDefs) {
|
||||
for (const [key, schema] of Object.entries(blockInputDefs)) {
|
||||
const value = result[key]
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const inputType = typeof schema === 'object' ? schema.type : schema
|
||||
if (inputType === 'json' || inputType === 'array') {
|
||||
try {
|
||||
result[key] = JSON.parse(value.trim())
|
||||
} catch {
|
||||
// Not valid JSON — keep as string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: uniqueToolId,
|
||||
name: toolName,
|
||||
description: toolDescription,
|
||||
params: userProvidedParams,
|
||||
parameters: llmSchema,
|
||||
paramsTransform,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1028,7 +1089,11 @@ export function getMaxOutputTokensForModel(model: string): number {
|
||||
* Prepare tool execution parameters, separating tool parameters from system parameters
|
||||
*/
|
||||
export function prepareToolExecution(
|
||||
tool: { params?: Record<string, any>; parameters?: Record<string, any> },
|
||||
tool: {
|
||||
params?: Record<string, any>
|
||||
parameters?: Record<string, any>
|
||||
paramsTransform?: (params: Record<string, any>) => Record<string, any>
|
||||
},
|
||||
llmArgs: Record<string, any>,
|
||||
request: {
|
||||
workflowId?: string
|
||||
@@ -1045,8 +1110,15 @@ export function prepareToolExecution(
|
||||
toolParams: Record<string, any>
|
||||
executionParams: Record<string, any>
|
||||
} {
|
||||
// Use centralized merge logic from tools/params
|
||||
const toolParams = mergeToolParameters(tool.params || {}, llmArgs) as Record<string, any>
|
||||
let toolParams = mergeToolParameters(tool.params || {}, llmArgs) as Record<string, any>
|
||||
|
||||
if (tool.paramsTransform) {
|
||||
try {
|
||||
toolParams = tool.paramsTransform(toolParams)
|
||||
} catch (err) {
|
||||
logger.warn('paramsTransform failed, using raw params', { error: err })
|
||||
}
|
||||
}
|
||||
|
||||
const executionParams = {
|
||||
...toolParams,
|
||||
|
||||
@@ -280,7 +280,7 @@ export class Serializer {
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
const serialized: SerializedBlock = {
|
||||
id: block.id,
|
||||
position: block.position,
|
||||
config: {
|
||||
@@ -300,6 +300,12 @@ export class Serializer {
|
||||
},
|
||||
enabled: block.enabled,
|
||||
}
|
||||
|
||||
if (block.data?.canonicalModes) {
|
||||
serialized.canonicalModes = block.data.canonicalModes as Record<string, 'basic' | 'advanced'>
|
||||
}
|
||||
|
||||
return serialized
|
||||
}
|
||||
|
||||
private extractParams(block: BlockState): Record<string, any> {
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface SerializedBlock {
|
||||
color?: string
|
||||
}
|
||||
enabled: boolean
|
||||
/** Canonical mode overrides from block.data (used by agent handler for tool param resolution) */
|
||||
canonicalModes?: Record<string, 'basic' | 'advanced'>
|
||||
}
|
||||
|
||||
export interface SerializedLoop {
|
||||
|
||||
@@ -95,7 +95,7 @@ export const fileParserTool: ToolConfig<FileParserInput, FileParserOutput> = {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
visibility: 'hidden',
|
||||
description: 'Path to the file(s). Can be a single path, URL, or an array of paths.',
|
||||
},
|
||||
file: {
|
||||
|
||||
@@ -36,7 +36,7 @@ export const jiraAddAttachmentTool: ToolConfig<JiraAddAttachmentParams, JiraAddA
|
||||
files: {
|
||||
type: 'file[]',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
visibility: 'user-only',
|
||||
description: 'Files to attach to the Jira issue',
|
||||
},
|
||||
cloudId: {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const linearCreateAttachmentTool: ToolConfig<
|
||||
file: {
|
||||
type: 'file',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
visibility: 'user-only',
|
||||
description: 'File to attach',
|
||||
},
|
||||
title: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
type CanonicalIndex,
|
||||
type CanonicalModeOverrides,
|
||||
evaluateSubBlockCondition,
|
||||
getCanonicalValues,
|
||||
isCanonicalPair,
|
||||
@@ -12,7 +13,10 @@ import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
export {
|
||||
buildCanonicalIndex,
|
||||
type CanonicalIndex,
|
||||
type CanonicalModeOverrides,
|
||||
evaluateSubBlockCondition,
|
||||
isCanonicalPair,
|
||||
resolveCanonicalMode,
|
||||
type SubBlockCondition,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
type CanonicalModeOverrides,
|
||||
evaluateSubBlockCondition,
|
||||
isCanonicalPair,
|
||||
resolveCanonicalMode,
|
||||
type SubBlockCondition,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig, GenerationType } from '@/blocks/types'
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
import { isEmptyTagValue } from '@/tools/shared/tags'
|
||||
import type { ParameterVisibility, ToolConfig } from '@/tools/types'
|
||||
import type { OAuthConfig, ParameterVisibility, ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
const logger = createLogger('ToolsParams')
|
||||
@@ -64,6 +68,14 @@ export interface UIComponentConfig {
|
||||
mode?: 'basic' | 'advanced' | 'both' | 'trigger'
|
||||
/** The actual subblock ID this config was derived from */
|
||||
actualSubBlockId?: string
|
||||
/** Wand configuration for AI assistance */
|
||||
wandConfig?: {
|
||||
enabled: boolean
|
||||
prompt: string
|
||||
generationType?: GenerationType
|
||||
placeholder?: string
|
||||
maintainHistory?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubBlockConfig {
|
||||
@@ -327,6 +339,7 @@ export function getToolParametersConfig(
|
||||
canonicalParamId: subBlock.canonicalParamId,
|
||||
mode: subBlock.mode,
|
||||
actualSubBlockId: subBlock.id,
|
||||
wandConfig: subBlock.wandConfig,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -812,3 +825,200 @@ export function formatParameterLabel(paramId: string): string {
|
||||
// Simple case - just capitalize first letter
|
||||
return paramId.charAt(0).toUpperCase() + paramId.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* SubBlock IDs that are "structural" — they control tool routing or auth,
|
||||
* not user-facing parameters. These are excluded from tool-input rendering
|
||||
* unless they have an explicit paramVisibility set.
|
||||
*/
|
||||
const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation', 'authMethod', 'destinationType'])
|
||||
|
||||
/**
|
||||
* SubBlock types that represent auth/credential inputs handled separately
|
||||
* by the tool-input OAuth credential selector.
|
||||
*/
|
||||
const AUTH_SUBBLOCK_TYPES = new Set(['oauth-input'])
|
||||
|
||||
/**
|
||||
* SubBlock types that should never appear in tool-input context.
|
||||
*/
|
||||
const EXCLUDED_SUBBLOCK_TYPES = new Set([
|
||||
'tool-input',
|
||||
'skill-input',
|
||||
'condition-input',
|
||||
'eval-input',
|
||||
'webhook-config',
|
||||
'schedule-info',
|
||||
'trigger-save',
|
||||
'input-format',
|
||||
'response-format',
|
||||
'mcp-server-selector',
|
||||
'mcp-tool-selector',
|
||||
'mcp-dynamic-args',
|
||||
'input-mapping',
|
||||
'variables-input',
|
||||
'messages-input',
|
||||
'router-input',
|
||||
'text',
|
||||
])
|
||||
|
||||
export interface SubBlocksForToolInput {
|
||||
toolConfig: ToolConfig
|
||||
subBlocks: BlockSubBlockConfig[]
|
||||
oauthConfig?: OAuthConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns filtered SubBlockConfig[] for rendering in tool-input context.
|
||||
* Uses subblock definitions as the primary source of UI metadata,
|
||||
* getting all features (wandConfig, rich conditions, dependsOn, etc.) for free.
|
||||
*
|
||||
* For blocks without paramVisibility annotations, falls back to inferring
|
||||
* visibility from the tool's param definitions.
|
||||
*/
|
||||
export function getSubBlocksForToolInput(
|
||||
toolId: string,
|
||||
blockType: string,
|
||||
currentValues?: Record<string, unknown>,
|
||||
canonicalModeOverrides?: CanonicalModeOverrides
|
||||
): SubBlocksForToolInput | null {
|
||||
try {
|
||||
const toolConfig = getTool(toolId)
|
||||
if (!toolConfig) {
|
||||
logger.warn(`Tool not found: ${toolId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const blockConfigs = getBlockConfigurations()
|
||||
const blockConfig = blockConfigs[blockType]
|
||||
if (!blockConfig?.subBlocks?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allSubBlocks = blockConfig.subBlocks as BlockSubBlockConfig[]
|
||||
const canonicalIndex = buildCanonicalIndex(allSubBlocks)
|
||||
|
||||
// Build values for condition evaluation
|
||||
const values = currentValues || {}
|
||||
const valuesWithOperation = { ...values }
|
||||
if (valuesWithOperation.operation === undefined) {
|
||||
const parts = toolId.split('_')
|
||||
valuesWithOperation.operation =
|
||||
parts.length >= 3 ? parts.slice(2).join('_') : parts[parts.length - 1]
|
||||
}
|
||||
|
||||
// Build a map of tool param IDs to their resolved visibility
|
||||
const toolParamVisibility: Record<string, ParameterVisibility> = {}
|
||||
for (const [paramId, param] of Object.entries(toolConfig.params || {})) {
|
||||
toolParamVisibility[paramId] =
|
||||
param.visibility ?? (param.required ? 'user-or-llm' : 'user-only')
|
||||
}
|
||||
|
||||
// Track which canonical groups we've already included (to avoid duplicates)
|
||||
const includedCanonicalIds = new Set<string>()
|
||||
|
||||
const filtered: BlockSubBlockConfig[] = []
|
||||
|
||||
for (const sb of allSubBlocks) {
|
||||
// Skip excluded types
|
||||
if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue
|
||||
|
||||
// Skip trigger-mode-only subblocks
|
||||
if (sb.mode === 'trigger') continue
|
||||
|
||||
// Determine the effective param ID (canonical or subblock id)
|
||||
const effectiveParamId = sb.canonicalParamId || sb.id
|
||||
|
||||
// Resolve paramVisibility: explicit > inferred from tool params > skip
|
||||
let visibility = sb.paramVisibility
|
||||
if (!visibility) {
|
||||
// Infer from structural checks
|
||||
if (STRUCTURAL_SUBBLOCK_IDS.has(sb.id)) {
|
||||
visibility = 'hidden'
|
||||
} else if (AUTH_SUBBLOCK_TYPES.has(sb.type)) {
|
||||
visibility = 'hidden'
|
||||
} else if (
|
||||
sb.password &&
|
||||
(sb.id === 'botToken' || sb.id === 'accessToken' || sb.id === 'apiKey')
|
||||
) {
|
||||
// Auth tokens without explicit paramVisibility are hidden
|
||||
// (they're handled by the OAuth credential selector or structurally)
|
||||
// But only if they don't have a matching tool param
|
||||
if (!(sb.id in toolParamVisibility)) {
|
||||
visibility = 'hidden'
|
||||
} else {
|
||||
visibility = toolParamVisibility[sb.id] || 'user-or-llm'
|
||||
}
|
||||
} else if (effectiveParamId in toolParamVisibility) {
|
||||
// Fallback: infer from tool param visibility
|
||||
visibility = toolParamVisibility[effectiveParamId]
|
||||
} else if (sb.id in toolParamVisibility) {
|
||||
visibility = toolParamVisibility[sb.id]
|
||||
} else if (sb.canonicalParamId) {
|
||||
// SubBlock has a canonicalParamId that doesn't directly match a tool param.
|
||||
// This means the block's params() function transforms it before sending to the tool
|
||||
// (e.g. listFolderId → folderId). These are user-facing inputs, default to user-or-llm.
|
||||
visibility = 'user-or-llm'
|
||||
} else {
|
||||
// SubBlock has no corresponding tool param — skip it
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by visibility: exclude hidden and llm-only
|
||||
if (visibility === 'hidden' || visibility === 'llm-only') continue
|
||||
|
||||
// Evaluate condition against current values
|
||||
if (sb.condition) {
|
||||
const conditionMet = evaluateSubBlockCondition(
|
||||
sb.condition as SubBlockCondition,
|
||||
valuesWithOperation
|
||||
)
|
||||
if (!conditionMet) continue
|
||||
}
|
||||
|
||||
// Handle canonical pairs: only include the active mode variant
|
||||
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[sb.id]
|
||||
if (canonicalId) {
|
||||
const group = canonicalIndex.groupsById[canonicalId]
|
||||
if (group && isCanonicalPair(group)) {
|
||||
if (includedCanonicalIds.has(canonicalId)) continue
|
||||
includedCanonicalIds.add(canonicalId)
|
||||
|
||||
// Determine active mode
|
||||
const mode = resolveCanonicalMode(group, valuesWithOperation, canonicalModeOverrides)
|
||||
if (mode === 'advanced') {
|
||||
// Find the advanced variant
|
||||
const advancedSb = allSubBlocks.find((s) => group.advancedIds.includes(s.id))
|
||||
if (advancedSb) {
|
||||
filtered.push({ ...advancedSb, paramVisibility: visibility })
|
||||
}
|
||||
} else {
|
||||
// Include basic variant (current sb if it's the basic one)
|
||||
if (group.basicId === sb.id) {
|
||||
filtered.push({ ...sb, paramVisibility: visibility })
|
||||
} else {
|
||||
const basicSb = allSubBlocks.find((s) => s.id === group.basicId)
|
||||
if (basicSb) {
|
||||
filtered.push({ ...basicSb, paramVisibility: visibility })
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Non-canonical, non-hidden, condition-passing subblock
|
||||
filtered.push({ ...sb, paramVisibility: visibility })
|
||||
}
|
||||
|
||||
return {
|
||||
toolConfig,
|
||||
subBlocks: filtered,
|
||||
oauthConfig: toolConfig.oauth,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error getting subblocks for tool input:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const pulseParserTool: ToolConfig<PulseParserInput, PulseParserOutput> =
|
||||
file: {
|
||||
type: 'file',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
visibility: 'user-only',
|
||||
description: 'Document file to be processed',
|
||||
},
|
||||
fileUpload: {
|
||||
@@ -268,7 +268,7 @@ export const pulseParserV2Tool: ToolConfig<PulseParserV2Input, PulseParserOutput
|
||||
file: {
|
||||
type: 'file',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
visibility: 'user-only',
|
||||
description: 'Document to be processed',
|
||||
},
|
||||
pages: pulseParserTool.params.pages,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
|
||||
file: {
|
||||
type: 'file',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
visibility: 'user-only',
|
||||
description: 'Document file to be processed',
|
||||
},
|
||||
fileUpload: {
|
||||
@@ -196,7 +196,7 @@ export const reductoParserV2Tool: ToolConfig<ReductoParserV2Input, ReductoParser
|
||||
file: {
|
||||
type: 'file',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
visibility: 'user-only',
|
||||
description: 'PDF document to be processed',
|
||||
},
|
||||
pages: reductoParserTool.params.pages,
|
||||
|
||||
@@ -53,7 +53,7 @@ export const sftpUploadTool: ToolConfig<SftpUploadParams, SftpUploadResult> = {
|
||||
files: {
|
||||
type: 'file[]',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
visibility: 'user-only',
|
||||
description: 'Files to upload',
|
||||
},
|
||||
fileContent: {
|
||||
|
||||
@@ -106,7 +106,7 @@ export const visionToolV2: ToolConfig<VisionV2Params, VisionResponse> = {
|
||||
imageFile: {
|
||||
type: 'file',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
visibility: 'user-only',
|
||||
description: 'Image file to analyze',
|
||||
},
|
||||
model: visionTool.params.model,
|
||||
|
||||
@@ -27,7 +27,7 @@ export const uploadMediaTool: ToolConfig<WordPressUploadMediaParams, WordPressUp
|
||||
file: {
|
||||
type: 'file',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
visibility: 'user-only',
|
||||
description: 'File to upload (UserFile object)',
|
||||
},
|
||||
filename: {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const workflowExecutorTool: ToolConfig<
|
||||
workflowId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
visibility: 'user-only',
|
||||
description: 'The ID of the workflow to execute',
|
||||
},
|
||||
inputMapping: {
|
||||
|
||||
Reference in New Issue
Block a user