mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-12 07:24:55 -05:00
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
This commit is contained in:
@@ -1,410 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { Combobox, Switch } from '@/components/emcn'
|
||||
import {
|
||||
CheckboxList,
|
||||
Code,
|
||||
DocumentSelector,
|
||||
DocumentTagEntry,
|
||||
FileSelectorInput,
|
||||
FileUpload,
|
||||
FolderSelectorInput,
|
||||
KnowledgeBaseSelector,
|
||||
KnowledgeTagFilters,
|
||||
LongInput,
|
||||
ProjectSelectorInput,
|
||||
SheetSelectorInput,
|
||||
ShortInput,
|
||||
SlackSelectorInput,
|
||||
SliderInput,
|
||||
Table,
|
||||
TimeInput,
|
||||
WorkflowSelectorInput,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { isPasswordParameter } from '@/tools/params'
|
||||
|
||||
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
|
||||
previewContextValues?: Record<string, unknown>
|
||||
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a subblock component inside tool-input by bridging the subblock store
|
||||
* with StoredTool.params via a synthetic store key.
|
||||
*
|
||||
* Replaces the 17+ individual SyncWrapper components that previously existed.
|
||||
* Components read/write to the store at a synthetic ID, and two effects
|
||||
* handle bidirectional sync with tool.params.
|
||||
*/
|
||||
export function ToolSubBlockRenderer({
|
||||
blockId,
|
||||
subBlockId,
|
||||
toolIndex,
|
||||
subBlock,
|
||||
effectiveParamId,
|
||||
toolParams,
|
||||
onParamChange,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
wandControlRef,
|
||||
}: ToolSubBlockRendererProps) {
|
||||
const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}`
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId)
|
||||
|
||||
// Gate the component using the same dependsOn logic as SubBlock
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
previewContextValues,
|
||||
})
|
||||
|
||||
const toolParamValue = toolParams?.[effectiveParamId] ?? ''
|
||||
|
||||
/** Tracks the last value we wrote to the store from tool.params to avoid echo loops */
|
||||
const lastInitRef = useRef<string>(toolParamValue)
|
||||
/** Tracks the last value we synced back to tool.params from the store */
|
||||
const lastSyncRef = useRef<string>(toolParamValue)
|
||||
|
||||
// Init effect: push tool.params value into the store when it changes externally
|
||||
useEffect(() => {
|
||||
if (toolParamValue !== lastInitRef.current) {
|
||||
lastInitRef.current = toolParamValue
|
||||
lastSyncRef.current = toolParamValue
|
||||
setStoreValue(toolParamValue)
|
||||
}
|
||||
}, [toolParamValue, setStoreValue])
|
||||
|
||||
// Sync effect: when the store changes (user interaction), push back to tool.params
|
||||
useEffect(() => {
|
||||
if (storeValue == null) return
|
||||
const stringValue = typeof storeValue === 'string' ? storeValue : JSON.stringify(storeValue)
|
||||
if (stringValue !== lastSyncRef.current) {
|
||||
lastSyncRef.current = stringValue
|
||||
lastInitRef.current = stringValue
|
||||
onParamChange(toolIndex, effectiveParamId, stringValue)
|
||||
}
|
||||
}, [storeValue, toolIndex, effectiveParamId, onParamChange])
|
||||
|
||||
// Initialize the store on first mount
|
||||
const hasInitializedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRef.current && toolParamValue) {
|
||||
hasInitializedRef.current = true
|
||||
setStoreValue(toolParamValue)
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const configWithSyntheticId = { ...subBlock, id: syntheticId }
|
||||
|
||||
return renderSubBlockComponent({
|
||||
blockId,
|
||||
syntheticId,
|
||||
config: configWithSyntheticId,
|
||||
subBlock,
|
||||
disabled: finalDisabled,
|
||||
previewContextValues,
|
||||
wandControlRef,
|
||||
toolParamValue,
|
||||
onParamChange: useCallback(
|
||||
(value: string) => onParamChange(toolIndex, effectiveParamId, value),
|
||||
[toolIndex, effectiveParamId, onParamChange]
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
interface RenderContext {
|
||||
blockId: string
|
||||
syntheticId: string
|
||||
config: BlockSubBlockConfig
|
||||
subBlock: BlockSubBlockConfig
|
||||
disabled: boolean
|
||||
previewContextValues?: Record<string, unknown>
|
||||
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
|
||||
toolParamValue: string
|
||||
onParamChange: (value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the appropriate component for a subblock type.
|
||||
* Mirrors the switch cases in SubBlock's renderInput(), using
|
||||
* the same component props pattern.
|
||||
*/
|
||||
function renderSubBlockComponent(ctx: RenderContext): React.ReactNode {
|
||||
const {
|
||||
blockId,
|
||||
syntheticId,
|
||||
config,
|
||||
subBlock,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
wandControlRef,
|
||||
toolParamValue,
|
||||
onParamChange,
|
||||
} = ctx
|
||||
|
||||
switch (subBlock.type) {
|
||||
case 'short-input':
|
||||
return (
|
||||
<ShortInput
|
||||
blockId={blockId}
|
||||
subBlockId={syntheticId}
|
||||
placeholder={subBlock.placeholder}
|
||||
password={subBlock.password || isPasswordParameter(subBlock.id)}
|
||||
config={config}
|
||||
disabled={disabled}
|
||||
wandControlRef={wandControlRef}
|
||||
hideInternalWand={true}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'long-input':
|
||||
return (
|
||||
<LongInput
|
||||
blockId={blockId}
|
||||
subBlockId={syntheticId}
|
||||
placeholder={subBlock.placeholder}
|
||||
rows={subBlock.rows}
|
||||
config={config}
|
||||
disabled={disabled}
|
||||
wandControlRef={wandControlRef}
|
||||
hideInternalWand={true}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'dropdown':
|
||||
return (
|
||||
<Combobox
|
||||
options={
|
||||
(subBlock.options as { label: string; id: string }[] | undefined)
|
||||
?.filter((option) => option.id !== '')
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
})) || []
|
||||
}
|
||||
value={toolParamValue}
|
||||
onChange={onParamChange}
|
||||
placeholder={subBlock.placeholder || 'Select option'}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'switch':
|
||||
return (
|
||||
<Switch
|
||||
checked={toolParamValue === 'true' || toolParamValue === 'True'}
|
||||
onCheckedChange={(checked) => onParamChange(checked ? 'true' : 'false')}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'code':
|
||||
return (
|
||||
<Code
|
||||
blockId={blockId}
|
||||
subBlockId={syntheticId}
|
||||
placeholder={subBlock.placeholder}
|
||||
language={subBlock.language}
|
||||
generationType={subBlock.generationType}
|
||||
value={typeof subBlock.value === 'function' ? subBlock.value({}) : undefined}
|
||||
disabled={disabled}
|
||||
wandConfig={
|
||||
subBlock.wandConfig || {
|
||||
enabled: false,
|
||||
prompt: '',
|
||||
placeholder: '',
|
||||
}
|
||||
}
|
||||
wandControlRef={wandControlRef}
|
||||
hideInternalWand={true}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'channel-selector':
|
||||
case 'user-selector':
|
||||
return (
|
||||
<SlackSelectorInput
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'project-selector':
|
||||
return (
|
||||
<ProjectSelectorInput
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'file-selector':
|
||||
return (
|
||||
<FileSelectorInput
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'sheet-selector':
|
||||
return (
|
||||
<SheetSelectorInput
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'folder-selector':
|
||||
return (
|
||||
<FolderSelectorInput
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'knowledge-base-selector':
|
||||
return <KnowledgeBaseSelector blockId={blockId} subBlock={config} disabled={disabled} />
|
||||
|
||||
case 'document-selector':
|
||||
return (
|
||||
<DocumentSelector
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'document-tag-entry':
|
||||
return (
|
||||
<DocumentTagEntry
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'knowledge-tag-filters':
|
||||
return (
|
||||
<KnowledgeTagFilters
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'table':
|
||||
return (
|
||||
<Table
|
||||
blockId={blockId}
|
||||
subBlockId={syntheticId}
|
||||
columns={subBlock.columns ?? []}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'slider':
|
||||
return (
|
||||
<SliderInput
|
||||
blockId={blockId}
|
||||
subBlockId={syntheticId}
|
||||
min={subBlock.min}
|
||||
max={subBlock.max}
|
||||
step={subBlock.step}
|
||||
integer={subBlock.integer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'checkbox-list':
|
||||
return (
|
||||
<CheckboxList
|
||||
blockId={blockId}
|
||||
subBlockId={syntheticId}
|
||||
title={subBlock.title ?? ''}
|
||||
options={subBlock.options as { label: string; id: string }[]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'time-input':
|
||||
return (
|
||||
<TimeInput
|
||||
blockId={blockId}
|
||||
subBlockId={syntheticId}
|
||||
placeholder={subBlock.placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'file-upload':
|
||||
return (
|
||||
<FileUpload
|
||||
blockId={blockId}
|
||||
subBlockId={syntheticId}
|
||||
acceptedTypes={subBlock.acceptedTypes || '*'}
|
||||
multiple={subBlock.multiple === true}
|
||||
maxSize={subBlock.maxSize}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'combobox':
|
||||
return (
|
||||
<Combobox
|
||||
options={((subBlock.options as { label: string; id: string }[] | undefined) || []).map(
|
||||
(opt) => ({
|
||||
label: opt.label,
|
||||
value: opt.id,
|
||||
})
|
||||
)}
|
||||
value={toolParamValue}
|
||||
onChange={onParamChange}
|
||||
placeholder={subBlock.placeholder || 'Select option'}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'workflow-selector':
|
||||
return <WorkflowSelectorInput blockId={blockId} subBlock={config} disabled={disabled} />
|
||||
|
||||
case 'oauth-input':
|
||||
// OAuth inputs are handled separately by ToolCredentialSelector in the parent
|
||||
return null
|
||||
|
||||
default:
|
||||
return (
|
||||
<ShortInput
|
||||
blockId={blockId}
|
||||
subBlockId={syntheticId}
|
||||
placeholder={subBlock.placeholder}
|
||||
password={isPasswordParameter(subBlock.id)}
|
||||
config={config}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'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-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{title}
|
||||
{isRequired && visibility === 'user-only' && <span className='ml-0.5'>*</span>}
|
||||
{visibility !== 'user-only' && (
|
||||
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>(optional)</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, useMemo, 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the subblock store with StoredTool.params via a synthetic store key,
|
||||
* then delegates all rendering to SubBlock for full parity.
|
||||
*
|
||||
* Two effects handle bidirectional sync:
|
||||
* - tool.params → store (external changes)
|
||||
* - store → tool.params (user interaction)
|
||||
*/
|
||||
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] ?? ''
|
||||
|
||||
/** Tracks the last value we pushed to the store from tool.params to avoid echo loops */
|
||||
const lastPushedToStoreRef = useRef<string | null>(null)
|
||||
/** Tracks the last value we synced back to tool.params from the store */
|
||||
const lastPushedToParamsRef = useRef<string | null>(null)
|
||||
|
||||
// Sync tool.params → store: push when the prop value changes (including first mount)
|
||||
useEffect(() => {
|
||||
if (!toolParamValue && lastPushedToStoreRef.current === null) {
|
||||
// Skip initializing the store with an empty value on first mount —
|
||||
// let the SubBlock component use its own default.
|
||||
lastPushedToStoreRef.current = toolParamValue
|
||||
lastPushedToParamsRef.current = toolParamValue
|
||||
return
|
||||
}
|
||||
if (toolParamValue !== lastPushedToStoreRef.current) {
|
||||
lastPushedToStoreRef.current = toolParamValue
|
||||
lastPushedToParamsRef.current = toolParamValue
|
||||
setStoreValue(toolParamValue)
|
||||
}
|
||||
}, [toolParamValue, setStoreValue])
|
||||
|
||||
// Sync store → tool.params: push when the user changes the value via SubBlock
|
||||
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])
|
||||
|
||||
// Determine if the parameter is optional for the user (LLM can fill it)
|
||||
const visibility = subBlock.paramVisibility ?? 'user-or-llm'
|
||||
const isOptionalForUser = visibility !== 'user-only'
|
||||
|
||||
const labelSuffix = useMemo(
|
||||
() =>
|
||||
isOptionalForUser ? (
|
||||
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>(optional)</span>
|
||||
) : null,
|
||||
[isOptionalForUser]
|
||||
)
|
||||
|
||||
// Suppress SubBlock's "*" required indicator for optional-for-user params
|
||||
const config = {
|
||||
...subBlock,
|
||||
id: syntheticId,
|
||||
...(isOptionalForUser && { required: false }),
|
||||
}
|
||||
|
||||
return (
|
||||
<SubBlock
|
||||
blockId={blockId}
|
||||
config={config}
|
||||
isPreview={false}
|
||||
disabled={disabled}
|
||||
canonicalToggle={canonicalToggle}
|
||||
labelSuffix={labelSuffix}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -76,6 +76,7 @@ interface SubBlockProps {
|
||||
disabled?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
labelSuffix?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,7 +203,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
|
||||
@@ -218,6 +220,7 @@ const renderLabel = (
|
||||
<Label className='flex items-center gap-[6px] whitespace-nowrap'>
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
{labelSuffix}
|
||||
{config.type === 'code' &&
|
||||
config.language === 'json' &&
|
||||
!isValidJson &&
|
||||
@@ -385,7 +388,8 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
|
||||
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
|
||||
canonicalToggleEqual
|
||||
canonicalToggleEqual &&
|
||||
prevProps.labelSuffix === nextProps.labelSuffix
|
||||
)
|
||||
}
|
||||
|
||||
@@ -415,6 +419,7 @@ function SubBlockComponent({
|
||||
fieldDiffStatus,
|
||||
allowExpandInPreview,
|
||||
canonicalToggle,
|
||||
labelSuffix,
|
||||
}: SubBlockProps): JSX.Element {
|
||||
const [isValidJson, setIsValidJson] = useState(true)
|
||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||
@@ -1059,7 +1064,8 @@ function SubBlockComponent({
|
||||
showCopyButton: Boolean(config.showCopyButton && config.useWebhookUrl),
|
||||
copied,
|
||||
onCopy: handleCopy,
|
||||
}
|
||||
},
|
||||
labelSuffix
|
||||
)}
|
||||
{renderInput()}
|
||||
</div>
|
||||
|
||||
@@ -907,8 +907,7 @@ export function getSubBlocksForToolInput(
|
||||
parts.length >= 3 ? parts.slice(2).join('_') : parts[parts.length - 1]
|
||||
}
|
||||
|
||||
// Build a set of param IDs from the tool config for fallback visibility inference
|
||||
const toolParamIds = new Set(Object.keys(toolConfig.params || {}))
|
||||
// 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] =
|
||||
@@ -945,16 +944,21 @@ export function getSubBlocksForToolInput(
|
||||
// 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 (!toolParamIds.has(sb.id)) {
|
||||
if (!(sb.id in toolParamVisibility)) {
|
||||
visibility = 'hidden'
|
||||
} else {
|
||||
visibility = toolParamVisibility[sb.id] || 'user-or-llm'
|
||||
}
|
||||
} else if (toolParamIds.has(effectiveParamId)) {
|
||||
} else if (effectiveParamId in toolParamVisibility) {
|
||||
// Fallback: infer from tool param visibility
|
||||
visibility = toolParamVisibility[effectiveParamId]
|
||||
} else if (toolParamIds.has(sb.id)) {
|
||||
} 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
|
||||
@@ -987,16 +991,16 @@ export function getSubBlocksForToolInput(
|
||||
// Find the advanced variant
|
||||
const advancedSb = allSubBlocks.find((s) => group.advancedIds.includes(s.id))
|
||||
if (advancedSb) {
|
||||
filtered.push(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)
|
||||
filtered.push({ ...sb, paramVisibility: visibility })
|
||||
} else {
|
||||
const basicSb = allSubBlocks.find((s) => s.id === group.basicId)
|
||||
if (basicSb) {
|
||||
filtered.push(basicSb)
|
||||
filtered.push({ ...basicSb, paramVisibility: visibility })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1005,7 +1009,7 @@ export function getSubBlocksForToolInput(
|
||||
}
|
||||
|
||||
// Non-canonical, non-hidden, condition-passing subblock
|
||||
filtered.push(sb)
|
||||
filtered.push({ ...sb, paramVisibility: visibility })
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user